Compare commits

..

3 Commits

Author SHA1 Message Date
385af349ae 20261015 实现RRDP失步检测与TA证书择优
Some checks failed
ci / rust (push) Failing after 1m42s
ci / docker (push) Failing after 50s
ci / audit (push) Failing after 8s
2026-10-15 14:31:50 +08:00
0fa0c940fe prepare v0.1.0 release metadata 2026-09-29 17:22:20 +08:00
9c056aac64 add configurable RRDP snapshot and delta timeouts 2026-09-29 16:39:17 +08:00
33 changed files with 1230 additions and 135 deletions

View File

@@ -1,6 +1,14 @@
# Changelog # Changelog
## 0.1.0 — Unreleased ## Unreleased
- Detect RRDP session desynchronization from persisted notification delta hashes
(RFC 9697), warn and recover using the current snapshot before applying deltas.
- Select automatic TA certificates using `draft-ietf-sidrops-rpki-ta-tiebreaker-06`:
prefer later notBefore, then earlier notAfter, then the newly fetched certificate.
A healthy preference for cache preserves a successful validation result.
## 0.1.0 — 2026-09-29
- Vendor five OpenBSD TALs and constraints with pinned provenance; enable policies - Vendor five OpenBSD TALs and constraints with pinned provenance; enable policies
in both all5 Compose examples and add the TOML `ta-constraints-enabled` switch. in both all5 Compose examples and add the TOML `ta-constraints-enabled` switch.

View File

@@ -1,13 +1,22 @@
# Panda RPKI # panda-rpki
Panda RPKI is an RPKI relying party written in Rust. It synchronizes repositories panda-rpki is an open-source RPKI Relying Party (RP) implementation written in Rust.
over RRDP, validates signed objects, and exports routing data as CSV and Canonical It retrieves and validates RPKI data and produces validated routing information for
Cache Representation (CCR). Run a single cycle or use the daemon for continuous operation. routing systems and other applications.
The current v0.1.0 implementation is under active development and testing. It may
contain bugs, incomplete features, or unexpected behavior. Feedback is welcome:
please open an issue or submit a pull request when you find a problem or have an
improvement to propose.
Panda RPKI synchronizes repositories over RRDP, validates signed objects, and exports
routing data as CSV and Canonical Cache Representation (CCR). Run a single cycle or
use the daemon for continuous operation.
Configuration is provided through TOML. See the [TOML configuration reference](docs/configuration.md) Configuration is provided through TOML. See the [TOML configuration reference](docs/configuration.md)
for every field, default, constraint and complete example. for every field, default, constraint and complete example.
This build is validated on Linux x86-64. Other platforms are not yet verified. The v0.1.0 build is validated on Linux x86-64. Other platforms are not yet verified.
Review the [standards and limitations](docs/conformance-matrix.md) and [security Review the [standards and limitations](docs/conformance-matrix.md) and [security
policy](SECURITY.md) before evaluating it for routing use. policy](SECURITY.md) before evaluating it for routing use.
@@ -59,6 +68,8 @@ For continuous operation see [Usage](docs/usage.md#continuous-operation).
- Multiple named anchors, fixed or automatically refreshed trust anchors, and - Multiple named anchors, fixed or automatically refreshed trust anchors, and
optional per-anchor resource constraints. optional per-anchor resource constraints.
- RRDP snapshot, delta, replace/withdraw and protocol fallback. - RRDP snapshot, delta, replace/withdraw and protocol fallback.
- RRDP session desynchronization detection (RFC 9697) and automatic TA
certificate selection (TA tiebreaker draft -06; unreleased).
- HTTPS same-origin checks for RRDP references and redirects. - HTTPS same-origin checks for RRDP references and redirects.
- Independent bounded repository and object worker pools. - Independent bounded repository and object worker pools.
- CA, CRL, manifest, ROA, ASPA and BGPsec router-certificate processing. - CA, CRL, manifest, ROA, ASPA and BGPsec router-certificate processing.
@@ -91,5 +102,5 @@ target/release/panda-rpki --help
## License ## License
Panda RPKI uses the [BSD-3-Clause license](LICENSE). panda-rpki uses the [BSD-3-Clause license](LICENSE).
See [third-party notices](THIRD_PARTY_NOTICES.md) for dependency attribution. See [third-party notices](THIRD_PARTY_NOTICES.md) for dependency attribution.

View File

@@ -8,6 +8,11 @@ ccr-out = "/output/result.ccr"
run-timeout-secs = 1800 run-timeout-secs = 1800
require-complete = true require-complete = true
ta-refresh-policy = "allow-valid-cache" ta-refresh-policy = "allow-valid-cache"
# Notification/TA requests use http-timeout; RRDP data bodies have independent
# per-resource limits and are selected automatically within the same run.
http-timeout-secs = 10
rrdp-snapshot-timeout-secs = 600
rrdp-delta-timeout-secs = 180
# Set false to ignore all constraints files, including adjacent auto-discovery. # Set false to ignore all constraints files, including adjacent auto-discovery.
ta-constraints-enabled = true ta-constraints-enabled = true
log-level = "info" log-level = "info"

View File

@@ -64,7 +64,9 @@ The remaining common keys and defaults are:
| Key | Default | | Key | Default |
| --- | ---: | | --- | ---: |
| `max-ca-depth` | 64 | | `max-ca-depth` | 64 |
| `http-timeout-secs` | 300 (0 is normalized to 1) | | `http-timeout-secs` | 10 (0 is normalized to 1) |
| `rrdp-snapshot-timeout-secs` | 600 (0 is normalized to 1) |
| `rrdp-delta-timeout-secs` | 180 (0 is normalized to 1) |
| `http-root-cert` | `[]` | | `http-root-cert` | `[]` |
| `parallel-max-repo-sync-workers-global` | 8 | | `parallel-max-repo-sync-workers-global` | 8 |
| `parallel-phase2-object-workers` | 8 | | `parallel-phase2-object-workers` | 8 |

View File

@@ -55,7 +55,9 @@ those choices do not change the built-in defaults.
| `ta-constraints-enabled` | Boolean | true | Apply explicit or adjacent TA constraints files. False skips all constraints loading and enforcement, including adjacent discovery; unused missing or malformed files are ignored. | | `ta-constraints-enabled` | Boolean | true | Apply explicit or adjacent TA constraints files. False skips all constraints loading and enforcement, including adjacent discovery; unused missing or malformed files are ignored. |
| `ta-refresh-policy` | String | `allow-valid-cache` | On automatic TA download failure, permit a revalidated, usable cached certificate. `require-fresh` prohibits fallback. Does not replace fixed TA files. | | `ta-refresh-policy` | String | `allow-valid-cache` | On automatic TA download failure, permit a revalidated, usable cached certificate. `require-fresh` prohibits fallback. Does not replace fixed TA files. |
| `max-ca-depth` | Nonnegative integer | 64 | Maximum CA validation-tree depth. Zero is accepted by configuration parsing, but is a depth limit, not unlimited traversal. Usually keep 64. | | `max-ca-depth` | Nonnegative integer | 64 | Maximum CA validation-tree depth. Zero is accepted by configuration parsing, but is a depth limit, not unlimited traversal. Usually keep 64. |
| `http-timeout-secs` | Nonnegative integer, seconds | 300 | HTTP fetch timeout. Zero is normalized to one second, not unlimited. Does not set the whole-run or rsync timeout. | | `http-timeout-secs` | Nonnegative integer, seconds | 10 | Timeout for RRDP notification, TA/TAL and other small HTTP requests. Snapshot/delta bodies use their resource-specific settings below. Zero is normalized to one second, not unlimited. Does not set the whole-run or rsync timeout. |
| `rrdp-snapshot-timeout-secs` | Nonnegative integer, seconds | 600 | Timeout for downloading an RRDP snapshot response body. Zero is normalized to one second. Selected by the RRDP resource type, including after same-origin redirects. |
| `rrdp-delta-timeout-secs` | Nonnegative integer, seconds | 180 | Timeout for downloading an RRDP delta response body. Zero is normalized to one second. Each delta resource uses this value; it is independent of the validation run's overall timeout. |
| `http-root-cert` | Array of path strings | `[]` | Additional HTTP TLS root certificates in PEM format. Every file must be readable and parseable. Empty means no custom additions; system TLS trust remains. These are not RPKI TA certificates. | | `http-root-cert` | Array of path strings | `[]` | Additional HTTP TLS root certificates in PEM format. Every file must be readable and parseable. Empty means no custom additions; system TLS trust remains. These are not RPKI TA certificates. |
| `parallel-max-repo-sync-workers-global` | Positive integer | 8 | Global repository-sync worker count. Zero is rejected. | | `parallel-max-repo-sync-workers-global` | Positive integer | 8 | Global repository-sync worker count. Zero is rejected. |
| `parallel-phase2-object-workers` | Positive integer | 8 | Phase-2 object-processing worker count. Zero is rejected. | | `parallel-phase2-object-workers` | Positive integer | 8 | Phase-2 object-processing worker count. Zero is rejected. |
@@ -154,10 +156,19 @@ no separate background refresh timer, and no invented HTTPS alternative for an
rsync-only TAL. Candidates must pass certificate and TAL-binding validation at rsync-only TAL. Candidates must pass certificate and TAL-binding validation at
the configured logical validation time. the configured logical validation time.
When a fetched candidate and a revalidated cached certificate are both usable,
selection follows `draft-ietf-sidrops-rpki-ta-tiebreaker-06` §2: prefer the later
`notBefore`; if equal, prefer the earlier `notAfter`; if both are equal, use the
fetched candidate. Comparisons use the signed certificate fields. Selecting a
preferred cached certificate after a successful fetch is healthy, including
under `require-fresh`: `trustAnchors[].source` is `cache`, `refreshFailed` is false,
and `selectionReason` explains the choice. It does not increment
`taCacheFallbackCount` or make the run partial.
The cache is `<rrdp-state-dir>.ta-cache`, keyed by the TAL public-key SHA-256. The cache is `<rrdp-state-dir>.ta-cache`, keyed by the TAL public-key SHA-256.
Download failure permits fallback only under allow-valid-cache with a usable, Download failure permits fallback only under allow-valid-cache with a usable,
revalidated cached certificate; that makes the result at least partial. revalidated cached certificate; that makes the result at least partial.
Require-fresh prohibits fallback. Existing fixed `.cer` files do not seed this Require-fresh prohibits fallback after a failed refresh. Existing fixed `.cer` files do not seed this
cache automatically; switching to automatic mode initially needs reachable URIs. cache automatically; switching to automatic mode initially needs reachable URIs.
One failed TA with others completing yields partial; all TAs failing yields One failed TA with others completing yields partial; all TAs failing yields
failed. A fixed-certificate error found during preflight exits 2 immediately. failed. A fixed-certificate error found during preflight exits 2 immediately.
@@ -279,7 +290,9 @@ TA refresh policy does not make an anchor with an explicit ta automatic.
log-format = "json" log-format = "json"
log-level = "debug" log-level = "debug"
log-progress-interval-secs = 0 log-progress-interval-secs = 0
http-timeout-secs = 600 http-timeout-secs = 10 # notification, TA/TAL and other small HTTP requests
rrdp-snapshot-timeout-secs = 600
rrdp-delta-timeout-secs = 180
http-root-cert = ["tls/internal-root.pem"] http-root-cert = ["tls/internal-root.pem"]
parallel-phase2-object-workers = 4 parallel-phase2-object-workers = 4
parallel-max-repo-sync-workers-global = 4 parallel-max-repo-sync-workers-global = 4
@@ -290,6 +303,10 @@ parallel-repo-worker-queue-capacity = 1024
Replace existing top-level settings; do not add duplicate keys. Prepare the Replace existing top-level settings; do not add duplicate keys. Prepare the
custom TLS PEM file first, or keep [] when no extra roots are needed. This does custom TLS PEM file first, or keep [] when no extra roots are needed. This does
not change the TAL public key, relax TA validation or disable system TLS checks. not change the TAL public key, relax TA validation or disable system TLS checks.
The snapshot and delta timeouts are selected per RRDP resource, not per
validation invocation: an `auto` run may use the snapshot value for one
repository and the delta value for another. RRDP notification and TA/TAL HTTP
requests continue to use `http-timeout-secs`.
### 8.4 Validation policy and rsync transport: top-level fragment ### 8.4 Validation policy and rsync transport: top-level fragment

View File

@@ -1,5 +1,22 @@
# v0.1.0 conformance matrix # v0.1.0 conformance matrix
## Unreleased additions
| Behaviour | Standard reference | Implementation | Evidence |
| --- | --- | --- | --- |
| Detect changed hashes for overlapping delta serials within a session, warn and recover with a snapshot | [RFC 9697 §§3–5](https://www.rfc-editor.org/rfc/rfc9697.html) | `src/repository/sync/rrdp/`, `src/repository/storage/models_summary.rs` | `tests_parts/desynchronization.rs`; `tests/security_recovery_e2e.rs` |
| Choose between valid fetched and cached TA certificates by later notBefore, earlier notAfter, then freshly fetched | [TA tiebreaker draft -06 §2](https://datatracker.ietf.org/doc/html/draft-ietf-sidrops-rpki-ta-tiebreaker-06) | `src/cli/anchors.rs` | `anchors_tiebreaker_tests.rs`; `tests/security_recovery_e2e.rs` |
The RRDP hash window is the last successfully processed notification's delta
references, retained across restarts. An old database without this window learns
it on its next successful sync. Failed recovery keeps the previous baseline.
No historical delta download is required for comparison. TA selection compares
revalidated DER certificates; fixed TA inputs retain their explicit selection.
The tiebreaker is a draft implementation. Failed-refresh fallback still follows
`ta-refresh-policy`; see [configuration](configuration.md#5-anchor-fields).
## Released v0.1.0 baseline
| Behaviour | Standard reference | Implementation | Evidence | | Behaviour | Standard reference | Implementation | Evidence |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| TAL syntax and TA public-key binding | RFC 8630 §2 | `src/validation/from_tal.rs`, `src/model/ta.rs` | parser and synthetic integration tests | | TAL syntax and TA public-key binding | RFC 8630 §2 | `src/validation/from_tal.rs`, `src/model/ta.rs` | parser and synthetic integration tests |
@@ -29,6 +46,6 @@ revalidation of the entire trust chain by another relying party.
The worker pool and interval index are implementation mechanisms, not new wire The worker pool and interval index are implementation mechanisms, not new wire
protocols; their tests must also prove deterministic output and unchanged protocols; their tests must also prove deterministic output and unchanged
validation decisions. Only the table entries above are in v0.1.0 scope. validation decisions. Only the released baseline table is in v0.1.0 scope.
Adding a new source file or protocol feature requires a new row with a document Adding a new source file or protocol feature requires a new row with a document
revision, section, test, and reviewer decision. revision, section, test, and reviewer decision.

View File

@@ -36,6 +36,14 @@ TAL public-key hash. Refresh failures may use that cache under
`allow-valid-cache` and produce `partial`; use `require-fresh` to reject the `allow-valid-cache` and produce `partial`; use `require-fresh` to reject the
fallback. fallback.
Automatic TA refresh compares usable fetched and cached certificates using the
[TA tiebreaker rules](configuration.md#5-anchor-fields). A preferred cached
certificate after a successful fetch keeps the run successful. RRDP also checks
the previous notification's delta hashes (RFC 9697); a changed hash within the
same session emits `rrdp_session_desynchronized` and triggers a fresh snapshot,
including when the notification serial has not advanced. This check uses stored
hashes and does not fetch old deltas.
## Multiple anchors and constraints ## Multiple anchors and constraints
Use one named `[[anchors]]` table per RIR. There is no positional TAL/TA pairing: Use one named `[[anchors]]` table per RIR. There is no positional TAL/TA pairing:

View File

@@ -7,7 +7,7 @@ shutdown-grace-secs = 30
# Common settings: these choices are explicit, not all built-in defaults. # Common settings: these choices are explicit, not all built-in defaults.
ta-refresh-policy = "allow-valid-cache" ta-refresh-policy = "allow-valid-cache"
max-ca-depth = 64 max-ca-depth = 64
http-timeout-secs = 300 http-timeout-secs = 10
http-root-cert = [] # optional: ["tls/internal-root.pem"] http-root-cert = [] # optional: ["tls/internal-root.pem"]
parallel-max-repo-sync-workers-global = 8 parallel-max-repo-sync-workers-global = 8
parallel-phase2-object-workers = 8 parallel-phase2-object-workers = 8

View File

@@ -8,7 +8,7 @@ shutdown-grace-secs = 30
# Common settings: these choices are explicit, not all built-in defaults. # Common settings: these choices are explicit, not all built-in defaults.
ta-refresh-policy = "allow-valid-cache" ta-refresh-policy = "allow-valid-cache"
max-ca-depth = 64 max-ca-depth = 64
http-timeout-secs = 300 http-timeout-secs = 10
http-root-cert = [] # optional: ["tls/internal-root.pem"] http-root-cert = [] # optional: ["tls/internal-root.pem"]
parallel-max-repo-sync-workers-global = 8 parallel-max-repo-sync-workers-global = 8
parallel-phase2-object-workers = 8 parallel-phase2-object-workers = 8

View File

@@ -8,7 +8,9 @@ publish-dir = "publish/full"
# Common settings: these choices are explicit, not all built-in defaults. # Common settings: these choices are explicit, not all built-in defaults.
ta-refresh-policy = "allow-valid-cache" ta-refresh-policy = "allow-valid-cache"
max-ca-depth = 64 max-ca-depth = 64
http-timeout-secs = 300 http-timeout-secs = 10
rrdp-snapshot-timeout-secs = 600
rrdp-delta-timeout-secs = 180
http-root-cert = [] # optional: ["tls/internal-root.pem"] http-root-cert = [] # optional: ["tls/internal-root.pem"]
parallel-max-repo-sync-workers-global = 8 parallel-max-repo-sync-workers-global = 8
parallel-phase2-object-workers = 8 parallel-phase2-object-workers = 8

View File

@@ -21,7 +21,7 @@ rsync-scope = "module-root" # "host" or "publication-point"
ta-refresh-policy = "allow-valid-cache" ta-refresh-policy = "allow-valid-cache"
max-ca-depth = 64 max-ca-depth = 64
http-timeout-secs = 300 http-timeout-secs = 10
http-root-cert = [] http-root-cert = []
parallel-max-repo-sync-workers-global = 8 parallel-max-repo-sync-workers-global = 8
parallel-phase2-object-workers = 8 parallel-phase2-object-workers = 8

View File

@@ -44,6 +44,54 @@ pub(crate) fn cache_root(state: &Path) -> PathBuf {
PathBuf::from(name) PathBuf::from(name)
} }
struct CachedTa {
bytes: Vec<u8>,
info: Value,
fetched_at: Value,
}
fn load_cached_ta(
path: &Path,
tal: &Tal,
strict_name: bool,
validation_time: OffsetDateTime,
) -> Result<CachedTa, String> {
let cached: Value = fs::read(path)
.map_err(|error| format!("read cached TA metadata failed: {error}"))
.and_then(|bytes| serde_json::from_slice(&bytes).map_err(|error| error.to_string()))?;
let encoded = cached["der"]
.as_str()
.ok_or("cached TA metadata has no DER value")?;
let bytes =
hex::decode(encoded).map_err(|error| format!("decode cached TA failed: {error}"))?;
let info = validate_certificate(tal, &bytes, strict_name, validation_time)?;
Ok(CachedTa {
bytes,
info,
fetched_at: cached["record"]["fetchedAt"].clone(),
})
}
/// draft-ietf-sidrops-rpki-ta-tiebreaker-06 §2. Both inputs must already
/// pass the current TAL binding, certificate and validity checks. Compare DER
/// fields, never the unauthenticated display dates in the JSON cache record.
fn prefer_download(cached: &[u8], downloaded: &[u8]) -> Result<(bool, &'static str), String> {
use std::cmp::Ordering;
let (_, old) = X509Certificate::from_der(cached).map_err(|error| error.to_string())?;
let (_, new) = X509Certificate::from_der(downloaded).map_err(|error| error.to_string())?;
Ok(
match new.validity().not_before.cmp(&old.validity().not_before) {
Ordering::Greater => (true, "newer_not_before"),
Ordering::Less => (false, "cached_newer_not_before"),
Ordering::Equal => match new.validity().not_after.cmp(&old.validity().not_after) {
Ordering::Less => (true, "shorter_validity"),
Ordering::Greater => (false, "cached_shorter_validity"),
Ordering::Equal => (true, "most_recently_fetched"),
},
},
)
}
pub(crate) fn resolve_at( pub(crate) fn resolve_at(
config: &Config, config: &Config,
validation_time: OffsetDateTime, validation_time: OffsetDateTime,
@@ -99,7 +147,7 @@ fn resolve_with_at(
let mut inputs = Vec::new(); let mut inputs = Vec::new();
let mut records = Vec::new(); let mut records = Vec::new();
for anchor in &config.anchors { for anchor in &config.anchors {
let mut record = json!({"id":anchor.id,"mode":if anchor.ta.is_some(){"fixed"}else{"auto"},"source":Value::Null,"certificateSha256":Value::Null,"fetchedAt":Value::Null,"notBefore":Value::Null,"notAfter":Value::Null,"refreshFailed":false,"errorCode":Value::Null}); let mut record = json!({"id":anchor.id,"mode":if anchor.ta.is_some(){"fixed"}else{"auto"},"source":Value::Null,"certificateSha256":Value::Null,"fetchedAt":Value::Null,"notBefore":Value::Null,"notAfter":Value::Null,"refreshFailed":false,"errorCode":Value::Null,"selectionReason":Value::Null});
let attempt = (|| -> Result<PathBuf, String> { let attempt = (|| -> Result<PathBuf, String> {
let tal = Tal::decode_bytes(&fs::read(&anchor.tal).map_err(|e| e.to_string())?) let tal = Tal::decode_bytes(&fs::read(&anchor.tal).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -115,11 +163,15 @@ fn resolve_with_at(
.unwrap() .unwrap()
.extend(info.as_object().unwrap().clone()); .extend(info.as_object().unwrap().clone());
record["source"] = json!("file"); record["source"] = json!("file");
record["selectionReason"] = json!("fixed_input");
return Ok(path.clone()); return Ok(path.clone());
} }
let key = hex::encode(Sha256::digest(&tal.subject_public_key_info_der)); let key = hex::encode(Sha256::digest(&tal.subject_public_key_info_der));
let path = root.join(format!("{key}.json")); let path = root.join(format!("{key}.json"));
let der_path = root.join(format!("{key}.cer")); let der_path = root.join(format!("{key}.cer"));
// A corrupt/expired/mismatched cache cannot veto a valid download.
// Keep its error for the existing failed-refresh fallback path.
let cached = load_cached_ta(&path, &tal, strict_name, validation_time);
let mut last_error = "no usable TA URI".to_string(); let mut last_error = "no usable TA URI".to_string();
let mut error_code = "download_failed"; let mut error_code = "download_failed";
for uri in &tal.ta_uris { for uri in &tal.ta_uris {
@@ -127,6 +179,29 @@ fn resolve_with_at(
Ok(bytes) => { Ok(bytes) => {
match validate_certificate(&tal, &bytes, strict_name, validation_time) { match validate_certificate(&tal, &bytes, strict_name, validation_time) {
Ok(info) => { Ok(info) => {
let (download, reason) = match &cached {
Ok(old) => prefer_download(&old.bytes, &bytes)?,
Err(_) => (true, "no_valid_cache"),
};
record["selectionReason"] = json!(reason);
if !download {
let old = cached.as_ref().map_err(|error| error.to_string())?;
record
.as_object_mut()
.unwrap()
.extend(old.info.as_object().unwrap().clone());
record["source"] = json!("cache");
record["fetchedAt"] = old.fetched_at.clone();
// Keep the authoritative cache and its actual fetch time;
// repair only the runtime DER projection if necessary.
super::publication::write_atomic(&der_path, &old.bytes)?;
crate::logging::emit(
crate::logging::Level::Info,
"ta_certificate_selected",
|| json!({"tal_id":anchor.id,"source":"cache","reason":reason}),
);
return Ok(der_path);
}
record record
.as_object_mut() .as_object_mut()
.unwrap() .unwrap()
@@ -155,40 +230,17 @@ fn resolve_with_at(
record["refreshFailed"] = json!(true); record["refreshFailed"] = json!(true);
record["errorCode"] = json!(error_code); record["errorCode"] = json!(error_code);
if config.ta_refresh_policy == "allow-valid-cache" && path.exists() { if config.ta_refresh_policy == "allow-valid-cache" && path.exists() {
let cached: Value = match fs::read(&path) let cached = cached.inspect_err(|_| {
.map_err(|e| e.to_string()) record["errorCode"] = json!("cache_unusable");
.and_then(|bytes| serde_json::from_slice(&bytes).map_err(|e| e.to_string())) })?;
{
Ok(value) => value,
Err(error) => {
record["errorCode"] = json!("cache_unusable");
return Err(format!("read cached TA metadata failed: {error}"));
}
};
let bytes = match cached["der"].as_str() {
Some(encoded) => match hex::decode(encoded) {
Ok(bytes) => bytes,
Err(error) => {
record["errorCode"] = json!("cache_unusable");
return Err(format!("decode cached TA failed: {error}"));
}
},
None => {
record["errorCode"] = json!("cache_unusable");
return Err("cached TA metadata has no DER value".into());
}
};
let info = validate_certificate(&tal, &bytes, strict_name, validation_time)
.inspect_err(|_| {
record["errorCode"] = json!("cache_unusable");
})?;
record record
.as_object_mut() .as_object_mut()
.unwrap() .unwrap()
.extend(info.as_object().unwrap().clone()); .extend(cached.info.as_object().unwrap().clone());
record["source"] = json!("cache"); record["source"] = json!("cache");
record["fetchedAt"] = cached["record"]["fetchedAt"].clone(); record["fetchedAt"] = cached.fetched_at;
super::publication::write_atomic(&der_path, &bytes)?; record["selectionReason"] = json!("refresh_failed_valid_cache");
super::publication::write_atomic(&der_path, &cached.bytes)?;
crate::logging::emit( crate::logging::emit(
crate::logging::Level::Warn, crate::logging::Level::Warn,
"ta_refresh_failed", "ta_refresh_failed",
@@ -217,6 +269,10 @@ fn resolve_with_at(
Ok((inputs, records)) Ok((inputs, records))
} }
#[cfg(test)]
#[path = "anchors_tiebreaker_tests.rs"]
mod tiebreaker_tests;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{cache_root, resolve_with}; use super::{cache_root, resolve_with};

View File

@@ -0,0 +1,207 @@
use super::*;
use crate::cli::config::Anchor;
fn auto_config(state: &Path) -> Config {
Config {
config_version: 1,
rrdp_state_dir: Some(state.to_path_buf()),
validation_time: Some("2026-06-01T00:00:00Z".into()),
anchors: vec![Anchor {
id: "custom".into(),
tal: crate::test_support::synthetic_repository().tal(),
ta: None,
constraints: None,
}],
..Default::default()
}
}
fn variant(name: &str) -> Vec<u8> {
let fixture = crate::test_support::synthetic_repository();
fs::read(if name == "base" {
fixture.trust_anchor()
} else {
fixture.ta_variant(name)
})
.unwrap()
}
fn cache_path(config: &Config, extension: &str) -> PathBuf {
let tal = Tal::decode_bytes(&fs::read(&config.anchors[0].tal).unwrap()).unwrap();
cache_root(config.rrdp_state_dir.as_ref().unwrap()).join(format!(
"{}.{}",
hex::encode(Sha256::digest(&tal.subject_public_key_info_der)),
extension
))
}
#[test]
fn tiebreaker_older_download_cannot_replace_newer_cached_ta() {
let temp = tempfile::tempdir().unwrap();
let config = auto_config(&temp.path().join("state"));
let cached = variant("newer");
resolve_with(&config, |_| Ok(cached.clone())).unwrap();
let cache_before = fs::read(cache_path(&config, "json")).unwrap();
let (inputs, records) = resolve_with(&config, |_| Ok(variant("base"))).unwrap();
assert_eq!(inputs.len(), 1);
assert_eq!(fs::read(cache_path(&config, "cer")).unwrap(), cached);
assert_eq!(fs::read(cache_path(&config, "json")).unwrap(), cache_before);
assert_eq!(records[0]["source"], "cache");
assert_eq!(records[0]["refreshFailed"], false);
}
#[test]
fn tiebreaker_equal_start_keeps_shorter_cached_validity() {
let temp = tempfile::tempdir().unwrap();
let config = auto_config(&temp.path().join("state"));
let cached = variant("shorter");
resolve_with(&config, |_| Ok(cached.clone())).unwrap();
resolve_with(&config, |_| Ok(variant("base"))).unwrap();
assert_eq!(fs::read(cache_path(&config, "cer")).unwrap(), cached);
}
#[test]
fn tiebreaker_download_wins_each_precedence_level() {
for (old, new, reason) in [
("shorter", "newer", "newer_not_before"),
("longer", "base", "shorter_validity"),
("base", "equal-period", "most_recently_fetched"),
("base", "base", "most_recently_fetched"),
] {
let temp = tempfile::tempdir().unwrap();
let config = auto_config(&temp.path().join("state"));
resolve_with(&config, |_| Ok(variant(old))).unwrap();
let selected = variant(new);
let (inputs, records) = resolve_with(&config, |_| Ok(selected.clone())).unwrap();
assert_eq!(inputs.len(), 1, "{old} -> {new}");
assert_eq!(records[0]["source"], "download");
assert_eq!(records[0]["selectionReason"], reason);
assert_eq!(records[0]["refreshFailed"], false);
assert_eq!(fs::read(cache_path(&config, "cer")).unwrap(), selected);
let saved: Value =
serde_json::from_slice(&fs::read(cache_path(&config, "json")).unwrap()).unwrap();
assert_eq!(saved["der"], hex::encode(selected));
}
}
#[test]
fn tiebreaker_revalidates_cache_and_ignores_display_metadata() {
let temp = tempfile::tempdir().unwrap();
let config = auto_config(&temp.path().join("state"));
resolve_with(&config, |_| Ok(variant("newer"))).unwrap();
let path = cache_path(&config, "json");
let mut saved: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
saved["record"]["notBefore"] = json!("1900-01-01T00:00:00Z");
saved["record"]["notAfter"] = json!("1901-01-01T00:00:00Z");
saved["record"]["certificateSha256"] = json!("not-the-certificate-hash");
fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap();
fs::write(cache_path(&config, "cer"), b"broken projection").unwrap();
let (_, records) = resolve_with(&config, |_| Ok(variant("base"))).unwrap();
assert_eq!(records[0]["selectionReason"], "cached_newer_not_before");
assert_eq!(
records[0]["certificateSha256"],
hex::encode(Sha256::digest(variant("newer")))
);
assert_ne!(records[0]["notBefore"], saved["record"]["notBefore"]);
assert_eq!(
fs::read(cache_path(&config, "cer")).unwrap(),
variant("newer")
);
}
#[test]
fn tiebreaker_invalid_cached_candidates_cannot_veto_valid_download() {
let mut bad_signature = variant("newer");
*bad_signature.last_mut().unwrap() ^= 1;
let candidates = [
variant("expired"),
variant("future"),
variant("wrong-key"),
bad_signature,
b"corrupt DER".to_vec(),
];
for bytes in candidates {
let temp = tempfile::tempdir().unwrap();
let config = auto_config(&temp.path().join("state"));
let path = cache_path(&config, "json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
serde_json::to_vec(&json!({"der":hex::encode(bytes), "record":{}})).unwrap(),
)
.unwrap();
let (inputs, records) = resolve_with(&config, |_| Ok(variant("base"))).unwrap();
assert_eq!(inputs.len(), 1);
assert_eq!(records[0]["selectionReason"], "no_valid_cache");
assert_eq!(
fs::read(cache_path(&config, "cer")).unwrap(),
variant("base")
);
}
}
#[test]
fn tiebreaker_invalid_downloads_obey_refresh_policy() {
for candidate in ["expired", "future", "wrong-key"] {
let temp = tempfile::tempdir().unwrap();
let mut config = auto_config(&temp.path().join("state"));
resolve_with(&config, |_| Ok(variant("base"))).unwrap();
let before = fs::read(cache_path(&config, "json")).unwrap();
let (inputs, records) = resolve_with(&config, |_| Ok(variant(candidate))).unwrap();
assert_eq!(inputs.len(), 1);
assert_eq!(records[0]["source"], "cache");
assert_eq!(records[0]["refreshFailed"], true);
assert_eq!(records[0]["errorCode"], "certificate_invalid");
assert_eq!(records[0]["selectionReason"], "refresh_failed_valid_cache");
assert_eq!(fs::read(cache_path(&config, "json")).unwrap(), before);
config.ta_refresh_policy = "require-fresh".into();
let (inputs, records) = resolve_with(&config, |_| Ok(variant(candidate))).unwrap();
assert!(inputs.is_empty());
assert_eq!(records[0]["refreshFailed"], true);
assert!(records[0]["source"].is_null());
assert_eq!(fs::read(cache_path(&config, "json")).unwrap(), before);
}
}
#[test]
fn tiebreaker_healthy_cached_choice_works_with_require_fresh() {
let temp = tempfile::tempdir().unwrap();
let mut config = auto_config(&temp.path().join("state"));
config.ta_refresh_policy = "require-fresh".into();
resolve_with(&config, |_| Ok(variant("newer"))).unwrap();
let (inputs, records) = resolve_with(&config, |_| Ok(variant("base"))).unwrap();
assert_eq!(inputs.len(), 1);
assert_eq!(records[0]["source"], "cache");
assert_eq!(records[0]["refreshFailed"], false);
let (inputs, records) = resolve_with(&config, |_| Err("offline".into())).unwrap();
assert!(inputs.is_empty());
assert_eq!(records[0]["errorCode"], "download_failed");
}
#[test]
fn tiebreaker_retries_uri_and_preserves_fixed_ta_mode() {
let temp = tempfile::tempdir().unwrap();
let mut config = auto_config(&temp.path().join("state"));
let mut attempts = 0;
let (inputs, records) = resolve_with(&config, |_| {
attempts += 1;
if attempts == 1 {
Err("unavailable first URI".into())
} else {
Ok(variant("newer"))
}
})
.unwrap();
assert_eq!(attempts, 2);
assert_eq!(inputs.len(), 1);
assert_eq!(records[0]["source"], "download");
config.anchors[0].ta = Some(crate::test_support::synthetic_repository().trust_anchor());
let (inputs, records) = resolve_with(&config, |_| panic!("fixed TA must not fetch")).unwrap();
assert_eq!(inputs.len(), 1);
assert_eq!(records[0]["source"], "file");
assert_eq!(records[0]["selectionReason"], "fixed_input");
assert_eq!(
fs::read(cache_path(&config, "cer")).unwrap(),
variant("newer")
);
}

View File

@@ -28,6 +28,8 @@ pub struct Config {
pub ta_constraints_enabled: bool, pub ta_constraints_enabled: bool,
pub max_ca_depth: usize, pub max_ca_depth: usize,
pub http_timeout_secs: u64, pub http_timeout_secs: u64,
pub rrdp_snapshot_timeout_secs: u64,
pub rrdp_delta_timeout_secs: u64,
pub http_root_cert: Vec<PathBuf>, pub http_root_cert: Vec<PathBuf>,
pub parallel_max_repo_sync_workers_global: usize, pub parallel_max_repo_sync_workers_global: usize,
pub parallel_phase2_object_workers: usize, pub parallel_phase2_object_workers: usize,
@@ -117,7 +119,11 @@ impl Default for Config {
ta_refresh_policy: "allow-valid-cache".into(), ta_refresh_policy: "allow-valid-cache".into(),
ta_constraints_enabled: true, ta_constraints_enabled: true,
max_ca_depth: 64, max_ca_depth: 64,
http_timeout_secs: 300, http_timeout_secs: 10,
rrdp_snapshot_timeout_secs:
crate::repository::fetch::http::DEFAULT_RRDP_SNAPSHOT_TIMEOUT_SECS,
rrdp_delta_timeout_secs:
crate::repository::fetch::http::DEFAULT_RRDP_DELTA_TIMEOUT_SECS,
http_root_cert: Vec::new(), http_root_cert: Vec::new(),
parallel_max_repo_sync_workers_global: 8, parallel_max_repo_sync_workers_global: 8,
parallel_phase2_object_workers: 8, parallel_phase2_object_workers: 8,
@@ -356,6 +362,15 @@ impl Config {
normalizations.push(json!({"field":"http-timeout-secs","input":0,"effective":1})); normalizations.push(json!({"field":"http-timeout-secs","input":0,"effective":1}));
config.http_timeout_secs = 1; config.http_timeout_secs = 1;
} }
if config.rrdp_snapshot_timeout_secs < 1 {
normalizations
.push(json!({"field":"rrdp-snapshot-timeout-secs","input":0,"effective":1}));
config.rrdp_snapshot_timeout_secs = 1;
}
if config.rrdp_delta_timeout_secs < 1 {
normalizations.push(json!({"field":"rrdp-delta-timeout-secs","input":0,"effective":1}));
config.rrdp_delta_timeout_secs = 1;
}
if config.parallel_repo_worker_queue_capacity < 1024 { if config.parallel_repo_worker_queue_capacity < 1024 {
normalizations.push(json!({"field":"parallel-repo-worker-queue-capacity","input":config.parallel_repo_worker_queue_capacity,"effective":1024})); normalizations.push(json!({"field":"parallel-repo-worker-queue-capacity","input":config.parallel_repo_worker_queue_capacity,"effective":1024}));
config.parallel_repo_worker_queue_capacity = 1024; config.parallel_repo_worker_queue_capacity = 1024;
@@ -618,6 +633,8 @@ resource-validation-mode = "rfc6487"
validation-time = "2026-09-22T08:00:00+08:00" validation-time = "2026-09-22T08:00:00+08:00"
sync-preference = "rsync-only" sync-preference = "rsync-only"
rsync-timeout-secs = 47 rsync-timeout-secs = 47
rrdp-snapshot-timeout-secs = 720
rrdp-delta-timeout-secs = 90
rsync-command = {:?} rsync-command = {:?}
rsync-mirror-root = "mirror" rsync-mirror-root = "mirror"
rsync-scope = "publication-point" rsync-scope = "publication-point"
@@ -652,6 +669,8 @@ tal = {:?}
); );
assert_eq!(config.sync_preference().unwrap(), SyncPreference::RsyncOnly); assert_eq!(config.sync_preference().unwrap(), SyncPreference::RsyncOnly);
assert_eq!(config.rsync_timeout_secs, 47); assert_eq!(config.rsync_timeout_secs, 47);
assert_eq!(config.rrdp_snapshot_timeout_secs, 720);
assert_eq!(config.rrdp_delta_timeout_secs, 90);
assert_eq!(config.rsync_scope, RsyncScopePolicy::PublicationPoint); assert_eq!(config.rsync_scope, RsyncScopePolicy::PublicationPoint);
assert_eq!( assert_eq!(
config.effective_validation_time().unwrap().unix_timestamp(), config.effective_validation_time().unwrap().unix_timestamp(),
@@ -662,6 +681,8 @@ tal = {:?}
assert_eq!(printed["effective"]["resource-validation-mode"], "rfc6487"); assert_eq!(printed["effective"]["resource-validation-mode"], "rfc6487");
assert_eq!(printed["effective"]["sync-preference"], "rsync-only"); assert_eq!(printed["effective"]["sync-preference"], "rsync-only");
assert_eq!(printed["effective"]["rsync-timeout-secs"], 47); assert_eq!(printed["effective"]["rsync-timeout-secs"], 47);
assert_eq!(printed["effective"]["rrdp-snapshot-timeout-secs"], 720);
assert_eq!(printed["effective"]["rrdp-delta-timeout-secs"], 90);
assert_eq!( assert_eq!(
printed["effective"]["validation-time"], printed["effective"]["validation-time"],
"2026-09-22T00:00:00Z" "2026-09-22T00:00:00Z"
@@ -689,6 +710,51 @@ tal = {:?}
); );
} }
#[test]
fn rrdp_timeout_defaults_are_distinct_and_zero_values_are_normalized() {
let fixture = crate::test_support::synthetic_repository();
let directory = tempfile::tempdir().unwrap();
let config_path = directory.path().join("validate.toml");
std::fs::write(
&config_path,
format!(
r#"
config-version = 1
mode = "validate"
out = "out"
rrdp-snapshot-timeout-secs = 0
rrdp-delta-timeout-secs = 0
[[anchors]]
id = "custom"
tal = {:?}
"#,
fixture.tal().to_string_lossy(),
),
)
.unwrap();
let (config, printed) = Config::load(&config_path, "validate").unwrap();
assert_eq!(Config::default().http_timeout_secs, 10);
assert_eq!(Config::default().rrdp_snapshot_timeout_secs, 600);
assert_eq!(Config::default().rrdp_delta_timeout_secs, 180);
assert_eq!(config.rrdp_snapshot_timeout_secs, 1);
assert_eq!(config.rrdp_delta_timeout_secs, 1);
assert_eq!(printed["effective"]["rrdp-snapshot-timeout-secs"], 1);
assert_eq!(printed["effective"]["rrdp-delta-timeout-secs"], 1);
let normalizations = printed["normalizations"].as_array().unwrap();
assert!(
normalizations
.iter()
.any(|item| item["field"] == "rrdp-snapshot-timeout-secs")
);
assert!(
normalizations
.iter()
.any(|item| item["field"] == "rrdp-delta-timeout-secs")
);
}
#[test] #[test]
fn constraints_selection_disable_override_and_reload() { fn constraints_selection_disable_override_and_reload() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View File

@@ -128,6 +128,8 @@ fn execute(config: &Config, run_id: &str) -> Result<i32, String> {
strict_policy: Some(config.strict), strict_policy: Some(config.strict),
resource_validation_mode: Some(config.resource_validation_mode), resource_validation_mode: Some(config.resource_validation_mode),
disable_rrdp: sync_preference == SyncPreference::RsyncOnly, disable_rrdp: sync_preference == SyncPreference::RsyncOnly,
rrdp_snapshot_timeout_secs: Some(config.rrdp_snapshot_timeout_secs),
rrdp_delta_timeout_secs: Some(config.rrdp_delta_timeout_secs),
rsync_command: config.rsync_command.clone(), rsync_command: config.rsync_command.clone(),
rsync_timeout_secs: config.rsync_timeout_secs, rsync_timeout_secs: config.rsync_timeout_secs,
rsync_mirror_root: config.rsync_mirror_root.clone(), rsync_mirror_root: config.rsync_mirror_root.clone(),
@@ -149,7 +151,12 @@ fn execute(config: &Config, run_id: &str) -> Result<i32, String> {
serde_json::from_slice(&fs::read(root.join("summary.json")).map_err(|e| e.to_string())?) serde_json::from_slice(&fs::read(root.join("summary.json")).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let repository_failed = summary["repositoryFailedCount"].as_u64().unwrap_or(0); let repository_failed = summary["repositoryFailedCount"].as_u64().unwrap_or(0);
let cached = anchors.iter().filter(|a| a["source"] == "cache").count(); // A healthy tiebreaker can prefer a cached TA after a successful download.
// Only a failed refresh using cache makes the result partial.
let cached = anchors
.iter()
.filter(|a| a["source"] == "cache" && a["refreshFailed"] == true)
.count();
let status = if failed > 0 || repository_failed > 0 || cached > 0 { let status = if failed > 0 || repository_failed > 0 || cached > 0 {
"partial" "partial"
} else { } else {

View File

@@ -215,6 +215,11 @@ fn message(event: &str, fields: &Value) -> String {
repository(fields), repository(fields),
reason(fields) reason(fields)
), ),
"rrdp_session_desynchronized" => format!(
"RRDP delta {} from {} changed its hash within the same session; recovering with the latest snapshot.",
number(fields, "serial"),
repository(fields)
),
"rrdp_cross_origin_rejected" => format!( "rrdp_cross_origin_rejected" => format!(
"Rejected the RRDP {} because its origin ({}) differs from the notification origin ({}).", "Rejected the RRDP {} because its origin ({}) differs from the notification origin ({}).",
string(fields, "resource", "resource"), string(fields, "resource", "resource"),
@@ -231,6 +236,11 @@ fn message(event: &str, fields: &Value) -> String {
string(fields, "mode", "the selected"), string(fields, "mode", "the selected"),
repository(fields) repository(fields)
), ),
"ta_certificate_selected" => format!(
"Selected the cached trust anchor for {} because it is preferred to the downloaded certificate ({}).",
string(fields, "tal_id", "the configured anchor"),
string(fields, "reason", "TA tiebreaker")
),
"rrdp_download_completed" => format!( "rrdp_download_completed" => format!(
"Finished downloading repository data from {}.", "Finished downloading repository data from {}.",
repository(fields) repository(fields)
@@ -448,8 +458,10 @@ mod tests {
"repo_terminal_failure", "repo_terminal_failure",
"http_fetch_failed", "http_fetch_failed",
"rrdp_delta_fallback", "rrdp_delta_fallback",
"rrdp_session_desynchronized",
"rrdp_cross_origin_rejected", "rrdp_cross_origin_rejected",
"rrdp_sync_selected", "rrdp_sync_selected",
"ta_certificate_selected",
"rrdp_download_completed", "rrdp_download_completed",
"daemon_started", "daemon_started",
"daemon_state_recovered", "daemon_state_recovered",

View File

@@ -28,6 +28,9 @@ pub fn with_scoped_http_timeout_override<R>(timeout: Duration, f: impl FnOnce()
/// Default User-Agent sent with every outgoing HTTP request (RRDP /// Default User-Agent sent with every outgoing HTTP request (RRDP
/// notification/snapshot/delta and TAL/TA downloads). /// notification/snapshot/delta and TAL/TA downloads).
pub const DEFAULT_HTTP_USER_AGENT: &str = "panda-rpki/0.2"; pub const DEFAULT_HTTP_USER_AGENT: &str = "panda-rpki/0.2";
pub const DEFAULT_LARGE_BODY_TIMEOUT_SECS: u64 = 180;
pub const DEFAULT_RRDP_SNAPSHOT_TIMEOUT_SECS: u64 = 600;
pub const DEFAULT_RRDP_DELTA_TIMEOUT_SECS: u64 = 180;
/// Environment variable that overrides the HTTP User-Agent. Unset, empty, or /// Environment variable that overrides the HTTP User-Agent. Unset, empty, or
/// values containing characters that are illegal in a header value fall back /// values containing characters that are illegal in a header value fall back
@@ -53,8 +56,12 @@ pub struct HttpFetcherConfig {
pub connect_timeout: Duration, pub connect_timeout: Duration,
/// Short timeout used for connection establishment and small metadata objects. /// Short timeout used for connection establishment and small metadata objects.
pub timeout: Duration, pub timeout: Duration,
/// Larger timeout used for RRDP snapshot / delta bodies. /// Generic large-body timeout, retained for untyped HTTP callers.
pub large_body_timeout: Duration, pub large_body_timeout: Duration,
/// Timeout for RRDP snapshot response bodies.
pub rrdp_snapshot_timeout: Duration,
/// Timeout for RRDP delta response bodies.
pub rrdp_delta_timeout: Duration,
pub user_agent: String, pub user_agent: String,
/// Extra PEM trust anchors for HTTPS transport tests or controlled endpoints. /// Extra PEM trust anchors for HTTPS transport tests or controlled endpoints.
pub extra_root_certificates_pem: Vec<Vec<u8>>, pub extra_root_certificates_pem: Vec<Vec<u8>>,
@@ -65,7 +72,9 @@ impl Default for HttpFetcherConfig {
Self { Self {
connect_timeout: Duration::from_secs(15), connect_timeout: Duration::from_secs(15),
timeout: Duration::from_secs(30), timeout: Duration::from_secs(30),
large_body_timeout: Duration::from_secs(180), large_body_timeout: Duration::from_secs(DEFAULT_LARGE_BODY_TIMEOUT_SECS),
rrdp_snapshot_timeout: Duration::from_secs(DEFAULT_RRDP_SNAPSHOT_TIMEOUT_SECS),
rrdp_delta_timeout: Duration::from_secs(DEFAULT_RRDP_DELTA_TIMEOUT_SECS),
user_agent: resolve_http_user_agent(std::env::var(HTTP_USER_AGENT_ENV).ok()), user_agent: resolve_http_user_agent(std::env::var(HTTP_USER_AGENT_ENV).ok()),
extra_root_certificates_pem: Vec::new(), extra_root_certificates_pem: Vec::new(),
} }
@@ -83,15 +92,22 @@ pub struct BlockingHttpFetcher {
large_body_client: Client, large_body_client: Client,
retry_short_client: Client, retry_short_client: Client,
rrdp_short_client: Client, rrdp_short_client: Client,
rrdp_large_body_client: Client, rrdp_snapshot_client: Client,
rrdp_delta_client: Client,
rrdp_retry_short_client: Client, rrdp_retry_short_client: Client,
short_timeout: Duration, short_timeout: Duration,
large_body_timeout: Duration, large_body_timeout: Duration,
rrdp_snapshot_timeout: Duration,
rrdp_delta_timeout: Duration,
} }
impl BlockingHttpFetcher { impl BlockingHttpFetcher {
pub fn new(config: HttpFetcherConfig) -> Result<Self, String> { pub fn new(config: HttpFetcherConfig) -> Result<Self, String> {
let short_timeout = config.timeout; let short_timeout = config.timeout;
let rrdp_snapshot_timeout = config.rrdp_snapshot_timeout;
let rrdp_delta_timeout = config.rrdp_delta_timeout;
// Preserve the existing timeout behavior for untyped HTTP callers.
// RRDP's typed fetch path below uses its exact snapshot/delta profile.
let large_body_timeout = std::cmp::max(config.large_body_timeout, config.timeout); let large_body_timeout = std::cmp::max(config.large_body_timeout, config.timeout);
let connect_timeout = std::cmp::min(config.connect_timeout, config.timeout); let connect_timeout = std::cmp::min(config.connect_timeout, config.timeout);
let short_client = Self::client_builder( let short_client = Self::client_builder(
@@ -126,10 +142,18 @@ impl BlockingHttpFetcher {
)? )?
.build() .build()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let rrdp_large_body_client = Self::rrdp_client_builder( let rrdp_snapshot_client = Self::rrdp_client_builder(
&config, &config,
connect_timeout, connect_timeout,
large_body_timeout, rrdp_snapshot_timeout,
config.user_agent.clone(),
)?
.build()
.map_err(|e| e.to_string())?;
let rrdp_delta_client = Self::rrdp_client_builder(
&config,
connect_timeout,
rrdp_delta_timeout,
config.user_agent.clone(), config.user_agent.clone(),
)? )?
.build() .build()
@@ -147,10 +171,13 @@ impl BlockingHttpFetcher {
large_body_client, large_body_client,
retry_short_client, retry_short_client,
rrdp_short_client, rrdp_short_client,
rrdp_large_body_client, rrdp_snapshot_client,
rrdp_delta_client,
rrdp_retry_short_client, rrdp_retry_short_client,
short_timeout, short_timeout,
large_body_timeout, large_body_timeout,
rrdp_snapshot_timeout,
rrdp_delta_timeout,
}) })
} }
@@ -300,18 +327,27 @@ impl BlockingHttpFetcher {
} }
} }
fn rrdp_client_for_uri(&self, uri: &str) -> (&Client, &'static str, Duration) { fn rrdp_client_for_resource(
&self,
resource: RrdpResourceKind,
) -> (&Client, &'static str, Duration) {
if let Some(timeout) = HTTP_TIMEOUT_OVERRIDE.with(|cell| *cell.borrow()) { if let Some(timeout) = HTTP_TIMEOUT_OVERRIDE.with(|cell| *cell.borrow()) {
return (&self.rrdp_retry_short_client, "retry_short", timeout); return (&self.rrdp_retry_short_client, "retry_short", timeout);
} }
if uses_large_body_timeout(uri) { match resource {
( RrdpResourceKind::Notification => {
&self.rrdp_large_body_client, (&self.rrdp_short_client, "short", self.short_timeout)
"large_body", }
self.large_body_timeout, RrdpResourceKind::Snapshot => (
) &self.rrdp_snapshot_client,
} else { "rrdp_snapshot",
(&self.rrdp_short_client, "short", self.short_timeout) self.rrdp_snapshot_timeout,
),
RrdpResourceKind::Delta => (
&self.rrdp_delta_client,
"rrdp_delta",
self.rrdp_delta_timeout,
),
} }
} }
@@ -336,7 +372,7 @@ impl BlockingHttpFetcher {
} }
for redirects in 0..=MAX_RRDP_REDIRECTS { for redirects in 0..=MAX_RRDP_REDIRECTS {
let (client, profile, timeout) = self.rrdp_client_for_uri(current.as_str()); let (client, profile, timeout) = self.rrdp_client_for_resource(resource);
let response = client let response = client
.get(current.clone()) .get(current.clone())
.send() .send()
@@ -729,6 +765,8 @@ mod tests {
let http = BlockingHttpFetcher::new(HttpFetcherConfig { let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(3), timeout: Duration::from_secs(3),
large_body_timeout: Duration::from_secs(9), large_body_timeout: Duration::from_secs(9),
rrdp_snapshot_timeout: Duration::from_secs(9),
rrdp_delta_timeout: Duration::from_secs(6),
..HttpFetcherConfig::default() ..HttpFetcherConfig::default()
}) })
.expect("http"); .expect("http");
@@ -744,6 +782,68 @@ mod tests {
assert_eq!(timeout_large, Duration::from_secs(9)); assert_eq!(timeout_large, Duration::from_secs(9));
} }
#[test]
fn typed_rrdp_resource_selects_its_own_timeout_independent_of_uri() {
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(10),
rrdp_snapshot_timeout: Duration::from_secs(600),
rrdp_delta_timeout: Duration::from_secs(180),
..HttpFetcherConfig::default()
})
.expect("http");
let (_, notification_profile, notification_timeout) =
http.rrdp_client_for_resource(RrdpResourceKind::Notification);
let (_, snapshot_profile, snapshot_timeout) =
http.rrdp_client_for_resource(RrdpResourceKind::Snapshot);
let (_, delta_profile, delta_timeout) =
http.rrdp_client_for_resource(RrdpResourceKind::Delta);
assert_eq!(notification_profile, "short");
assert_eq!(notification_timeout, Duration::from_secs(10));
assert_eq!(snapshot_profile, "rrdp_snapshot");
assert_eq!(snapshot_timeout, Duration::from_secs(600));
assert_eq!(delta_profile, "rrdp_delta");
assert_eq!(delta_timeout, Duration::from_secs(180));
}
#[test]
fn rrdp_snapshot_and_delta_requests_enforce_independent_timeouts() {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind");
let addr = listener.local_addr().expect("addr");
let server = thread::spawn(move || {
for _ in 0..2 {
let (mut stream, _) = listener.accept().expect("accept");
let mut request = [0u8; 1024];
let _ = stream.read(&mut request);
thread::sleep(StdDuration::from_millis(250));
let response =
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok";
let _ = stream.write_all(response);
}
});
let uri = format!("http://{addr}/resource.xml");
let origin = RrdpOrigin::parse(&uri, RrdpResourceKind::Notification).expect("origin");
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(3),
rrdp_snapshot_timeout: Duration::from_secs(3),
rrdp_delta_timeout: Duration::from_millis(100),
..HttpFetcherConfig::default()
})
.expect("http");
assert_eq!(
http.fetch_rrdp(RrdpResourceKind::Snapshot, &uri, &origin)
.expect("snapshot response should fit its timeout"),
b"ok"
);
assert!(
http.fetch_rrdp(RrdpResourceKind::Delta, &uri, &origin)
.is_err()
);
server.join().expect("server thread");
}
#[test] #[test]
fn resolve_http_user_agent_falls_back_to_default() { fn resolve_http_user_agent_falls_back_to_default() {
assert_eq!(resolve_http_user_agent(None), DEFAULT_HTTP_USER_AGENT); assert_eq!(resolve_http_user_agent(None), DEFAULT_HTTP_USER_AGENT);

View File

@@ -21,6 +21,10 @@ pub struct RrdpSourceRecord {
pub last_snapshot_uri: Option<String>, pub last_snapshot_uri: Option<String>,
pub last_snapshot_hash: Option<String>, pub last_snapshot_hash: Option<String>,
pub last_error: Option<String>, pub last_error: Option<String>,
/// Delta references in the last successfully synchronized notification.
/// Older databases have no baseline until their next successful sync.
#[serde(default)]
pub last_delta_hashes: std::collections::BTreeMap<u64, [u8; 32]>,
} }
impl RrdpSourceRecord { impl RrdpSourceRecord {
@@ -43,6 +47,16 @@ impl RrdpSourceRecord {
if let Some(last_error) = &self.last_error { if let Some(last_error) = &self.last_error {
validate_non_empty("rrdp_source.last_error", last_error)?; validate_non_empty("rrdp_source.last_error", last_error)?;
} }
if !self.last_delta_hashes.is_empty()
&& (self.last_session_id.is_none()
|| self.last_serial.is_none()
|| self.last_delta_hashes.keys().any(|serial| Some(*serial) > self.last_serial))
{
return Err(StorageError::InvalidData {
entity: "rrdp_source",
detail: "delta hash history requires a session and serial covering all references".into(),
});
}
Ok(()) Ok(())
} }
} }

View File

@@ -9,10 +9,10 @@ mod snapshot_apply;
use crate::repository::sync::store_projection::{ use crate::repository::sync::store_projection::{
build_repository_view_present_entry, build_repository_view_withdrawn_entry, build_repository_view_present_entry, build_repository_view_withdrawn_entry,
current_rrdp_owner_is, ensure_rrdp_uri_can_be_owned_by, put_repository_view_present, build_rrdp_source_record_on_success, current_rrdp_owner_is, ensure_rrdp_uri_can_be_owned_by,
put_repository_view_withdrawn, put_rrdp_source_member_present, put_repository_view_present, put_repository_view_withdrawn, put_rrdp_source_member_present,
put_rrdp_source_member_withdrawn, put_rrdp_uri_owner_active, put_rrdp_uri_owner_withdrawn, put_rrdp_source_member_withdrawn, put_rrdp_uri_owner_active, put_rrdp_uri_owner_withdrawn,
update_rrdp_source_record_on_success, upsert_repo_blob_bytes, upsert_repo_blob_bytes,
}; };
use base64::Engine; use base64::Engine;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@@ -321,6 +321,8 @@ pub trait Fetcher: Send + Sync {
pub struct RrdpState { pub struct RrdpState {
pub session_id: String, pub session_id: String,
pub serial: u64, pub serial: u64,
#[serde(default)]
pub delta_hashes: std::collections::BTreeMap<u64, [u8; 32]>,
} }
impl RrdpState { impl RrdpState {
@@ -342,7 +344,11 @@ pub(crate) fn load_rrdp_local_state(
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
&& let (Some(session_id), Some(serial)) = (record.last_session_id, record.last_serial) && let (Some(session_id), Some(serial)) = (record.last_session_id, record.last_serial)
{ {
return Ok(Some(RrdpState { session_id, serial })); return Ok(Some(RrdpState {
session_id,
serial,
delta_hashes: record.last_delta_hashes,
}));
} }
Ok(None) Ok(None)
@@ -351,20 +357,28 @@ pub(crate) fn load_rrdp_local_state(
pub(crate) fn persist_rrdp_local_state( pub(crate) fn persist_rrdp_local_state(
store: &RocksStore, store: &RocksStore,
notification_uri: &str, notification_uri: &str,
state: &RrdpState, notification: &Notification,
sync_state: RrdpSourceSyncState, sync_state: Option<RrdpSourceSyncState>,
last_snapshot_uri: Option<&str>,
last_snapshot_hash_hex: Option<&str>,
) -> Result<(), String> { ) -> Result<(), String> {
update_rrdp_source_record_on_success( let mut record = build_rrdp_source_record_on_success(
store, store,
notification_uri, notification_uri,
state.session_id.as_str(), &notification.session_id.to_string(),
state.serial, notification.serial,
sync_state, sync_state,
last_snapshot_uri, Some(&notification.snapshot_uri),
last_snapshot_hash_hex, Some(&hex::encode(notification.snapshot_hash_sha256)),
) )?;
// Retain only the previous successful notification window (RFC 9697 §3),
// rather than accumulating an unbounded history for a long-lived session.
record.last_delta_hashes = notification
.deltas
.iter()
.map(|delta| (delta.serial, delta.hash_sha256))
.collect();
store
.put_rrdp_source_record(&record)
.map_err(|error| error.to_string())
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]

View File

@@ -123,7 +123,46 @@ fn sync_from_notification_inner(
"delta_chain_unavailable" "delta_chain_unavailable"
}; };
if let Some(s) = same_session_state { if let Some(s) = same_session_state
&& s.serial > notif.serial
{
return Err(RrdpError::NotificationSerialRollback {
old: s.serial,
new: notif.serial,
}
.into());
}
// RFC 9697 §3–5: compare references before either the noop fast path or
// fetching a new delta. Old delta files do not need to be downloaded.
let mutation = same_session_state.and_then(|previous| {
notif.deltas.iter().find_map(|delta| {
previous
.delta_hashes
.get(&delta.serial)
.filter(|old_hash| **old_hash != delta.hash_sha256)
.map(|old_hash| (delta.serial, *old_hash, delta.hash_sha256))
})
});
if let Some((serial, old_hash, new_hash)) = mutation {
snapshot_reason = "session_desynchronized";
crate::logging::emit(
crate::logging::Level::Warn,
"rrdp_session_desynchronized",
|| {
serde_json::json!({
"notify_uri": notification_uri, "session_id": notif.session_id.to_string(),
"serial": serial, "old_hash": hex::encode(old_hash), "new_hash": hex::encode(new_hash),
"recovery": "snapshot", "rfc": "RFC 9697 §3–5",
})
},
);
if let Some(t) = timing.as_ref() {
t.record_count("rrdp_session_desynchronization_total", 1);
}
}
if let Some(s) = same_session_state.filter(|_| mutation.is_none()) {
if s.serial == notif.serial { if s.serial == notif.serial {
log_selection("unchanged", "same_session_and_serial"); log_selection("unchanged", "same_session_and_serial");
let _hydrate_step = timing let _hydrate_step = timing
@@ -142,18 +181,13 @@ fn sync_from_notification_inner(
if let Some(t) = timing.as_ref() { if let Some(t) = timing.as_ref() {
t.record_count("rrdp_current_index_hydrated_objects_total", hydrated as u64); t.record_count("rrdp_current_index_hydrated_objects_total", hydrated as u64);
} }
persist_rrdp_local_state(store, notification_uri, &notif, None)
.map_err(RrdpSyncError::Storage)?;
return Ok(0); return Ok(0);
} }
if s.serial > notif.serial {
return Err(RrdpError::NotificationSerialRollback {
old: s.serial,
new: notif.serial,
}
.into());
}
} }
if let Some(s) = same_session_state { if let Some(s) = same_session_state.filter(|_| mutation.is_none()) {
// RFC 8182 §3.4.1: if session matches, MAY use deltas when a contiguous chain from the // RFC 8182 §3.4.1: if session matches, MAY use deltas when a contiguous chain from the
// last processed serial to the current serial can be processed (i.e. deltas cover the gap). // last processed serial to the current serial can be processed (i.e. deltas cover the gap).
let want_first = s.serial + 1; let want_first = s.serial + 1;
@@ -273,17 +307,11 @@ fn sync_from_notification_inner(
let _write_state_total = timing let _write_state_total = timing
.as_ref() .as_ref()
.map(|t| t.span_phase("rrdp_write_state_total")); .map(|t| t.span_phase("rrdp_write_state_total"));
let new_state = RrdpState {
session_id: notif.session_id.to_string(),
serial: notif.serial,
};
persist_rrdp_local_state( persist_rrdp_local_state(
store, store,
notification_uri, notification_uri,
&new_state, &notif,
RrdpSourceSyncState::DeltaReady, Some(RrdpSourceSyncState::DeltaReady),
Some(&notif.snapshot_uri),
Some(&hex::encode(notif.snapshot_hash_sha256)),
) )
.map_err(RrdpSyncError::Storage)?; .map_err(RrdpSyncError::Storage)?;
drop(_write_state_step); drop(_write_state_step);
@@ -394,17 +422,11 @@ fn sync_from_notification_inner(
let _write_state_total = timing let _write_state_total = timing
.as_ref() .as_ref()
.map(|t| t.span_phase("rrdp_write_state_total")); .map(|t| t.span_phase("rrdp_write_state_total"));
let new_state = RrdpState {
session_id: notif.session_id.to_string(),
serial: notif.serial,
};
persist_rrdp_local_state( persist_rrdp_local_state(
store, store,
notification_uri, notification_uri,
&new_state, &notif,
RrdpSourceSyncState::SnapshotOnly, Some(RrdpSourceSyncState::SnapshotOnly),
Some(&notif.snapshot_uri),
Some(&hex::encode(notif.snapshot_hash_sha256)),
) )
.map_err(RrdpSyncError::Storage)?; .map_err(RrdpSyncError::Storage)?;
drop(_write_state_step); drop(_write_state_step);

View File

@@ -162,17 +162,11 @@ fn sync_from_notification_snapshot_inner(
let _write_state_total = timing let _write_state_total = timing
.as_ref() .as_ref()
.map(|t| t.span_phase("rrdp_write_state_total")); .map(|t| t.span_phase("rrdp_write_state_total"));
let state = RrdpState {
session_id: notif.session_id.to_string(),
serial: notif.serial,
};
persist_rrdp_local_state( persist_rrdp_local_state(
store, store,
notification_uri, notification_uri,
&state, &notif,
RrdpSourceSyncState::SnapshotOnly, Some(RrdpSourceSyncState::SnapshotOnly),
Some(&notif.snapshot_uri),
Some(&hex::encode(notif.snapshot_hash_sha256)),
) )
.map_err(RrdpSyncError::Storage)?; .map_err(RrdpSyncError::Storage)?;
drop(_write_state_step); drop(_write_state_step);

View File

@@ -3,3 +3,4 @@ include!("tests_parts/parsing.rs");
include!("tests_parts/delta_apply.rs"); include!("tests_parts/delta_apply.rs");
include!("tests_parts/sync.rs"); include!("tests_parts/sync.rs");
include!("tests_parts/edge_cases.rs"); include!("tests_parts/edge_cases.rs");
include!("tests_parts/desynchronization.rs");

View File

@@ -0,0 +1,323 @@
// RFC 9697: compare notification history without downloading old delta files.
const DESYNC_NOTIFY: &str = "https://example.net/notification.xml";
const DESYNC_SESSION: &str = "550e8400-e29b-41d4-a716-446655440000";
const DESYNC_OBJECT: &str = "rsync://example.net/repo/a.roa";
const DESYNC_OBSOLETE: &str = "rsync://example.net/repo/obsolete.roa";
struct DesyncFetcher {
files: HashMap<String, Vec<u8>>,
requests: std::sync::Mutex<Vec<String>>,
}
impl DesyncFetcher {
fn snapshot(bytes: Vec<u8>) -> Self {
Self {
files: HashMap::from([("https://example.net/snapshot.xml".to_string(), bytes)]),
requests: Default::default(),
}
}
fn requested(&self) -> Vec<String> {
self.requests.lock().unwrap().clone()
}
}
impl Fetcher for DesyncFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
self.requests.lock().unwrap().push(uri.to_string());
self.files
.get(uri)
.cloned()
.ok_or_else(|| format!("not found: {uri}"))
}
}
fn desync_notification(session: &str, serial: u64, snapshot: &[u8], old_hash: &str) -> Vec<u8> {
let snapshot_hash = hex::encode(sha2::Sha256::digest(snapshot));
let mut deltas = vec![("old", 100, "https://example.net/delta-100.xml", old_hash)];
let hash_101 = "22".repeat(32);
if serial == 101 {
deltas.push(("new", 101, "https://example.net/delta-101.xml", &hash_101));
}
notification_xml_with_deltas(
session,
serial,
"https://example.net/snapshot.xml",
&snapshot_hash,
&deltas,
)
}
fn seed_desync_repository(store: &RocksStore) {
let snapshot = snapshot_xml(
DESYNC_SESSION,
100,
&[(DESYNC_OBJECT, b"old"), (DESYNC_OBSOLETE, b"obsolete")],
);
let notification = desync_notification(DESYNC_SESSION, 100, &snapshot, &"11".repeat(32));
sync_from_notification_snapshot(
store,
DESYNC_NOTIFY,
&notification,
&DesyncFetcher::snapshot(snapshot),
)
.unwrap();
}
#[test]
fn desynchronization_same_serial_recovers_snapshot_before_noop() {
let temp = tempfile::tempdir().unwrap();
let store = RocksStore::open(temp.path()).unwrap();
seed_desync_repository(&store);
let snapshot = snapshot_xml(DESYNC_SESSION, 100, &[(DESYNC_OBJECT, b"recovered")]);
let notification = desync_notification(DESYNC_SESSION, 100, &snapshot, &"33".repeat(32));
let fetcher = DesyncFetcher::snapshot(snapshot);
assert_eq!(
sync_from_notification(&store, DESYNC_NOTIFY, &notification, &fetcher).unwrap(),
1
);
assert_eq!(fetcher.requested(), ["https://example.net/snapshot.xml"]);
assert_current_object(&store, DESYNC_OBJECT, b"recovered");
assert!(
store
.load_current_object_bytes_by_uri(DESYNC_OBSOLETE)
.unwrap()
.is_none()
);
}
#[test]
fn desynchronization_advanced_serial_survives_restart_and_skips_all_deltas() {
let temp = tempfile::tempdir().unwrap();
{
let store = RocksStore::open(temp.path()).unwrap();
seed_desync_repository(&store);
}
let store = RocksStore::open(temp.path()).unwrap();
let snapshot = snapshot_xml(DESYNC_SESSION, 101, &[(DESYNC_OBJECT, b"recovered")]);
let notification = desync_notification(DESYNC_SESSION, 101, &snapshot, &"33".repeat(32));
let fetcher = DesyncFetcher::snapshot(snapshot);
sync_from_notification(&store, DESYNC_NOTIFY, &notification, &fetcher).unwrap();
assert_eq!(fetcher.requested(), ["https://example.net/snapshot.xml"]);
assert_current_object(&store, DESYNC_OBJECT, b"recovered");
assert!(
store
.load_current_object_bytes_by_uri(DESYNC_OBSOLETE)
.unwrap()
.is_none()
);
}
#[test]
fn desynchronization_failed_recovery_keeps_baseline_for_retry() {
let temp = tempfile::tempdir().unwrap();
let store = RocksStore::open(temp.path()).unwrap();
seed_desync_repository(&store);
let before = store
.get_rrdp_source_record(DESYNC_NOTIFY)
.unwrap()
.unwrap();
let snapshot = snapshot_xml(DESYNC_SESSION, 100, &[(DESYNC_OBJECT, b"recovered")]);
let notification = desync_notification(DESYNC_SESSION, 100, &snapshot, &"33".repeat(32));
// A failed download must not bless the new notification history.
let mut failing = DesyncFetcher::snapshot(snapshot.clone());
failing.files.clear();
assert!(sync_from_notification(&store, DESYNC_NOTIFY, &notification, &failing).is_err());
assert_eq!(
store
.get_rrdp_source_record(DESYNC_NOTIFY)
.unwrap()
.unwrap(),
before
);
assert_current_object(&store, DESYNC_OBJECT, b"old");
// Nor may a downloaded snapshot with a bad hash advance the baseline.
let corrupt = DesyncFetcher::snapshot(b"corrupt snapshot".to_vec());
assert!(matches!(
sync_from_notification(&store, DESYNC_NOTIFY, &notification, &corrupt),
Err(RrdpSyncError::Rrdp(RrdpError::SnapshotHashMismatch))
));
assert_eq!(
store
.get_rrdp_source_record(DESYNC_NOTIFY)
.unwrap()
.unwrap(),
before
);
let fetcher = DesyncFetcher::snapshot(snapshot);
sync_from_notification(&store, DESYNC_NOTIFY, &notification, &fetcher).unwrap();
assert_eq!(fetcher.requested(), ["https://example.net/snapshot.xml"]);
assert_current_object(&store, DESYNC_OBJECT, b"recovered");
// Once recovery succeeds the changed hash becomes the new baseline.
assert_eq!(
sync_from_notification(&store, DESYNC_NOTIFY, &notification, &PanicOnRrdpFetch).unwrap(),
0
);
}
#[test]
fn desynchronization_noop_learns_new_references_before_future_mutation() {
let temp = tempfile::tempdir().unwrap();
let store = RocksStore::open(temp.path()).unwrap();
seed_desync_repository(&store);
let snapshot = snapshot_xml(DESYNC_SESSION, 100, &[(DESYNC_OBJECT, b"recovered")]);
let hash = hex::encode(sha2::Sha256::digest(&snapshot));
let h99 = "99".repeat(32);
let h100 = "11".repeat(32);
let expanded = notification_xml_with_deltas(
DESYNC_SESSION,
100,
"https://example.net/snapshot.xml",
&hash,
&[
("d99", 99, "https://example.net/d99.xml", &h99),
("d100", 100, "https://example.net/d100.xml", &h100),
],
);
assert_eq!(
sync_from_notification(&store, DESYNC_NOTIFY, &expanded, &PanicOnRrdpFetch).unwrap(),
0
);
assert_current_object(&store, DESYNC_OBJECT, b"old");
let mutated = String::from_utf8(expanded)
.unwrap()
.replace(&h99, &"88".repeat(32));
let fetcher = DesyncFetcher::snapshot(snapshot);
sync_from_notification(&store, DESYNC_NOTIFY, mutated.as_bytes(), &fetcher).unwrap();
assert_eq!(fetcher.requested(), ["https://example.net/snapshot.xml"]);
assert_current_object(&store, DESYNC_OBJECT, b"recovered");
}
#[test]
fn desynchronization_successful_delta_updates_and_prunes_history_window() {
let temp = tempfile::tempdir().unwrap();
let store = RocksStore::open(temp.path()).unwrap();
seed_desync_repository(&store);
let delta = delta_xml(
DESYNC_SESSION,
101,
&[&format!(
r#"<withdraw uri="{DESYNC_OBSOLETE}" hash="{}"/>"#,
hex::encode(sha2::Sha256::digest(b"obsolete"))
)],
);
let hash = hex::encode(sha2::Sha256::digest(&delta));
let snapshot = snapshot_xml(DESYNC_SESSION, 101, &[(DESYNC_OBJECT, b"recovered")]);
let snapshot_hash = hex::encode(sha2::Sha256::digest(&snapshot));
let notification = notification_xml_with_deltas(
DESYNC_SESSION,
101,
"https://example.net/snapshot.xml",
&snapshot_hash,
&[("new", 101, "https://example.net/delta-101.xml", &hash)],
);
let fetcher = DesyncFetcher {
files: HashMap::from([("https://example.net/delta-101.xml".into(), delta)]),
requests: Default::default(),
};
assert_eq!(
sync_from_notification(&store, DESYNC_NOTIFY, &notification, &fetcher).unwrap(),
1
);
assert_eq!(fetcher.requested(), ["https://example.net/delta-101.xml"]);
let state = load_rrdp_local_state(&store, DESYNC_NOTIFY)
.unwrap()
.unwrap();
assert_eq!(state.delta_hashes.len(), 1);
assert_eq!(hex::encode(state.delta_hashes[&101]), hash);
assert_current_object(&store, DESYNC_OBJECT, b"old");
let mutated = String::from_utf8(notification)
.unwrap()
.replace(&hash, &"ff".repeat(32));
let recovery = DesyncFetcher::snapshot(snapshot);
sync_from_notification(&store, DESYNC_NOTIFY, mutated.as_bytes(), &recovery).unwrap();
assert_eq!(recovery.requested(), ["https://example.net/snapshot.xml"]);
assert_current_object(&store, DESYNC_OBJECT, b"recovered");
}
#[test]
fn desynchronization_new_session_replaces_hash_baseline() {
let temp = tempfile::tempdir().unwrap();
let store = RocksStore::open(temp.path()).unwrap();
seed_desync_repository(&store);
let session = "550e8400-e29b-41d4-a716-446655440001";
let snapshot = snapshot_xml(session, 100, &[(DESYNC_OBJECT, b"new session")]);
let notification = desync_notification(session, 100, &snapshot, &"44".repeat(32));
sync_from_notification(
&store,
DESYNC_NOTIFY,
&notification,
&DesyncFetcher::snapshot(snapshot),
)
.unwrap();
let state = load_rrdp_local_state(&store, DESYNC_NOTIFY)
.unwrap()
.unwrap();
assert_eq!(state.session_id, session);
assert_eq!(state.delta_hashes[&100], [0x44; 32]);
assert_eq!(
sync_from_notification(&store, DESYNC_NOTIFY, &notification, &PanicOnRrdpFetch).unwrap(),
0
);
assert_current_object(&store, DESYNC_OBJECT, b"new session");
}
#[test]
fn desynchronization_legacy_source_record_defaults_to_empty_history() {
let temp = tempfile::tempdir().unwrap();
let store = RocksStore::open(temp.path()).unwrap();
seed_desync_repository(&store);
let record = store
.get_rrdp_source_record(DESYNC_NOTIFY)
.unwrap()
.unwrap();
let mut legacy = serde_cbor::value::to_value(&record).unwrap();
if let serde_cbor::Value::Map(map) = &mut legacy {
map.remove(&serde_cbor::Value::Text("last_delta_hashes".into()));
} else {
panic!("source record must encode as a CBOR map");
}
let decoded: crate::repository::storage::RrdpSourceRecord =
serde_cbor::from_slice(&serde_cbor::to_vec(&legacy).unwrap()).unwrap();
assert!(decoded.last_delta_hashes.is_empty());
store.put_rrdp_source_record(&decoded).unwrap();
let snapshot = snapshot_xml(DESYNC_SESSION, 100, &[(DESYNC_OBJECT, b"unused")]);
let notification = desync_notification(DESYNC_SESSION, 100, &snapshot, &"55".repeat(32));
assert_eq!(
sync_from_notification(&store, DESYNC_NOTIFY, &notification, &PanicOnRrdpFetch).unwrap(),
0
);
assert_eq!(
load_rrdp_local_state(&store, DESYNC_NOTIFY)
.unwrap()
.unwrap()
.delta_hashes[&100],
[0x55; 32]
);
assert_current_object(&store, DESYNC_OBJECT, b"old");
}
#[test]
fn desynchronization_does_not_override_serial_rollback_guard() {
let temp = tempfile::tempdir().unwrap();
let store = RocksStore::open(temp.path()).unwrap();
seed_desync_repository(&store);
let before = store.get_rrdp_source_record(DESYNC_NOTIFY).unwrap();
let notification = notification_xml(
DESYNC_SESSION,
99,
"https://example.net/snapshot.xml",
&"00".repeat(32),
);
assert!(matches!(
sync_from_notification(&store, DESYNC_NOTIFY, &notification, &PanicOnRrdpFetch),
Err(RrdpSyncError::Rrdp(RrdpError::NotificationSerialRollback {
old: 100,
new: 99
}))
));
assert_eq!(store.get_rrdp_source_record(DESYNC_NOTIFY).unwrap(), before);
}

View File

@@ -10,16 +10,17 @@ fn load_rrdp_local_state_uses_source_record_only() {
None None
); );
update_rrdp_source_record_on_success( let record = build_rrdp_source_record_on_success(
&store, &store,
notif_uri, notif_uri,
"source-session", "source-session",
9, 9,
crate::repository::storage::RrdpSourceSyncState::DeltaReady, Some(crate::repository::storage::RrdpSourceSyncState::DeltaReady),
Some("https://example.net/snapshot.xml"), Some("https://example.net/snapshot.xml"),
Some(&hex::encode([0x11; 32])), Some(&hex::encode([0x11; 32])),
) )
.expect("write source record"); .expect("build source record");
store.put_rrdp_source_record(&record).expect("write source record");
let got = load_rrdp_local_state(&store, notif_uri) let got = load_rrdp_local_state(&store, notif_uri)
.expect("load source preferred") .expect("load source preferred")
@@ -29,6 +30,7 @@ fn load_rrdp_local_state_uses_source_record_only() {
RrdpState { RrdpState {
session_id: "source-session".to_string(), session_id: "source-session".to_string(),
serial: 9, serial: 9,
delta_hashes: Default::default(),
} }
); );
} }

View File

@@ -408,15 +408,15 @@ pub fn put_rrdp_uri_owner_withdrawn(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
pub fn update_rrdp_source_record_on_success( pub fn build_rrdp_source_record_on_success(
store: &RocksStore, store: &RocksStore,
notification_uri: &str, notification_uri: &str,
session_id: &str, session_id: &str,
serial: u64, serial: u64,
sync_state: RrdpSourceSyncState, sync_state: Option<RrdpSourceSyncState>,
last_snapshot_uri: Option<&str>, last_snapshot_uri: Option<&str>,
last_snapshot_hash_hex: Option<&str>, last_snapshot_hash_hex: Option<&str>,
) -> Result<(), String> { ) -> Result<RrdpSourceRecord, String> {
let now = now_pack_time(); let now = now_pack_time();
let mut record = match store let mut record = match store
.get_rrdp_source_record(notification_uri) .get_rrdp_source_record(notification_uri)
@@ -430,10 +430,11 @@ pub fn update_rrdp_source_record_on_success(
first_seen_at: now.clone(), first_seen_at: now.clone(),
last_seen_at: now.clone(), last_seen_at: now.clone(),
last_sync_at: None, last_sync_at: None,
sync_state, sync_state: sync_state.unwrap_or(RrdpSourceSyncState::Empty),
last_snapshot_uri: None, last_snapshot_uri: None,
last_snapshot_hash: None, last_snapshot_hash: None,
last_error: None, last_error: None,
last_delta_hashes: Default::default(),
}, },
}; };
@@ -441,14 +442,14 @@ pub fn update_rrdp_source_record_on_success(
record.last_serial = Some(serial); record.last_serial = Some(serial);
record.last_seen_at = now.clone(); record.last_seen_at = now.clone();
record.last_sync_at = Some(now); record.last_sync_at = Some(now);
record.sync_state = sync_state; record.sync_state = sync_state.unwrap_or(record.sync_state);
record.last_snapshot_uri = last_snapshot_uri.map(str::to_string); record.last_snapshot_uri = last_snapshot_uri.map(str::to_string);
record.last_snapshot_hash = last_snapshot_hash_hex.map(str::to_string); record.last_snapshot_hash = last_snapshot_hash_hex.map(str::to_string);
record.last_error = None; record.last_error = None;
store // The caller adds the notification's delta history before the single write,
.put_rrdp_source_record(&record) // so session/serial and the history cannot be committed independently.
.map_err(|e| e.to_string()) Ok(record)
} }
pub fn compute_sha256_hex(bytes: &[u8]) -> String { pub fn compute_sha256_hex(bytes: &[u8]) -> String {

View File

@@ -112,13 +112,19 @@ pub(crate) fn run_config(args: RunConfig) -> Result<(), String> {
let validation_started = std::time::Instant::now(); let validation_started = std::time::Instant::now();
crate::logging::observer::phase("runtime_setup"); crate::logging::observer::phase("runtime_setup");
let collect_current_repo_objects = false; let collect_current_repo_objects = false;
let mut http_config = HttpFetcherConfig {
timeout: std::time::Duration::from_secs(args.http_timeout_secs.max(1)),
extra_root_certificates_pem: http_root_certificates_pem.clone(),
..HttpFetcherConfig::default()
};
if let Some(timeout) = args.rrdp_snapshot_timeout_secs {
http_config.rrdp_snapshot_timeout = std::time::Duration::from_secs(timeout.max(1));
}
if let Some(timeout) = args.rrdp_delta_timeout_secs {
http_config.rrdp_delta_timeout = std::time::Duration::from_secs(timeout.max(1));
}
let out = if let Some(dir) = args.rsync_local_dir.as_ref() { let out = if let Some(dir) = args.rsync_local_dir.as_ref() {
let http = BlockingHttpFetcher::new(HttpFetcherConfig { let http = BlockingHttpFetcher::new(http_config.clone()).map_err(|e| e.to_string())?;
timeout: std::time::Duration::from_secs(args.http_timeout_secs.max(1)),
extra_root_certificates_pem: http_root_certificates_pem.clone(),
..HttpFetcherConfig::default()
})
.map_err(|e| e.to_string())?;
let rsync = LocalDirRsyncFetcher::new(dir); let rsync = LocalDirRsyncFetcher::new(dir);
run_online_validation_with_fetchers( run_online_validation_with_fetchers(
Arc::clone(&store), Arc::clone(&store),
@@ -132,12 +138,7 @@ pub(crate) fn run_config(args: RunConfig) -> Result<(), String> {
timing.as_ref().map(|(_, t)| t), timing.as_ref().map(|(_, t)| t),
)? )?
} else { } else {
let http = BlockingHttpFetcher::new(HttpFetcherConfig { let http = BlockingHttpFetcher::new(http_config).map_err(|e| e.to_string())?;
timeout: std::time::Duration::from_secs(args.http_timeout_secs.max(1)),
extra_root_certificates_pem: http_root_certificates_pem.clone(),
..HttpFetcherConfig::default()
})
.map_err(|e| e.to_string())?;
let rsync = SystemRsyncFetcher::new(SystemRsyncConfig { let rsync = SystemRsyncFetcher::new(SystemRsyncConfig {
rsync_bin: args rsync_bin: args
.rsync_command .rsync_command

View File

@@ -79,6 +79,9 @@ pub struct RunConfig {
pub rsync_command: Option<PathBuf>, pub rsync_command: Option<PathBuf>,
pub http_timeout_secs: u64, pub http_timeout_secs: u64,
/// Per-resource RRDP response timeouts; None selects the fetcher default.
pub rrdp_snapshot_timeout_secs: Option<u64>,
pub rrdp_delta_timeout_secs: Option<u64>,
pub http_root_cert_paths: Vec<PathBuf>, pub http_root_cert_paths: Vec<PathBuf>,
pub rsync_timeout_secs: u64, pub rsync_timeout_secs: u64,
pub rsync_mirror_root: Option<PathBuf>, pub rsync_mirror_root: Option<PathBuf>,

View File

@@ -26,6 +26,10 @@ impl SyntheticRepositoryFixture {
pub(crate) fn trust_anchor(&self) -> PathBuf { pub(crate) fn trust_anchor(&self) -> PathBuf {
self.root.join("ta/custom-ta.cer") self.root.join("ta/custom-ta.cer")
} }
pub(crate) fn ta_variant(&self, name: &str) -> PathBuf {
self.root.join("ta-variants").join(format!("{name}.cer"))
}
} }
pub(crate) fn synthetic_repository() -> &'static SyntheticRepositoryFixture { pub(crate) fn synthetic_repository() -> &'static SyntheticRepositoryFixture {

View File

@@ -0,0 +1,9 @@
#[test]
fn persisted_rrdp_desynchronization_and_ta_tiebreaker_are_end_to_end() {
let status = std::process::Command::new("python3")
.arg("tests/support/test_security_recovery.py")
.arg(env!("CARGO_BIN_EXE_panda-rpki"))
.status()
.expect("start RRDP recovery and TA selection integration driver");
assert!(status.success(), "security recovery E2E failed: {status}");
}

View File

@@ -11,3 +11,17 @@ network request is included in this directory. The generator requires Python
`cryptography`, OpenSSL's `cms` command, and Python 3; CI installs those test `cryptography`, OpenSSL's `cms` command, and Python 3; CI installs those test
tools explicitly. Test output is created under a temporary directory and is tools explicitly. Test output is created under a temporary directory and is
not part of a release artifact. not part of a release artifact.
# Security recovery and TA selection
`cargo test --offline --test security_recovery_e2e` runs the real CLI against a
local HTTPS repository with persisted state. Across three processes it checks
initial snapshot, recovery after a delta-hash mutation at unchanged session/serial,
and a subsequent noop. Request assertions prove that no historical delta is
downloaded. A newer cached TA must survive a valid older download under
`require-fresh`, with a successful result and no cache-fallback count.
To preserve the per-step request and summary evidence:
```sh
python3 tests/support/test_security_recovery.py target/debug/panda-rpki --proof /tmp/security-recovery.json
```

View File

@@ -545,6 +545,29 @@ def make_https_certificates(output: Path) -> None:
write_key(output / "certs/rrdp-server.key", server_key) write_key(output / "certs/rrdp-server.key", server_key)
def make_ta_variants(output: Path, name: x509.Name, key, foreign_key) -> None:
"""Real self-signed versions of one TAL-bound key, for tiebreaker tests."""
variants = {
"older": (datetime(2025, 1, 1, tzinfo=UTC), VALID_TO),
"newer": (datetime(2026, 5, 1, tzinfo=UTC), VALID_TO),
"shorter": (VALID_FROM, datetime(2026, 12, 31, tzinfo=UTC)),
"longer": (VALID_FROM, datetime(2030, 1, 1, tzinfo=UTC)),
"equal-period": (VALID_FROM, VALID_TO),
"expired": (datetime(2025, 1, 1, tzinfo=UTC), datetime(2025, 12, 31, tzinfo=UTC)),
"future": (datetime(2027, 1, 1, tzinfo=UTC), datetime(2028, 1, 1, tzinfo=UTC)),
"wrong-key": (VALID_FROM, VALID_TO),
}
for serial, (variant, (start, end)) in enumerate(variants.items(), 0x14700100):
selected_key = foreign_key if variant == "wrong-key" else key
certificate = sign_cert(
name, name, selected_key.public_key(), selected_key, serial, True, selected_key,
ip_ext=ip_resources([CHILD_PREFIX]), as_ext=as_resources([TEST_ASN]),
ca_repository=ROOT_REPO_URI, manifest_uri=ROOT_MFT_URI, notify_uri=RRDP_URI,
not_before=start, not_after=end,
)
write(output / f"ta-variants/{variant}.cer", certificate.public_bytes(Encoding.DER))
def main() -> None: def main() -> None:
global RRDP_HOST, FOREIGN_RRDP_HOST, RRDP_PORT, RSYNC_HOST, RSYNC_PORT global RRDP_HOST, FOREIGN_RRDP_HOST, RRDP_PORT, RSYNC_HOST, RSYNC_PORT
global TAL_URI, TAL_HTTPS_URI, RRDP_URI, STRICT_NAMES global TAL_URI, TAL_HTTPS_URI, RRDP_URI, STRICT_NAMES
@@ -638,6 +661,7 @@ def main() -> None:
).encode() ).encode()
write(output / "tal/custom.tal", tal) write(output / "tal/custom.tal", tal)
write(output / "ta/custom-ta.cer", root_cert.public_bytes(Encoding.DER)) write(output / "ta/custom-ta.cer", root_cert.public_bytes(Encoding.DER))
make_ta_variants(output, root_name, root_key, child_key)
with tempfile.TemporaryDirectory(prefix="custom-rpki-cms-") as temp: with tempfile.TemporaryDirectory(prefix="custom-rpki-cms-") as temp:
work = Path(temp) work = Path(temp)
common = output / ".common" common = output / ".common"

View File

@@ -0,0 +1,151 @@
"""Real CLI, HTTPS and persisted state for RFC 9697 and TA tiebreaker -06."""
from __future__ import annotations
import argparse
import functools
import http.server
import json
from pathlib import Path
import shutil
import ssl
import sys
import tempfile
import threading
from test_public_config import (
assert_successful_output,
free_port,
make_rsync_wrapper,
read_json,
run_checked,
write_config,
)
def run(binary: str) -> dict:
support = Path(__file__).resolve().parent
with tempfile.TemporaryDirectory(prefix="panda-rpki-security-") as tmp:
base = Path(tmp)
fixture = base / "fixture"
port = free_port()
run_checked([
sys.executable, str(support / "generate_repository.py"),
"--rrdp-host", "127.0.0.1", "--rrdp-port", str(port),
"--rsync-host", "127.0.0.1", "--strict-names", "--output", str(fixture),
])
active = base / "active"
shutil.copytree(fixture / "cases/baseline-v1/http", active)
# Prefer real HTTPS for automatic TA retrieval. Retain the rsync URI:
# the validation core uses it as the key for its offline TA projection.
tal = fixture / "tal/custom.tal"
lines = tal.read_text().splitlines()
tal.write_text("\n".join(
[line for line in lines if line.startswith("https://")]
+ [line for line in lines if line.startswith("rsync://")]
+ [line for line in lines if not line.startswith(("https://", "rsync://"))]
) + "\n", encoding="utf-8")
served_ta = active / "ta/custom-ta.cer"
served_ta.write_bytes((fixture / "ta-variants/newer.cer").read_bytes())
notification = active / "rrdp/notification.xml"
original = notification.read_text()
def advertise_hash(value: str) -> None:
reference = (
f'<delta serial="1" uri="https://127.0.0.1:{port}/rrdp/delta-1.xml" '
f'hash="{value * 32}"/>'
)
notification.write_text(original.replace("</notification>", reference + "</notification>"))
advertise_hash("11")
requests: list[str] = []
class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self) -> None:
requests.append(self.path)
super().do_GET()
def log_message(self, format: str, *args) -> None:
pass
server = http.server.ThreadingHTTPServer(
("127.0.0.1", port), functools.partial(Handler, directory=str(active))
)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(fixture / "certs/rrdp-server.pem", fixture / "certs/rrdp-server.key")
server.socket = context.wrap_socket(server.socket, server_side=True)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
output, state = base / "output", base / "state"
wrapper, wrapper_log = base / "rsync.py", base / "rsync.log"
make_rsync_wrapper(wrapper, fixture, wrapper_log)
config = base / "validate.toml"
write_config(
config, fixture=fixture, output=output, state=state,
ccr=output / "result.ccr", wrapper=wrapper, mirror=base / "mirror",
sync_preference="rrdp-then-rsync", rrdp_mode="auto",
validation_time="2026-06-01T00:00:00Z",
resource_mode="rfc6487", scope="publication-point",
)
text = "\n".join(line for line in config.read_text().splitlines() if not line.startswith("ta = "))
text = text.replace("[strict]", 'ta-refresh-policy = "require-fresh"\n\n[strict]')
config.write_text(text + "\n", encoding="utf-8")
proof = {"steps": []}
def validate(name: str) -> tuple[dict, str, list[str]]:
start = len(requests)
result = run_checked([binary, "validate", "--config", str(config)])
assert_successful_output(output, "2026-06-01T00:00:00Z")
summary = read_json(output / "summary.json")
seen = requests[start:]
assert summary["taCacheFallbackCount"] == 0, summary
assert not any("delta-" in uri for uri in seen), seen
assert not wrapper_log.exists(), "unexpected rsync fallback"
proof["steps"].append({
"name": name, "requests": seen, "vrps": summary["vrps"],
"resultStatus": summary["resultStatus"],
"taCacheFallbackCount": summary["taCacheFallbackCount"],
"trustAnchors": summary["trustAnchors"],
})
return summary, result.stderr, seen
summary, _, seen = validate("initial_snapshot_newer_ta")
assert "/rrdp/snapshot.xml" in seen, seen
assert summary["trustAnchors"][0]["source"] == "download", summary
cache = next(base.joinpath("state.ta-cache").glob("*.json"))
cache_before = cache.read_bytes()
# A new process sees a mutated historical delta hash at the same
# session/serial, and a valid but older TA with the identical key.
advertise_hash("33")
served_ta.write_bytes((fixture / "ta/custom-ta.cer").read_bytes())
summary, stderr, seen = validate("restarted_hash_mutation_and_older_ta")
assert "/rrdp/snapshot.xml" in seen, seen
assert "rrdp_session_desynchronized" in stderr, stderr
anchor = summary["trustAnchors"][0]
assert anchor["source"] == "cache" and not anchor["refreshFailed"], anchor
assert anchor["selectionReason"] == "cached_newer_not_before", anchor
assert cache.read_bytes() == cache_before, "older TA replaced the preferred cache"
_, stderr, seen = validate("restarted_noop_after_recovery")
assert "/rrdp/snapshot.xml" not in seen, seen
assert "rrdp_session_desynchronized" not in stderr, stderr
assert cache.read_bytes() == cache_before
return proof
finally:
server.shutdown()
thread.join(timeout=5)
server.server_close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("binary")
parser.add_argument("--proof", type=Path)
args = parser.parse_args()
proof = run(args.binary)
if args.proof:
args.proof.parent.mkdir(parents=True, exist_ok=True)
args.proof.write_text(json.dumps(proof, indent=2) + "\n", encoding="utf-8")
print(json.dumps(proof, indent=2))