diff --git a/data/20260324T000138Z-zur1.ccr b/data/20260324T000138Z-zur1.ccr deleted file mode 100644 index 3040cc1..0000000 Binary files a/data/20260324T000138Z-zur1.ccr and /dev/null differ diff --git a/deploy/Dockerfile b/deploy/Dockerfile index fd19891..75da52e 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,7 +1,17 @@ -FROM rust:1.86-bookworm AS builder +FROM rust:1.89-bookworm AS builder WORKDIR /build +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + pkg-config \ + clang \ + libclang-dev \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + COPY Cargo.toml Cargo.lock ./ COPY src ./src @@ -31,4 +41,4 @@ ENV RPKI_RTR_ENABLE_TLS=false \ EXPOSE 323 324 -CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/rpki-rtr.conf"] +CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/rpki-rtr.conf"] \ No newline at end of file diff --git a/deploy/Dockerfile.client b/deploy/Dockerfile.client new file mode 100644 index 0000000..8aff6d3 --- /dev/null +++ b/deploy/Dockerfile.client @@ -0,0 +1,24 @@ +FROM rust:1.89-bookworm AS builder + +WORKDIR /build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends clang libclang-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock ./ +COPY src ./src + +RUN cargo build --release --bin rtr_debug_client + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=builder /build/target/release/rtr_debug_client /usr/local/bin/rtr_debug_client + +ENTRYPOINT ["/usr/local/bin/rtr_debug_client"] diff --git a/deploy/docker-compose.client.yml b/deploy/docker-compose.client.yml new file mode 100644 index 0000000..d7c9f4c --- /dev/null +++ b/deploy/docker-compose.client.yml @@ -0,0 +1,10 @@ +version: "3.9" + +services: + rtr-debug-client: + build: + context: .. + dockerfile: deploy/Dockerfile.client + image: rpki-rtr-debug-client:latest + stdin_open: true + tty: true diff --git a/deploy/docker-compose.clients.yml b/deploy/docker-compose.clients.yml new file mode 100644 index 0000000..4c1ad96 --- /dev/null +++ b/deploy/docker-compose.clients.yml @@ -0,0 +1,32 @@ +version: "3.9" + +services: + rtr-client-1: + image: rpki-rtr-debug-client:latest + network_mode: host + command: ["127.0.0.1:323", "2", "reset", "--keep-after-error", "--summary-only"] + restart: unless-stopped + + rtr-client-2: + image: rpki-rtr-debug-client:latest + network_mode: host + command: ["127.0.0.1:323", "2", "reset", "--keep-after-error", "--summary-only"] + restart: unless-stopped + + rtr-client-3: + image: rpki-rtr-debug-client:latest + network_mode: host + command: ["127.0.0.1:323", "2", "reset", "--keep-after-error", "--summary-only"] + restart: unless-stopped + + rtr-client-4: + image: rpki-rtr-debug-client:latest + network_mode: host + command: ["127.0.0.1:323", "2", "reset", "--keep-after-error", "--summary-only"] + restart: unless-stopped + + rtr-client-5: + image: rpki-rtr-debug-client:latest + network_mode: host + command: ["127.0.0.1:323", "2", "reset", "--keep-after-error", "--summary-only"] + restart: unless-stopped diff --git a/src/bin/rtr_debug_client/main.rs b/src/bin/rtr_debug_client/main.rs index 0f96c0c..9fa6c57 100644 --- a/src/bin/rtr_debug_client/main.rs +++ b/src/bin/rtr_debug_client/main.rs @@ -1,4 +1,5 @@ use std::env; +use std::future::pending; use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -27,6 +28,12 @@ impl AsyncStream for T where T: AsyncRead + AsyncWrite + Unpin + Send {} type DynStream = Box; type ClientWriter = WriteHalf; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputMode { + Verbose, + SummaryOnly, +} + #[tokio::main] async fn main() -> io::Result<()> { let config = Config::from_args()?; @@ -41,6 +48,7 @@ async fn main() -> io::Result<()> { config.default_poll_secs ); println!("keep-after-error: {}", config.keep_after_error); + println!("output : {}", config.output_mode.describe()); match &config.mode { QueryMode::Reset => { println!("mode : reset"); @@ -59,10 +67,12 @@ async fn main() -> io::Result<()> { config.read_timeout_secs, config.default_poll_secs, config.keep_after_error, + config.output_mode, ); let stdin = tokio::io::stdin(); let mut stdin_lines = BufReader::new(stdin).lines(); + let mut stdin_closed = false; loop { let stream = loop { @@ -89,7 +99,13 @@ async fn main() -> io::Result<()> { tokio::pin!(poll_sleep); tokio::select! { - line = stdin_lines.next_line() => { + line = async { + if stdin_closed { + pending::>>().await + } else { + stdin_lines.next_line().await + } + } => { match line { Ok(Some(line)) => { match handle_console_command( @@ -111,7 +127,8 @@ async fn main() -> io::Result<()> { } } Ok(None) => { - println!("stdin closed, continue network loop."); + stdin_closed = true; + println!("stdin closed, disable console input."); } Err(err) => { eprintln!("read stdin failed: {}", err); @@ -136,8 +153,11 @@ async fn main() -> io::Result<()> { ) => { match read_result { Ok(Ok(pdu)) => { - print_raw_pdu(&pdu.header, &pdu.body); - print_pdu(&pdu.header, &pdu.body); + state.observe_pdu(&pdu.header); + if should_print_pdu(state.output_mode, &pdu.header) { + print_raw_pdu(&pdu.header, &pdu.body); + print_pdu(&pdu.header, &pdu.body); + } match handle_incoming_pdu(&mut writer, &mut state, &pdu.header, &pdu.body).await { Ok(()) => {} Err(err) if should_reconnect(&err) => { @@ -177,7 +197,13 @@ async fn main() -> io::Result<()> { loop { tokio::select! { _ = &mut reconnect_sleep => break, - line = stdin_lines.next_line() => { + line = async { + if stdin_closed { + pending::>>().await + } else { + stdin_lines.next_line().await + } + } => { match line { Ok(Some(line)) => { match handle_console_command(&line, None, &mut state).await { @@ -197,7 +223,8 @@ async fn main() -> io::Result<()> { } } Ok(None) => { - println!("stdin closed, continue reconnect loop."); + stdin_closed = true; + println!("stdin closed, disable console input."); } Err(err) => { eprintln!("read stdin failed: {}", err); @@ -326,6 +353,16 @@ async fn handle_incoming_pdu( ); } + if state.output_mode == OutputMode::SummaryOnly + && state.skipped_payload_pdu_count_in_round > 0 + { + println!( + "summary : skipped {} payload PDUs in this response", + state.skipped_payload_pdu_count_in_round + ); + state.skipped_payload_pdu_count_in_round = 0; + } + println!("received EndOfData, keep connection open."); println!(); } @@ -370,6 +407,7 @@ async fn handle_incoming_pdu( state.current_session_id = None; state.serial = None; state.last_error_code = None; + state.skipped_payload_pdu_count_in_round = 0; send_reset_query(writer, state.version).await?; state.schedule_next_poll(); println!(); @@ -604,6 +642,21 @@ async fn handle_console_command( return Ok(true); } + ["output"] => { + println!("current output mode: {}", state.output_mode.describe()); + println!("skipped payload PDUs: {}", state.skipped_payload_pdu_count); + } + + ["output", "verbose"] => { + state.output_mode = OutputMode::Verbose; + println!("updated output mode to verbose"); + } + + ["output", "summary"] => { + state.output_mode = OutputMode::SummaryOnly; + println!("updated output mode to summary"); + } + _ => { println!("unknown command: {}", line); print_help(); @@ -629,6 +682,9 @@ fn print_help() { println!(" poll pause pause auto polling"); println!(" poll resume resume auto polling"); println!(" keep-after-error show current keep-after-error setting"); + println!(" output show current output mode"); + println!(" output verbose print all PDUs"); + println!(" output summary suppress payload PDU details"); println!(" quit exit client"); println!(); } @@ -648,6 +704,8 @@ fn print_state(state: &ClientState) { println!(" poll_source : {}", state.poll_interval_source()); println!(" last_error_code : {:?}", state.last_error_code); println!(" keep_after_error : {}", state.keep_after_error); + println!(" output_mode : {}", state.output_mode.describe()); + println!(" skipped_payloads : {}", state.skipped_payload_pdu_count); println!(" poll_paused : {}", state.poll_paused); println!(); } @@ -664,6 +722,9 @@ struct ClientState { expire: Option, last_error_code: Option, keep_after_error: bool, + output_mode: OutputMode, + skipped_payload_pdu_count: u64, + skipped_payload_pdu_count_in_round: u64, read_timeout_secs: u64, default_poll_secs: u64, @@ -679,6 +740,7 @@ impl ClientState { read_timeout_secs: u64, default_poll_secs: u64, keep_after_error: bool, + output_mode: OutputMode, ) -> Self { Self { version, @@ -690,6 +752,9 @@ impl ClientState { expire: None, last_error_code: None, keep_after_error, + output_mode, + skipped_payload_pdu_count: 0, + skipped_payload_pdu_count_in_round: 0, read_timeout_secs, default_poll_secs, next_poll_deadline: Instant::now() + Duration::from_secs(default_poll_secs), @@ -770,6 +835,14 @@ impl ClientState { false } } + + fn observe_pdu(&mut self, header: &PduHeader) { + if self.output_mode == OutputMode::SummaryOnly && is_payload_pdu(header) { + self.skipped_payload_pdu_count = self.skipped_payload_pdu_count.saturating_add(1); + self.skipped_payload_pdu_count_in_round = + self.skipped_payload_pdu_count_in_round.saturating_add(1); + } + } } #[derive(Debug)] @@ -781,6 +854,7 @@ struct Config { default_poll_secs: u64, transport: TransportConfig, keep_after_error: bool, + output_mode: OutputMode, } impl Config { @@ -791,6 +865,7 @@ impl Config { let mut read_timeout_secs = DEFAULT_READ_TIMEOUT_SECS; let mut default_poll_secs = DEFAULT_POLL_INTERVAL_SECS; let mut keep_after_error = false; + let mut output_mode = OutputMode::Verbose; while let Some(arg) = args.next() { match arg.as_str() { @@ -841,6 +916,9 @@ impl Config { "--keep-after-error" => { keep_after_error = true; } + "--summary-only" => { + output_mode = OutputMode::SummaryOnly; + } _ if arg.starts_with("--") => { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -924,10 +1002,34 @@ impl Config { default_poll_secs, transport, keep_after_error, + output_mode, }) } } +impl OutputMode { + fn describe(self) -> &'static str { + match self { + Self::Verbose => "verbose", + Self::SummaryOnly => "summary", + } + } +} + +fn is_payload_pdu(header: &PduHeader) -> bool { + matches!( + header.pdu_type(), + PduType::Ipv4Prefix | PduType::Ipv6Prefix | PduType::RouterKey | PduType::Aspa + ) +} + +fn should_print_pdu(output_mode: OutputMode, header: &PduHeader) -> bool { + match output_mode { + OutputMode::Verbose => true, + OutputMode::SummaryOnly => !is_payload_pdu(header), + } +} + #[derive(Debug, Clone)] enum TransportConfig { Tcp, diff --git a/src/rtr/session.rs b/src/rtr/session.rs index 202c0ad..e578270 100644 --- a/src/rtr/session.rs +++ b/src/rtr/session.rs @@ -668,13 +668,14 @@ where ( cache.session_id_for_version(version), cache.serial_for_version(version), - ) + ) }; + self.write_cache_response(current_session).await?; self.write_end_of_data(current_session, current_serial) .await?; info!( - "RTR session replied EndOfData (up-to-date) to Serial Query: client_session_id={}, client_serial={}, response_session_id={}, response_serial={}, {}", + "RTR session replied CacheResponse+EndOfData (up-to-date) to Serial Query: client_session_id={}, client_serial={}, response_session_id={}, response_serial={}, {}", client_session, client_serial, current_session, diff --git a/src/slurm/policy.rs b/src/slurm/policy.rs index e721864..d959c81 100644 --- a/src/slurm/policy.rs +++ b/src/slurm/policy.rs @@ -268,6 +268,12 @@ pub struct AspaAssertion { impl AspaAssertion { fn validate(&self) -> Result<(), SlurmError> { + if self.provider_asns.contains(&self.customer_asn) { + return Err(SlurmError::Invalid( + "aspaAssertion providerAsns must not contain customerAsn".to_string(), + )); + } + let providers = self .provider_asns .iter() diff --git a/src/slurm/serde.rs b/src/slurm/serde.rs index 2e2001f..bd421c5 100644 --- a/src/slurm/serde.rs +++ b/src/slurm/serde.rs @@ -289,8 +289,9 @@ impl<'de> Deserialize<'de> for AspaAssertion { } fn decode_ski(input: &str) -> Result { - let bytes = hex::decode(input) - .map_err(|err| SlurmError::Invalid(format!("invalid SKI '{}': {}", input, err)))?; + let bytes = STANDARD_NO_PAD + .decode(input) + .map_err(|err| SlurmError::Invalid(format!("invalid SKI base64 '{}': {}", input, err)))?; if bytes.len() != 20 { return Err(SlurmError::Invalid(format!( "SKI must be exactly 20 bytes, got {}", diff --git a/tests/test_slurm.rs b/tests/test_slurm.rs index 2a47a6c..0fc0c1c 100644 --- a/tests/test_slurm.rs +++ b/tests/test_slurm.rs @@ -20,9 +20,13 @@ fn sample_ski() -> [u8; 20] { ] } +fn sample_ski_b64() -> String { + STANDARD_NO_PAD.encode(sample_ski()) +} + #[test] fn parses_rfc8416_v1_slurm() { - let ski_hex = hex::encode(sample_ski()); + let ski_b64 = sample_ski_b64(); let router_public_key = STANDARD_NO_PAD.encode(sample_spki()); let json = format!( r#"{{ @@ -32,7 +36,7 @@ fn parses_rfc8416_v1_slurm() { {{ "prefix": "192.0.2.0/24", "asn": 64496, "comment": "drop roa" }} ], "bgpsecFilters": [ - {{ "asn": 64497, "SKI": "{ski_hex}" }} + {{ "asn": 64497, "SKI": "{ski_b64}" }} ] }}, "locallyAddedAssertions": {{ @@ -40,7 +44,7 @@ fn parses_rfc8416_v1_slurm() { {{ "prefix": "198.51.100.0/24", "asn": 64500, "maxPrefixLength": 24 }} ], "bgpsecAssertions": [ - {{ "asn": 64501, "SKI": "{ski_hex}", "routerPublicKey": "{router_public_key}" }} + {{ "asn": 64501, "SKI": "{ski_b64}", "routerPublicKey": "{router_public_key}" }} ] }} }}"# @@ -145,7 +149,7 @@ fn applies_filters_before_assertions_and_excludes_duplicates() { let ski = Ski::from_bytes(sample_ski()); let spki = sample_spki(); let spki_b64 = STANDARD_NO_PAD.encode(&spki); - let ski_hex = hex::encode(sample_ski()); + let ski_b64 = sample_ski_b64(); let json = format!( r#"{{ "slurmVersion": 2, @@ -154,7 +158,7 @@ fn applies_filters_before_assertions_and_excludes_duplicates() { {{ "prefix": "192.0.2.0/24", "asn": 64496 }} ], "bgpsecFilters": [ - {{ "SKI": "{ski_hex}" }} + {{ "SKI": "{ski_b64}" }} ], "aspaFilters": [ {{ "customerAsn": 64496 }} @@ -166,7 +170,7 @@ fn applies_filters_before_assertions_and_excludes_duplicates() { {{ "prefix": "198.51.100.0/24", "asn": 64500, "maxPrefixLength": 24 }} ], "bgpsecAssertions": [ - {{ "asn": 64501, "SKI": "{ski_hex}", "routerPublicKey": "{spki_b64}" }} + {{ "asn": 64501, "SKI": "{ski_b64}", "routerPublicKey": "{spki_b64}" }} ], "aspaAssertions": [ {{ "customerAsn": 64510, "providerAsns": [64511, 64512] }} @@ -216,6 +220,55 @@ fn applies_filters_before_assertions_and_excludes_duplicates() { ))); } +#[test] +fn rejects_hex_encoded_ski_and_aspa_customer_in_providers() { + let ski_hex = hex::encode(sample_ski()); + let router_public_key = STANDARD_NO_PAD.encode(sample_spki()); + let invalid_ski = format!( + r#"{{ + "slurmVersion": 1, + "validationOutputFilters": {{ + "prefixFilters": [], + "bgpsecFilters": [ + {{ "SKI": "{ski_hex}" }} + ] + }}, + "locallyAddedAssertions": {{ + "prefixAssertions": [], + "bgpsecAssertions": [ + {{ "asn": 64501, "SKI": "{ski_hex}", "routerPublicKey": "{router_public_key}" }} + ] + }} + }}"# + ); + let ski_err = SlurmFile::from_slice(invalid_ski.as_bytes()).unwrap_err(); + let ski_err_text = ski_err.to_string(); + assert!( + ski_err_text.contains("invalid SKI base64") + || ski_err_text.contains("SKI must be exactly 20 bytes") + ); + + let invalid_aspa = r#"{ + "slurmVersion": 2, + "validationOutputFilters": { + "prefixFilters": [], + "bgpsecFilters": [], + "aspaFilters": [] + }, + "locallyAddedAssertions": { + "prefixAssertions": [], + "bgpsecAssertions": [], + "aspaAssertions": [ + { "customerAsn": 64500, "providerAsns": [64500, 64501] } + ] + } + }"#; + let aspa_err = SlurmFile::from_slice(invalid_aspa.as_bytes()).unwrap_err(); + assert!(aspa_err + .to_string() + .contains("providerAsns must not contain customerAsn")); +} + #[test] fn merges_multiple_slurm_files_without_conflict() { let a = r#"{