From 2c1f6068924d3590a027df2c3b3fb8915222b043 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:48:43 +0200 Subject: [PATCH 01/81] Add limited initial IXFR out support (AXFR fallback only) and a minimal system test. --- .../scripts/manage-test-environment.sh | 3 +- integration-tests/system-tests.yml | 15 +++ integration-tests/tests/ixfr-out/action.yml | 92 +++++++++++++++++++ src/server/request.rs | 1 - src/server/service.rs | 75 ++++++++++++++- 5 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 integration-tests/tests/ixfr-out/action.yml diff --git a/integration-tests/scripts/manage-test-environment.sh b/integration-tests/scripts/manage-test-environment.sh index 2b811cd19..733d8b7e3 100755 --- a/integration-tests/scripts/manage-test-environment.sh +++ b/integration-tests/scripts/manage-test-environment.sh @@ -293,8 +293,7 @@ pattern: name: secondary zonefile: "%s.secondary-zone" allow-notify: 127.0.0.1 NOKEY - # Until Cascade supports IXFR we always use AXFR - request-xfr: AXFR 127.0.0.1@${_cascade_port} NOKEY + request-xfr: 127.0.0.1@${_cascade_port} NOKEY provide-xfr: 127.0.0.1 NOKEY zone: diff --git a/integration-tests/system-tests.yml b/integration-tests/system-tests.yml index c9d3592af..cdc590e49 100644 --- a/integration-tests/system-tests.yml +++ b/integration-tests/system-tests.yml @@ -166,6 +166,21 @@ jobs: with: log-level: ${{ inputs.log-level }} + ixfr-out: + name: Serve a zone via IXFR to the NSD secondary. + runs-on: ubuntu-latest + strategy: + matrix: + rust: [stable] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/set-build-profile + with: + build-profile: ${{ inputs.build-profile }} + - uses: ./integration-tests/tests/ixfr-out + with: + log-level: ${{ inputs.log-level }} + incremental-signing: name: Test incremental signing. runs-on: ubuntu-latest diff --git a/integration-tests/tests/ixfr-out/action.yml b/integration-tests/tests/ixfr-out/action.yml new file mode 100644 index 000000000..1adadce51 --- /dev/null +++ b/integration-tests/tests/ixfr-out/action.yml @@ -0,0 +1,92 @@ +# Making reusable composite actions documented at +# https://docs.github.com/en/actions/tutorials/create-actions/create-a-composite-action#creating-a-composite-action-within-the-same-repository +name: 'Serve a zone via IXFR to the NSD secondary.' +description: 'Serve a zone via IXFR to the NSD secondary.' +defaults: + # see: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defaultsrunshell + run: + shell: bash --noprofile --norc -eo pipefail -x {0} +inputs: + log-level: + description: The level of logging that Cascade should output. + required: false + default: debug + type: choice + options: + - error + - warning + - info + - debug + - trace +runs: + using: "composite" + steps: + - uses: ./.github/actions/prepare-systest-env + - uses: ./.github/actions/setup-and-start-cascade + with: + log-level: ${{ inputs.log-level }} + + - name: Add a NOTIFY with TSIG outbound policy + run: | + POLICY_DIR=$(integration-tests/scripts/get-default-path.sh policy-dir) + cascade template policy | grep -Ev '(send-notify-to|accept-xfr-from)' > "${POLICY_DIR}/custom.toml" + + sed -i -e 's/serial-policy = "date-counter"/serial-policy = "keep"/' "${POLICY_DIR}/custom.toml" + echo 'send-notify-to = ["127.0.0.1:1054"]' >> "${POLICY_DIR}/custom.toml" + + # TODO: Extend the test to use TSIG (driven by inputs ala the tsig-downstream test) + # once PRs #564 and #587 have been merged. + # echo 'send-notify-to = ["127.0.0.1:1054^tsig-key"]' >> "${POLICY_DIR}/custom.toml" + # cascade tsig add tsig-key hmac-sha256 "COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" + cascade policy reload + + - name: Make an initial zone based on RFC 1995 section 7 + run: | + tee example.test.zone <<'EOF' + example.test. IN SOA ns.example.test. mail.example.test. ( + 1 60 60 3600 5) + IN NS ns.example.test. + ns.example.test. IN A 133.69.136.1 + nezu.example.test. IN A 133.69.136.5 + EOF + + - name: Add the zone using the custom policy + run: | + cascade zone add --policy custom --source $PWD/example.test.zone example.test + + - name: Check zone status + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone status example.test | grep -q "Published zone available"; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example.test + echo "timeout: zone status did not report published zone available" >&2 + exit 1 + fi + sleep 1 + done + + - name: Check the SOA SERIAL at the NSD secondary + run: | + timeout=10 # seconds + start=$(date +%s) + until dig +short @127.0.0.1 -p 1054 example.test SOA | grep -q 'ns.example.test. mail.example.test. 1 60 60 3600 5'; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example.test + dig +short @127.0.0.1 -p 1054 example.test SOA + echo "::error:: timeout: NSD did not acquire the zone changes" + exit 1 + fi + sleep 1 + done + + # Note: There is no NSD metric that I am aware of that we can use to + # verify that it received IXFR from Cascade instead of AXFR, nor is there + # any Cascade Prometheus metric or CLI command that we can use to check + # this either. Increasing the NSD log level to 9 doesn't cause it to + # report whether it received IXFR or AXFR either. + + - name: Print log files on any failure in this job + uses: ./.github/actions/print-logfiles + if: failure() diff --git a/src/server/request.rs b/src/server/request.rs index 44c4a248a..078026d8a 100644 --- a/src/server/request.rs +++ b/src/server/request.rs @@ -139,7 +139,6 @@ pub enum ZoneRequestKind { Axfr, /// An IXFR request. - #[expect(dead_code)] Ixfr { /// The SOA record known to the client. known_soa: Record<(), Soa>>, diff --git a/src/server/service.rs b/src/server/service.rs index b6faecf37..6a334c10b 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -79,10 +79,14 @@ mod compat { message::Request, service::{CallResult, Service, ServiceResult}, }, - new::base::wire::ParseBytesZC, + new::{ + base::{name::Name, wire::ParseBytesZC}, + rdata::Soa, + }, tsig, }; use futures::Stream; + use tracing::trace; use crate::server::request::{RequestKind, ZoneRequestKind}; @@ -142,10 +146,9 @@ mod compat { } // TODO: Support IXFR. - ZoneRequestKind::Ixfr { .. } => Box::pin(std::future::ready(error( - old_request.message(), - Rcode::NOTIMP, - ))), + ZoneRequestKind::Ixfr { known_soa } => { + Box::pin(ixfr(old_request, known_soa.rdata, zone.clone())) as Response + } } } } @@ -252,6 +255,68 @@ mod compat { Box::new(stream) as _ } + async fn ixfr( + request: Request, Option>>, + client_soa: Soa>, + zone: ServedZone, + ) -> ResponseStream { + // Refuse IXFR requests over UDP. + if request.transport_ctx().is_udp() { + return error(request.message(), Rcode::NOTIMP); + } + + // Save a cheap clone of the zone to avoid a borrow checker error. + let zone_clone = zone.clone(); + + // Obtain a read lock to read the zone for an extended duration. + let viewer = zone.viewer.read_owned().await; + + if viewer.is_empty() { + // The zone is known to exist, but we don't have any data for it. + return error(request.message(), Rcode::NOTAUTH); + } + + // https://datatracker.ietf.org/doc/html/rfc1995#section-4 + // 4. Response Format + // "If incremental zone transfer is not available, the entire zone + // is returned. The first and the last RR of the response is the + // SOA record of the zone. I.e. the behavior is the same as an + // AXFR response except the query type is IXFR." + + // https://datatracker.ietf.org/doc/html/rfc1995#section-2 + // 2. Brief Description of the Protocol + // "If an IXFR query with the same or newer version number than that + // of the server is received, it is replied to with a single SOA + // record of the server's current version, just as in AXFR." + // ^^^^^^^^^^^^^^^ + // Errata https://www.rfc-editor.org/errata/eid3196 points out that + // this is NOT "just as in AXFR" as AXFR does not do that. + let our_soa_serial = { viewer.soa().rdata.serial }; + + if client_soa.serial >= our_soa_serial { + trace!("Responding to IXFR with single SOA because query serial >= zone serial"); + return soa(request.message(), &*viewer); + } else { + // TODO: implement retrieval and serving of diffs. + + // TODO: Add something like the Bind `max-ixfr-ratio` option that + // "sets the size threshold (expressed as a percentage of the + // size of the full zone) beyond which named chooses to use an + // AXFR response rather than IXFR when answering zone transfer + // requests"? + + // Note: Unlike RFC 5936 for AXFR, neither RFC 1995 nor RFC 9103 + // say anything about whether an IXFR response can consist of + // more than one response message, but given the 2^16 byte maximum + // response size of a TCP DNS message and the 2^16 maximum number + // of ANSWER RRs allowed per DNS response, large zones may not + // fit in a single response message and will have to be split into + // multiple response messages. + + axfr(request, zone_clone).await + } + } + fn error(request: &Message>, rcode: Rcode) -> ResponseStream { let response = MessageBuilder::new_stream_vec() .start_error(request, rcode) From 5dd79aa27a861246005ce4c500a17bc13f79365e Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:14:59 +0200 Subject: [PATCH 02/81] PoC IXFR-out. Will need changing based on PR #589. --- crates/zonedata/src/persister.rs | 8 +- integration-tests/tests/ixfr-out/action.yml | 96 +++++++++++++++- src/server/service.rs | 119 +++++++++++++++++--- src/zone/storage.rs | 24 +++- 4 files changed, 217 insertions(+), 30 deletions(-) diff --git a/crates/zonedata/src/persister.rs b/crates/zonedata/src/persister.rs index ae19663dc..373564cb2 100644 --- a/crates/zonedata/src/persister.rs +++ b/crates/zonedata/src/persister.rs @@ -116,17 +116,17 @@ impl SignedZonePersister { impl SignedZonePersister { /// Perform the actual persisting. - pub fn persist(self) -> SignedZonePersisted { + pub fn persist(self) -> (SignedZonePersisted, Arc) { let SignedZonePersister { data, - signed_index, + signed_index: _, signed_diff, } = self; // TODO - let _ = (signed_index, signed_diff); + // let _ = (signed_index, signed_diff); - SignedZonePersisted { data } + (SignedZonePersisted { data }, signed_diff) } } diff --git a/integration-tests/tests/ixfr-out/action.yml b/integration-tests/tests/ixfr-out/action.yml index 1adadce51..21d2aecdc 100644 --- a/integration-tests/tests/ixfr-out/action.yml +++ b/integration-tests/tests/ixfr-out/action.yml @@ -43,11 +43,11 @@ runs: - name: Make an initial zone based on RFC 1995 section 7 run: | tee example.test.zone <<'EOF' - example.test. IN SOA ns.example.test. mail.example.test. ( + EXAMPLE.TEST. IN SOA NS.EXAMPLE.TEST. mail.example.test. ( 1 60 60 3600 5) - IN NS ns.example.test. - ns.example.test. IN A 133.69.136.1 - nezu.example.test. IN A 133.69.136.5 + IN NS NS.EXAMPLE.TEST. + NS.EXAMPLE.TEST. IN A 133.69.136.1 + NEZU.EXAMPLE.TEST. IN A 133.69.136.5 EOF - name: Add the zone using the custom policy @@ -81,12 +81,100 @@ runs: sleep 1 done + - name: Edit the source zone and tell Cascade to reload it. + run: | + tee example.test.zone <<'EOF' + example.test. IN SOA ns.example.test. mail.example.test. ( + 2 60 60 3600 5) + IN NS NS.EXAMPLE.TEST. + NS.EXAMPLE.TEST. IN A 133.69.136.1 + JAIN-BB.EXAMPLE.TEST. IN A 133.69.136.4 + IN A 192.41.197.2 + EOF + cascade zone reload example.test + + - name: Check zone status + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone status example.test | grep -q "Published zone available"; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example.test + echo "timeout: zone status did not report published zone available" >&2 + exit 1 + fi + sleep 1 + done + + - name: Check the SOA SERIAL at the NSD secondary + run: | + timeout=10 # seconds + start=$(date +%s) + until dig +short @127.0.0.1 -p 1054 example.test SOA | grep -q 'ns.example.test. mail.example.test. 2 60 60 3600 5'; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example.test + dig +short @127.0.0.1 -p 1054 example.test SOA + echo "::error:: timeout: NSD did not acquire the zone changes" + exit 1 + fi + sleep 1 + done + + - name: Edit the source zone and tell Cascade to reload it. + run: | + tee example.test.zone <<'EOF' + EXAMPLE.TEST. IN SOA ns.example.test. mail.example.test. ( + 3 60 60 3600 5) + IN NS NS.EXAMPLE.TEST. + NS.EXAMPLE.TEST. IN A 133.69.136.1 + JAIN-BB.EXAMPLE.TEST. IN A 133.69.136.3 + IN A 192.41.197.2 + EOF + cascade zone reload example.test + + - name: Check zone status + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone status example.test | grep -q "Published zone available"; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example.test + echo "timeout: zone status did not report published zone available" >&2 + exit 1 + fi + sleep 1 + done + + - name: Check the SOA SERIAL at the NSD secondary + run: | + timeout=10 # seconds + start=$(date +%s) + until dig +short @127.0.0.1 -p 1054 example.test SOA | grep -q 'ns.example.test. mail.example.test. 3 60 60 3600 5'; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example.test + dig +short @127.0.0.1 -p 1054 example.test SOA + echo "::error:: timeout: NSD did not acquire the zone changes" + exit 1 + fi + sleep 1 + done + # Note: There is no NSD metric that I am aware of that we can use to # verify that it received IXFR from Cascade instead of AXFR, nor is there # any Cascade Prometheus metric or CLI command that we can use to check # this either. Increasing the NSD log level to 9 doesn't cause it to # report whether it received IXFR or AXFR either. + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test AXFR + + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=1 + + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=2 + + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=3 + + - run: dig +onesoa +noall +answer @127.0.0.1 -p 1054 example.test AXFR + - name: Print log files on any failure in this job uses: ./.github/actions/print-logfiles if: failure() diff --git a/src/server/service.rs b/src/server/service.rs index 6a334c10b..72f163af8 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -262,6 +262,7 @@ mod compat { ) -> ResponseStream { // Refuse IXFR requests over UDP. if request.transport_ctx().is_udp() { + tracing::warn!("Reject IXFR over UDP"); return error(request.message(), Rcode::NOTIMP); } @@ -276,6 +277,9 @@ mod compat { return error(request.message(), Rcode::NOTAUTH); } + // Remember the latest SOA. + let new_soa = viewer.soa().clone(); + // https://datatracker.ietf.org/doc/html/rfc1995#section-4 // 4. Response Format // "If incremental zone transfer is not available, the entire zone @@ -296,25 +300,104 @@ mod compat { if client_soa.serial >= our_soa_serial { trace!("Responding to IXFR with single SOA because query serial >= zone serial"); return soa(request.message(), &*viewer); - } else { - // TODO: implement retrieval and serving of diffs. - - // TODO: Add something like the Bind `max-ixfr-ratio` option that - // "sets the size threshold (expressed as a percentage of the - // size of the full zone) beyond which named chooses to use an - // AXFR response rather than IXFR when answering zone transfer - // requests"? - - // Note: Unlike RFC 5936 for AXFR, neither RFC 1995 nor RFC 9103 - // say anything about whether an IXFR response can consist of - // more than one response message, but given the 2^16 byte maximum - // response size of a TCP DNS message and the 2^16 maximum number - // of ANSWER RRs allowed per DNS response, large zones may not - // fit in a single response message and will have to be split into - // multiple response messages. - - axfr(request, zone_clone).await } + + let diffs = zone.handle.state.lock().unwrap().storage.diffs.clone(); + + // TODO: Add something like the Bind `max-ixfr-ratio` option that + // "sets the size threshold (expressed as a percentage of the size of + // the full zone) beyond which named chooses to use an AXFR response + // rather than IXFR when answering zone transfer requests"? + + // Note: Unlike RFC 5936 for AXFR, neither RFC 1995 nor RFC 9103 say + // anything about whether an IXFR response can consist of more than + // one response message, but given the 2^16 byte maximum response + // size of a TCP DNS message and the 2^16 maximum number of ANSWER + // RRs allowed per DNS response, large zones may not fit in a single + // response message and will have to be split into multiple response + // messages. + + // Currently we have only a single diff available. + for (i, d) in diffs.iter().enumerate() { + tracing::info!( + "Diff {i} for zone '{}': serial {} => serial {}", + zone.handle.name, + d.removed_soa.as_ref().unwrap().0.rdata.serial, + d.added_soa.as_ref().unwrap().0.rdata.serial, + ); + } + + // Find the diff, if we have it, that removes the SOA serial number + // that the client currently has. That will be the start of the diff + // that we need to serve. + let start_idx = diffs.iter().position(|d| { + d.removed_soa.as_ref().map(|rr| rr.0.rdata.serial) == Some(client_soa.serial) + }); + + let Some(start_idx) = start_idx else { + tracing::trace!( + "Falling back from IXFR to AXFR because no diff is available for zone '{}' from serial {}", + zone.handle.name, + client_soa.serial, + ); + return axfr(request, zone_clone).await; + }; + + let (tx, mut rx) = tokio::sync::mpsc::channel(1024); + + // Stream the records in the background. + tokio::task::spawn(async move { + // Collect the sequence of IXFR output records.. + let mut rrs = vec![new_soa.clone().into()]; + for diff in &diffs[start_idx..] { + rrs.push(diff.removed_soa.clone().unwrap().into()); + rrs.extend(diff.removed_records.clone()); + rrs.push(diff.added_soa.clone().unwrap().into()); + rrs.extend(diff.added_records.clone()); + } + rrs.push(new_soa.into()); + + // Divide the records into DNS messages. + let mut rr_iter = rrs.into_iter().peekable(); + let mut max_message_size = u16::MAX; // TCP + max_message_size -= request.num_reserved_bytes(); + let messages = std::iter::from_fn(move || { + rr_iter.peek()?; + + let mut builder = MessageBuilder::new_stream_vec(); + builder.set_push_limit(max_message_size as usize); + let mut builder = builder + .start_answer(request.message(), Rcode::NOERROR) + .unwrap(); + builder.header_mut().set_aa(true); + + while let Some(record) = rr_iter.peek() { + match builder.push(OldRecord::from(record.clone())) { + // On success, consume the record. + Ok(()) => { + let _ = rr_iter.next(); + } + + // Once the message runs out of space, stop. + Err(_) => break, + } + } + + let response = builder.additional(); + Some(CallResult::new(response)) + }); + + for message in messages { + if tx.send(message).await.is_err() { + // The channel has closed; stop. + break; + } + } + }); + + let stream = futures::stream::poll_fn(move |cx| rx.poll_recv(cx).map(|m| m.map(Ok))); + + return Box::new(stream) as _; } fn error(request: &Message>, rcode: Rcode) -> ResponseStream { diff --git a/src/zone/storage.rs b/src/zone/storage.rs index 5574d26ab..48b4f5de0 100644 --- a/src/zone/storage.rs +++ b/src/zone/storage.rs @@ -26,9 +26,9 @@ use std::{fmt, sync::Arc}; use cascade_zonedata::{ - LoadedZoneBuilder, LoadedZoneBuilt, LoadedZonePersister, LoadedZoneReviewer, SignedZoneBuilder, - SignedZoneBuilt, SignedZonePersister, SignedZoneReviewer, SoaRecord, ZoneCleaner, - ZoneDataStorage, ZoneViewer, + DiffData, LoadedZoneBuilder, LoadedZoneBuilt, LoadedZonePersister, LoadedZoneReviewer, + SignedZoneBuilder, SignedZoneBuilt, SignedZonePersister, SignedZoneReviewer, SoaRecord, + ZoneCleaner, ZoneDataStorage, ZoneViewer, }; use domain::base::Serial; use tracing::{info, trace, trace_span, warn}; @@ -648,12 +648,24 @@ impl StorageZoneHandle<'_> { trace!("Persisting the signed instance"); // Perform the persisting. - let persisted = tokio::task::spawn_blocking(move || persister.persist()).await.unwrap(); + let (persisted, signed_diff) = tokio::task::spawn_blocking(move || persister.persist()).await.unwrap(); // Mark persistence as completed. let viewer = { let mut state = zone.state.lock().unwrap(); let state = &mut *state; + + // Store the signed diff in-memory for serving IXFR. + // Only push a diff if a SOA was removed, otherwise this is + // not a diff to a previous version of the zone but actually + // the entire new zone content compared to an empty zone. Also + // don't store a diff to the same serial. + if signed_diff.removed_soa.is_some() { + if signed_diff.removed_soa != signed_diff.added_soa { + state.storage.diffs.push(signed_diff); + } + } + match transition(&mut state.storage.machine) { (transition, ZoneDataStorage::PersistingSigned(s)) => { let (s, viewer) = s.mark_complete(persisted); @@ -836,6 +848,9 @@ pub struct StorageState { // current i.e. published zone instance. pub published_loaded_soa: Option, + /// Diffs from one serial to another. + pub diffs: Vec>, + /// Ongoing background tasks. /// /// When the zone data needs to be cleaned or persisted, a background task @@ -857,6 +872,7 @@ impl StorageState { signed_review_soa: None, published_soa: None, published_loaded_soa: None, + diffs: Default::default(), background_tasks: Default::default(), } } From 942120319487da8f4e4c292102c1f1e0994483cc Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:14:16 +0200 Subject: [PATCH 03/81] Clippy. --- src/persistence/persist.rs | 8 +++----- src/server/service.rs | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 38c372693..bf9f9dda0 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -44,11 +44,9 @@ pub fn persist_signed( // compared to an empty zone. Also don't store a diff to the same serial. let signed_diff = persister.signed_diff(); - if signed_diff.removed_soa.is_some() { - if signed_diff.removed_soa != signed_diff.added_soa { - let mut state = zone.state.lock().unwrap(); - state.storage.diffs.push(signed_diff.clone()); - } + if signed_diff.removed_soa.is_some() && signed_diff.removed_soa != signed_diff.added_soa { + let mut state = zone.state.lock().unwrap(); + state.storage.diffs.push(signed_diff.clone()); } persister.mark_complete() diff --git a/src/server/service.rs b/src/server/service.rs index c856c3d2a..8582ace00 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -439,7 +439,7 @@ mod compat { let stream = futures::stream::poll_fn(move |cx| rx.poll_recv(cx).map(|m| m.map(Ok))); - return Box::new(stream) as _; + Box::new(stream) as _ } fn error(request: &Message>, rcode: Rcode) -> ResponseStream { From 5345a2b9beece2ee46a7a4497f8279c92578a7f9 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:30:25 +0200 Subject: [PATCH 04/81] De-clutter ixfr-out system test output. --- integration-tests/tests/ixfr-out/action.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/integration-tests/tests/ixfr-out/action.yml b/integration-tests/tests/ixfr-out/action.yml index 21d2aecdc..d6394463f 100644 --- a/integration-tests/tests/ixfr-out/action.yml +++ b/integration-tests/tests/ixfr-out/action.yml @@ -165,15 +165,18 @@ runs: # this either. Increasing the NSD log level to 9 doesn't cause it to # report whether it received IXFR or AXFR either. - - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test AXFR + # TODO: Verify the AXFR and IXFR response content is as expected / + # matching that of NSD. - - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=1 + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test AXFR > cascade-axfr.log - - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=2 + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=1 >cascade-ixfr-1.log - - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=3 + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=2 >cascade-ixfr-2.log - - run: dig +onesoa +noall +answer @127.0.0.1 -p 1054 example.test AXFR + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=3 >cascade-ixfr-3.log + + - run: dig +onesoa +noall +answer @127.0.0.1 -p 1054 example.test AXFR >nsd-axfr.log - name: Print log files on any failure in this job uses: ./.github/actions/print-logfiles From f8890f78546621eaa5359d03b77f8d6ddc76c8f0 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 1 May 2026 00:44:54 +0200 Subject: [PATCH 05/81] Show TSIG key of source in output of CLI command zone status. --- crates/api/src/lib.rs | 8 +++++++- src/loader/mod.rs | 2 +- src/units/http_server.rs | 8 ++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 3ae1eec4c..0ecf01acb 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -378,7 +378,13 @@ impl Display for ZoneSource { match self { ZoneSource::None => f.write_str(""), ZoneSource::Zonefile { path } => path.fmt(f), - ZoneSource::Server { addr, .. } => addr.fmt(f), + ZoneSource::Server { addr, tsig_key } => { + write!(f, "{addr}")?; + if let Some(tsig_key) = &tsig_key { + write!(f, " with TSIG key '{tsig_key}'")?; + } + Ok(()) + } } } } diff --git a/src/loader/mod.rs b/src/loader/mod.rs index 5bb26dd71..39c8c7e44 100644 --- a/src/loader/mod.rs +++ b/src/loader/mod.rs @@ -318,7 +318,7 @@ impl std::fmt::Display for Source { Source::Server { addr, tsig_key } => { write!(f, "{addr}")?; if let Some(tsig_key) = &tsig_key { - write!(f, " with TSIG key '{}'", tsig_key.name())?; + write!(f, " with TSIG key '{}'", tsig_key.name())?; } Ok(()) } diff --git a/src/units/http_server.rs b/src/units/http_server.rs index 07998dbfc..8ad0c940f 100644 --- a/src/units/http_server.rs +++ b/src/units/http_server.rs @@ -384,10 +384,10 @@ impl HttpServer { source = match zone_state.loader.source.clone() { loader::Source::None => api::ZoneSource::None, loader::Source::Zonefile { path } => api::ZoneSource::Zonefile { path }, - loader::Source::Server { addr, tsig_key: _ } => api::ZoneSource::Server { - addr, - tsig_key: None, - }, + loader::Source::Server { addr, tsig_key } => { + let tsig_key = tsig_key.map(|k| k.name().clone()); + api::ZoneSource::Server { addr, tsig_key } + } }; unsigned_review_addr = state .center From 52bd50de661a0cd6a696435bfaf512d39fb3fe0d Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 1 May 2026 01:59:40 +0200 Subject: [PATCH 06/81] Updated to match updated persist/restore framework. --- src/persistence/persist.rs | 153 +++++++++++++++++- src/persistence/restore.rs | 319 ++++++++++++++++++++++++++++++++++++- src/zone/mod.rs | 13 ++ src/zone/state/mod.rs | 4 + src/zone/state/v1.rs | 13 ++ 5 files changed, 487 insertions(+), 15 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 3981e81df..b96b1d984 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -1,12 +1,22 @@ //! Persisting zone data. -use std::sync::Arc; +use std::{ + fs::File, + io::{BufWriter, Write}, + path::Path, + sync::Arc, +}; use cascade_zonedata::{ - LoadedZonePersisted, LoadedZonePersister, SignedZonePersisted, SignedZonePersister, + DiffData, LoadedZonePersisted, LoadedZonePersister, SignedZonePersisted, SignedZonePersister, }; -use crate::{center::Center, zone::Zone}; +use domain::new::base::wire::{BuildBytes, TruncationError}; + +use crate::{ + center::Center, + zone::{Zone, ZoneHandle}, +}; /// Persist the data for a loaded instance of a zone. #[tracing::instrument( @@ -19,8 +29,27 @@ pub fn persist_loaded( center: &Arc
, persister: LoadedZonePersister, ) -> LoadedZonePersisted { - // TODO - let _ = (zone, center); + // Determine the path to write to and update the record of written + // paths here as we don't want to give responsibility for working + // with ZoneState to the persistence crate. Accumulate a set of + // diffs per unsigned and signed zone, each stored at a path one + // suffixed by an index which rises by one when persisted. + // TODO: Don't keep an unlimited number of diffs. + // TODO: Compact diffs when idle? + let mut state = zone.state.lock().unwrap(); + let handle = ZoneHandle { + zone: &zone, + state: &mut state, + center: ¢er, + }; + let next_idx = handle.state.persisted_loaded_diffs.len(); + let destination = center + .config + .zone_state_dir + .join(format!("{}.loaded.{next_idx}", zone.name)); + persist_to_file(destination.as_std_path(), persister.loaded_diff().clone()); + handle.state.persisted_loaded_diffs.push(destination.into()); + handle.zone.mark_dirty(handle.state, handle.center); persister.mark_complete() } @@ -35,7 +64,117 @@ pub fn persist_signed( center: &Arc
, persister: SignedZonePersister, ) -> SignedZonePersisted { - // TODO - let _ = (zone, center); + // Determine the path to write to and update the record of written + // paths here as we don't want to give responsibility for working + // with ZoneState to the persistence crate. Accumulate a set of + // diffs per unsigned and signed zone, each stored at a path one + // suffixed by an index which rises by one when persisted. + // TODO: Don't keep an unlimited number of diffs. + // TODO: Compact diffs when idle? + let mut state = zone.state.lock().unwrap(); + let handle = ZoneHandle { + zone: &zone, + state: &mut state, + center: ¢er, + }; + let next_idx = handle.state.persisted_signed_diffs.len(); + let destination = center + .config + .zone_state_dir + .join(format!("{}.signed.{next_idx}", zone.name)); + persist_to_file(destination.as_std_path(), persister.signed_diff().clone()); + handle.state.persisted_loaded_diffs.push(destination.into()); + handle.zone.mark_dirty(handle.state, handle.center); persister.mark_complete() } + +//------------ persist_to_file() ---------------------------------------------- + +fn persist_to_file(destination: &Path, loaded_diff: Arc) { + // Write the diff in AXFR / IXFR wire format to disk. + let f = File::create_new(destination).unwrap_or_else(|err| { + panic!( + "Failed to persist unsigned zone data to '{}': {err}", + destination.display() + ); + }); + let mut f = BufWriter::new(f); + + let mut buf = vec![0u8; 1024]; + + fn write_rr(buf: &mut Vec, rr: &RR, mut writer: W) + where + RR: std::ops::Deref, + W: Write, + { + // Earlier attempt using presentation format instead of wire format. + // let r: OldRecord = loaded_diff.added_soa.clone().unwrap().into(); + // writeln!(f, "{r}").unwrap(); + + let buf_len = buf.len(); + + let num_bytes_to_write = match rr.build_bytes(buf) { + Ok(unused_buf_part) => { + // Build succeeded, now determine how many bytes were built. + buf_len - unused_buf_part.len() + } + Err(TruncationError) => { + // Build failed due to insufficient buffer space, resize the + // buffer to be large enough then redo the build which should + // not fail this time. + let num_required_bytes = rr.built_bytes_size(); + buf.resize(num_required_bytes, 0u8); + rr.build_bytes(buf).unwrap(); + num_required_bytes + } + }; + + // Failure to write would be imply there is a serious problem with + // the environment in which Cascade is running, and being unable to + // persist a zone means we can't allow downstreams to consume it as if + // we get terminated we will not be able to serve the same zone again + // (which would only actually be a real problem if we are unable to + // bump the serial). So, treat a write failure as fatal. + writer.write_all(&buf[0..num_bytes_to_write]).unwrap(); + } + + let added_soa = loaded_diff.added_soa.clone().unwrap(); + + // IXFR format has the form: + // - New SOA + // - Old SOA + // - Deleted records + // - New SOA + // - Added records + // - New SOA + // + // AXFR format has the form: + // - New SOA + // - Records + // - New SOA + // + // Write AXFR if no records were deleted by the diff, else write IXFR. + + write_rr(&mut buf, &added_soa, &mut f); + + // Start deleted records block by writing the old SOA, if any. + if let Some(removed_soa) = &loaded_diff.removed_soa { + write_rr(&mut buf, removed_soa, &mut f); + + // Write the deleted records. + for r in &loaded_diff.removed_records { + write_rr(&mut buf, r, &mut f); + } + + // Start added records block by writing the new SOA + write_rr(&mut buf, &added_soa, &mut f); + } + + // Write the added records. + for r in &loaded_diff.added_records { + write_rr(&mut buf, r, &mut f); + } + + // Finish the AXFR/IXFR by writing the new SOA again + write_rr(&mut buf, &added_soa, &mut f); +} diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index bbfbb709d..60d8c169e 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -1,8 +1,27 @@ //! Restoring persisted zone data. -use std::{io, sync::Arc}; +use std::{ + fs::File, + io::{self, BufReader, Read}, + path::Path, + sync::Arc, +}; -use cascade_zonedata::{LoadedZoneRestorer, SignedZoneRestorer}; +use cascade_zonedata::{ + LoadedZonePatcher, LoadedZoneRestorer, RegularRecord, SignedZonePatcher, SignedZoneRestorer, + SoaRecord, +}; +use domain::{ + new::{ + base::{ + RType, Record, + name::{NameBuf, RevNameBuf}, + parse::{ParseMessageBytes, SplitMessageBytes}, + }, + rdata::{BoxedRecordData, Soa}, + }, + utils::dst::UnsizedCopy, +}; use crate::{center::Center, zone::Zone}; @@ -17,9 +36,54 @@ pub fn restore_loaded( center: &Arc
, restorer: &mut LoadedZoneRestorer, ) -> io::Result<()> { - // TODO - let _ = (zone, center, restorer); - Err(io::Error::other("not yet implemented")) + let state = zone.state.lock().unwrap(); + if !state.persisted_loaded_diffs.is_empty() { + // Determine the paths to read from. Each zone is persisted as an AXFR + // plus zero or more IXFRs. The restorer takes a base path ending in + // an unsigned integer number and loads that file plus N more, where + // the final number in the path is replaced by the previous number + // plus one each time. + let loaded_source = center + .config + .zone_state_dir + .join(format!("{}.loaded.0", zone.name)); + let count = state.persisted_loaded_diffs.len(); + let mut buf = Vec::::new(); + + // Extract the initial unsigned integer number file extension. + let n = loaded_source + .extension() + .unwrap() + .to_string() + .parse::() + .unwrap(); + + // Process the initial "loaded" AXFR wire format dump. + let (soa, records) = load_axfr_wire_dump(loaded_source.as_std_path(), &mut buf).unwrap(); + let mut loaded_replacer = restorer.fill().unwrap(); // TODO: SAFETY + loaded_replacer.set_soa(soa).unwrap(); + loaded_replacer.set_records(records).unwrap(); + loaded_replacer.apply().unwrap(); + + if count > 1 { + let mut loaded_patcher = restorer.patch().unwrap(); // TODO: SAFETY + let mut source = loaded_source.to_path_buf(); + for i in 1..count { + source.set_extension((n + i).to_string()); + + let loaded_patcher = &mut loaded_patcher; + load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { + apply_ixfr_event_to_loaded_data(loaded_patcher, event); + }) + .unwrap(); + + loaded_patcher.next_patchset().unwrap() + } + loaded_patcher.apply().unwrap(); + } + } + + io::Result::Ok(()) } /// Restore the loaded instance data of a zone. @@ -33,7 +97,246 @@ pub fn restore_signed( center: &Arc
, restorer: &mut SignedZoneRestorer, ) -> io::Result<()> { - // TODO - let _ = (zone, center, restorer); - Err(io::Error::other("not yet implemented")) + let state = zone.state.lock().unwrap(); + if !state.persisted_loaded_diffs.is_empty() { + // Determine the paths to read from. Each zone is persisted as an AXFR + // plus zero or more IXFRs. The restorer takes a base path ending in + // an unsigned integer number and loads that file plus N more, where + // the final number in the path is replaced by the previous number + // plus one each time. + let signed_source = center + .config + .zone_state_dir + .join(format!("{}.signed.0", zone.name)); + let count = state.persisted_signed_diffs.len(); + let mut buf = Vec::::new(); + + // Extract the initial unsigned integer number file extension. + let n = signed_source + .extension() + .unwrap() + .to_string() + .parse::() + .unwrap(); + + // Process the initial "signed" AXFR wire format dump. + let (soa, records) = load_axfr_wire_dump(signed_source.as_std_path(), &mut buf).unwrap(); + let mut signed_replacer = restorer.fill().unwrap(); // TODO: SAFETY + signed_replacer.set_soa(soa).unwrap(); + signed_replacer.set_records(records).unwrap(); + signed_replacer.apply().unwrap(); + + // Process zero or more "signed" IXFR wire format dumps. + if count > 1 { + let mut signed_patcher = restorer.patch().unwrap(); // TODO: SAFETY + let mut source = signed_source.to_path_buf(); + for i in 1..count { + source.set_extension((n + i).to_string()); + + load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { + apply_ixfr_event_to_signed_data(&mut signed_patcher, event); + }) + .unwrap(); + + signed_patcher.next_patchset().unwrap() + } + signed_patcher.apply().unwrap(); + } + } + io::Result::Ok(()) +} + +fn parse_rr( + buf: &[u8], + pos: usize, +) -> Result<(Record, usize), String> { + Record::::split_message_bytes(buf, pos) + .map_err(|err| format!("Invalid wire format RR: {err}")) +} + +fn parse_soa(buf: &[u8], pos: usize) -> Result<(SoaRecord, Soa, usize), String> { + let (first_rr, rest) = Record::>::split_message_bytes(buf, pos) + .map_err(|err| { + format!("Failed to parse record of persisted XFR dump as a SOA record: {err}") + })?; + + if first_rr.rtype != RType::SOA { + return Err(format!( + "Persisted XFR dump record has RTYPE '{}' which is not a SOA RR.", + first_rr.rtype.code + )); + } + + // Save the SOA rdata for comparison later, before we convert it from + // using NameBuf typed fields to having Box typed fields (which + // is the format we store resource records in longer term in memory). + let soa_rdata = first_rr.rdata.clone(); + let soa_rr = SoaRecord(first_rr.transform_ref( + |name: &RevNameBuf| (*name).unsized_copy_into(), + |data: &Soa| data.map_names_by_ref(|name| (*name).unsized_copy_into()), + )); + + Ok((soa_rr, soa_rdata, rest)) +} + +fn load_file_into_memory(source: &Path, buf: &mut Vec) -> std::io::Result { + buf.clear(); + BufReader::new(File::open(source)?).read_to_end(buf) +} + +fn load_axfr_wire_dump( + source: &Path, + buf: &mut Vec, +) -> Result<(SoaRecord, Vec), String> { + load_file_into_memory(source, buf).map_err(|err| err.to_string())?; + + let (start_soa, start_soa_rdata, mut rest) = parse_soa(buf, 0)?; + + let mut records = vec![]; + loop { + // Parse remaining resource records from the current read + // index in the buffer, each time receiving back the 'rest' + // index at which parsing should start on the next iteration + // of the loop. + let r; + (r, rest) = parse_rr(buf, rest)?; + + // If the parsed record is a SOA it should be identical to + // the SOA record that started the AXFR dump and signals the + // end of the dump. + // + // TODO: If the SOA is not at the apex should we keep it and + // keep going? + if r.rtype == RType::SOA { + // An AXFR ends with a SOA RR identical to the start SOA. + // Since we persisted the AXFR wire dump to disk it is a + // very unexpected error if it does not match the starting + // SOA. + if r.rname.as_ref() == &*start_soa.0.rname + || r.rclass == start_soa.0.rclass + || r.ttl == start_soa.ttl + { + let soa_rdata = Soa::::parse_message_bytes(r.rdata.bytes(), 0).unwrap(); + if soa_rdata == start_soa_rdata { + break; + } + } + } + + let r = r.transform(|name| name.unsized_copy_into(), |data| data); + records.push(RegularRecord(r)); + } + + Ok((start_soa, records)) +} + +fn load_ixfr_wire_dump(source: &Path, buf: &mut Vec, mut rr_handler: F) -> Result<(), String> +where + F: FnMut(IxfrEvent), +{ + load_file_into_memory(source, buf).map_err(|err| err.to_string())?; + + let buf = buf.as_slice(); + let (start_soa, start_soa_rdata, mut rest) = parse_soa(buf, 0)?; + + // Parse one or more diff sequences. + loop { + // Parse one diff sequence: + // "Each difference sequence represents one update to the + // zone (one SOA serial change) consisting of deleted RRs + // and added RRs. The first RR of the deleted RRs is the + // older SOA RR and the first RR of the added RRs is the + // newer SOA RR." + // Iterate over a sequence of resource records to remove + // followed by a sequence of resource records to add, + + // Read the first deleted RR which should be a SOA RR. + let r; + (r, rest) = parse_rr(buf, rest)?; + + if r.rtype != RType::SOA { + // If this is the first RR of the sequence it MUST + // be a SOA RR. + return Err(format!( + "Expected first record of IXFR remove sequence to be a SOA RR but found RTYPE {}", + r.rtype.code + )); + } + + let r = RegularRecord(r.transform(|name| name.unsized_copy_into(), |data| data)); + rr_handler(IxfrEvent::Remove(r)); + + // Read more removed RRs until a SOA signals the end of the + // removed RRs and the start of the added RRs. + loop { + let r; + (r, rest) = parse_rr(buf, rest)?; + + let r = RegularRecord(r.transform(|name| name.unsized_copy_into(), |data| data)); + if r.rtype == RType::SOA { + rr_handler(IxfrEvent::Add(r)); + break; + } else { + rr_handler(IxfrEvent::Remove(r)); + } + } + + // Read more added RRs until a SOA signals the end of added RRs. + loop { + let r; + (r, rest) = parse_rr(buf, rest)?; + + let r = RegularRecord(r.transform(|name| name.unsized_copy_into(), |data| data)); + if r.rtype == RType::SOA { + if is_same_soa(&start_soa, &start_soa_rdata, &r) { + // This SOA signals the end of the IXFR dump. + return Ok(()); + } + + rr_handler(IxfrEvent::EndOfUpdate); + rr_handler(IxfrEvent::Remove(r)); + break; + } else { + rr_handler(IxfrEvent::Add(r)); + } + } + } +} + +fn is_same_soa(start_soa: &SoaRecord, start_soa_rdata: &Soa, r: &RegularRecord) -> bool { + if r.rtype == RType::SOA + && r.rname.as_ref() == &*start_soa.0.rname + && r.rclass == start_soa.0.rclass + && r.ttl == start_soa.ttl + { + let soa_rdata = Soa::::parse_message_bytes(r.rdata.bytes(), 0).unwrap(); + return &soa_rdata == start_soa_rdata; + } + false +} + +enum IxfrEvent { + Remove(RegularRecord), + Add(RegularRecord), + EndOfUpdate, +} + +fn apply_ixfr_event_to_loaded_data(patcher: &mut LoadedZonePatcher<'_>, event: IxfrEvent) { + match event { + IxfrEvent::Remove(r) if r.rtype == RType::SOA => patcher.remove_soa(r.into()).unwrap(), + IxfrEvent::Remove(r) => patcher.remove(r).unwrap(), + IxfrEvent::Add(r) if r.rtype == RType::SOA => patcher.add_soa(r.into()).unwrap(), + IxfrEvent::Add(r) => patcher.add(r).unwrap(), + IxfrEvent::EndOfUpdate => patcher.next_patchset().unwrap(), + } +} + +fn apply_ixfr_event_to_signed_data(patcher: &mut SignedZonePatcher<'_>, event: IxfrEvent) { + match event { + IxfrEvent::Remove(r) if r.rtype == RType::SOA => patcher.remove_soa(r.into()).unwrap(), + IxfrEvent::Remove(r) => patcher.remove(r).unwrap(), + IxfrEvent::Add(r) if r.rtype == RType::SOA => patcher.add_soa(r.into()).unwrap(), + IxfrEvent::Add(r) => patcher.add(r).unwrap(), + IxfrEvent::EndOfUpdate => patcher.next_patchset().unwrap(), + } } diff --git a/src/zone/mod.rs b/src/zone/mod.rs index 7a4d635df..5744f8445 100644 --- a/src/zone/mod.rs +++ b/src/zone/mod.rs @@ -1,6 +1,7 @@ //! Zone-specific state and management. use std::collections::HashSet; +use std::path::PathBuf; use std::{ borrow::Borrow, cmp::Ordering, @@ -248,6 +249,16 @@ pub struct ZoneState { /// History of interesting events that occurred for this zone. pub history: Vec, + /// Locations of persisted unsigned zone diffs to enable IXFR from + /// the upstream to resume on restart, and to enable a complete latest + /// unsigned version of the zone to be reconstituted. + pub persisted_loaded_diffs: Vec, + + /// Locations of persisted signed zone diffs to ensure IXFR out toward + /// downstreams is still possible after restart, and to enable a complete + /// latest signed version of the zone to be reconsituted. + pub persisted_signed_diffs: Vec, + /// Loading new versions of the zone. pub loader: LoaderState, @@ -310,6 +321,8 @@ impl Default for ZoneState { signer: Default::default(), storage: Default::default(), persistence: Default::default(), + persisted_loaded_diffs: Default::default(), + persisted_signed_diffs: Default::default(), } } } diff --git a/src/zone/state/mod.rs b/src/zone/state/mod.rs index 8efe55379..356cb82ad 100644 --- a/src/zone/state/mod.rs +++ b/src/zone/state/mod.rs @@ -91,6 +91,8 @@ impl Spec { last_signature_refresh, previous_serial, history, + persisted_loaded_diffs, + persisted_signed_diffs, }) => { let loader = LoaderState { source: source.parse(), @@ -118,6 +120,8 @@ impl Spec { previous_serial, loader, history, + persisted_loaded_diffs, + persisted_signed_diffs, ..Default::default() } } diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index 81e684153..ac6989d9d 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use std::net::SocketAddr; +use std::path::PathBuf; use bytes::Bytes; use camino::Utf8Path; @@ -87,6 +88,16 @@ pub struct Spec { /// History of interesting events that occurred for this zone. pub history: Vec, + + /// Locations of persisted unsigned zone diffs to enable IXFR from + /// the upstream to resume on restart, and to enable a complete latest + /// unsigned version of the zone to be reconstituted. + pub persisted_loaded_diffs: Vec, + + /// Locations of persisted signed zone diffs to ensure IXFR out toward + /// downstreams is still possible after restart, and to enable a complete + /// latest signed version of the zone to be reconsituted. + pub persisted_signed_diffs: Vec, } //--- Conversion @@ -106,6 +117,8 @@ impl Spec { last_signature_refresh: zone.last_signature_refresh.clone(), previous_serial: zone.previous_serial, history: zone.history.clone(), + persisted_loaded_diffs: zone.persisted_loaded_diffs.clone(), + persisted_signed_diffs: zone.persisted_signed_diffs.clone(), } } } From c613e24e19c5e0c1f0f03aa6d99f1786251c7d41 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 6 May 2026 10:28:43 +0200 Subject: [PATCH 07/81] Clippy. --- src/persistence/persist.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index b96b1d984..dcb4496fc 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -38,9 +38,9 @@ pub fn persist_loaded( // TODO: Compact diffs when idle? let mut state = zone.state.lock().unwrap(); let handle = ZoneHandle { - zone: &zone, + zone, state: &mut state, - center: ¢er, + center, }; let next_idx = handle.state.persisted_loaded_diffs.len(); let destination = center @@ -73,9 +73,9 @@ pub fn persist_signed( // TODO: Compact diffs when idle? let mut state = zone.state.lock().unwrap(); let handle = ZoneHandle { - zone: &zone, + zone, state: &mut state, - center: ¢er, + center, }; let next_idx = handle.state.persisted_signed_diffs.len(); let destination = center From c4b91d586ef4371814d6019c003f237669c85513 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 6 May 2026 11:07:53 +0200 Subject: [PATCH 08/81] Handle errors during restore of persisted zone data. --- src/persistence/restore.rs | 92 +++++++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 37 deletions(-) diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 60d8c169e..97b3daf85 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -50,17 +50,15 @@ pub fn restore_loaded( let count = state.persisted_loaded_diffs.len(); let mut buf = Vec::::new(); - // Extract the initial unsigned integer number file extension. - let n = loaded_source - .extension() - .unwrap() - .to_string() - .parse::() - .unwrap(); - // Process the initial "loaded" AXFR wire format dump. - let (soa, records) = load_axfr_wire_dump(loaded_source.as_std_path(), &mut buf).unwrap(); - let mut loaded_replacer = restorer.fill().unwrap(); // TODO: SAFETY + let (soa, records) = + load_axfr_wire_dump(loaded_source.as_std_path(), &mut buf).map_err(|err| { + io::Error::other(format!( + "Failed to load persisted snapshot for zone '{}' from '{loaded_source}': {err}", + zone.name + )) + })?; + let mut loaded_replacer = restorer.fill().ok_or(io::Error::other(format!("Unable to restore persisted snapshot for zone '{}' from '{loaded_source}': Could not acquire replacer", zone.name)))?; loaded_replacer.set_soa(soa).unwrap(); loaded_replacer.set_records(records).unwrap(); loaded_replacer.apply().unwrap(); @@ -69,17 +67,16 @@ pub fn restore_loaded( let mut loaded_patcher = restorer.patch().unwrap(); // TODO: SAFETY let mut source = loaded_source.to_path_buf(); for i in 1..count { - source.set_extension((n + i).to_string()); + source.set_extension(i.to_string()); let loaded_patcher = &mut loaded_patcher; load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { apply_ixfr_event_to_loaded_data(loaded_patcher, event); - }) - .unwrap(); + })?; - loaded_patcher.next_patchset().unwrap() + loaded_patcher.next_patchset().map_err(|err| io::Error::other(format!("Unable to restore persisted diff {i} for zone '{}' from '{source}': Could not move to next patch set: {err}", zone.name)))?; } - loaded_patcher.apply().unwrap(); + loaded_patcher.apply().map_err(|err| io::Error::other(format!("Unable to restore persisted diffs for zone '{}': Could apply the collected changes: {err}", zone.name)))?; } } @@ -187,10 +184,14 @@ fn load_file_into_memory(source: &Path, buf: &mut Vec) -> std::io::Result, -) -> Result<(SoaRecord, Vec), String> { - load_file_into_memory(source, buf).map_err(|err| err.to_string())?; +) -> io::Result<(SoaRecord, Vec)> { + load_file_into_memory(source, buf)?; - let (start_soa, start_soa_rdata, mut rest) = parse_soa(buf, 0)?; + let (start_soa, start_soa_rdata, mut rest) = parse_soa(buf, 0).map_err(|err| { + io::Error::other(format!( + "Failed to parse persisted snapshot initial SOA: {err}" + )) + })?; let mut records = vec![]; loop { @@ -199,7 +200,11 @@ fn load_axfr_wire_dump( // index at which parsing should start on the next iteration // of the loop. let r; - (r, rest) = parse_rr(buf, rest)?; + (r, rest) = parse_rr(buf, rest).map_err(|err| { + io::Error::other(format!( + "Failed to parse persisted snapshot resource record at pos {rest}: {err}" + )) + })?; // If the parsed record is a SOA it should be identical to // the SOA record that started the AXFR dump and signals the @@ -212,14 +217,13 @@ fn load_axfr_wire_dump( // Since we persisted the AXFR wire dump to disk it is a // very unexpected error if it does not match the starting // SOA. - if r.rname.as_ref() == &*start_soa.0.rname + if (r.rname.as_ref() == &*start_soa.0.rname || r.rclass == start_soa.0.rclass - || r.ttl == start_soa.ttl + || r.ttl == start_soa.ttl) + && let Ok(soa_rdata) = Soa::::parse_message_bytes(r.rdata.bytes(), 0) + && soa_rdata == start_soa_rdata { - let soa_rdata = Soa::::parse_message_bytes(r.rdata.bytes(), 0).unwrap(); - if soa_rdata == start_soa_rdata { - break; - } + break; } } @@ -230,14 +234,16 @@ fn load_axfr_wire_dump( Ok((start_soa, records)) } -fn load_ixfr_wire_dump(source: &Path, buf: &mut Vec, mut rr_handler: F) -> Result<(), String> +fn load_ixfr_wire_dump(source: &Path, buf: &mut Vec, mut rr_handler: F) -> io::Result<()> where F: FnMut(IxfrEvent), { - load_file_into_memory(source, buf).map_err(|err| err.to_string())?; + load_file_into_memory(source, buf)?; let buf = buf.as_slice(); - let (start_soa, start_soa_rdata, mut rest) = parse_soa(buf, 0)?; + let (start_soa, start_soa_rdata, mut rest) = parse_soa(buf, 0).map_err(|err| { + io::Error::other(format!("Failed to parse persisted diff initial SOA: {err}")) + })?; // Parse one or more diff sequences. loop { @@ -252,15 +258,19 @@ where // Read the first deleted RR which should be a SOA RR. let r; - (r, rest) = parse_rr(buf, rest)?; + (r, rest) = parse_rr(buf, rest).map_err(|err| { + io::Error::other(format!( + "Failed to parse persisted diff resource record at pos {rest}: {err}" + )) + })?; if r.rtype != RType::SOA { // If this is the first RR of the sequence it MUST // be a SOA RR. - return Err(format!( - "Expected first record of IXFR remove sequence to be a SOA RR but found RTYPE {}", + return Err(io::Error::other(format!( + "Expected first record of persisted diff remove sequence to be a SOA RR but found RTYPE {}", r.rtype.code - )); + ))); } let r = RegularRecord(r.transform(|name| name.unsized_copy_into(), |data| data)); @@ -270,7 +280,11 @@ where // removed RRs and the start of the added RRs. loop { let r; - (r, rest) = parse_rr(buf, rest)?; + (r, rest) = parse_rr(buf, rest).map_err(|err| { + io::Error::other(format!( + "Failed to parse persisted diff removed resource record at pos {rest}: {err}" + )) + })?; let r = RegularRecord(r.transform(|name| name.unsized_copy_into(), |data| data)); if r.rtype == RType::SOA { @@ -284,7 +298,11 @@ where // Read more added RRs until a SOA signals the end of added RRs. loop { let r; - (r, rest) = parse_rr(buf, rest)?; + (r, rest) = parse_rr(buf, rest).map_err(|err| { + io::Error::other(format!( + "Failed to parse persisted diff added resource record at pos {rest}: {err}" + )) + })?; let r = RegularRecord(r.transform(|name| name.unsized_copy_into(), |data| data)); if r.rtype == RType::SOA { @@ -304,12 +322,12 @@ where } fn is_same_soa(start_soa: &SoaRecord, start_soa_rdata: &Soa, r: &RegularRecord) -> bool { - if r.rtype == RType::SOA + if (r.rtype == RType::SOA && r.rname.as_ref() == &*start_soa.0.rname && r.rclass == start_soa.0.rclass - && r.ttl == start_soa.ttl + && r.ttl == start_soa.ttl) + && let Ok(soa_rdata) = Soa::::parse_message_bytes(r.rdata.bytes(), 0) { - let soa_rdata = Soa::::parse_message_bytes(r.rdata.bytes(), 0).unwrap(); return &soa_rdata == start_soa_rdata; } false From 39b0b201a00b77cc9c16990a203fdda54c6768ab Mon Sep 17 00:00:00 2001 From: Philip Homburg Date: Wed, 6 May 2026 13:53:45 +0200 Subject: [PATCH 09/81] Save last serial and key tags in zone state. --- src/signer/incremental.rs | 20 ++++---- src/units/zone_signer.rs | 104 ++++++++++++++++++++------------------ 2 files changed, 65 insertions(+), 59 deletions(-) diff --git a/src/signer/incremental.rs b/src/signer/incremental.rs index c587616d0..5d2aa3c57 100644 --- a/src/signer/incremental.rs +++ b/src/signer/incremental.rs @@ -2216,18 +2216,18 @@ impl IncrementalSigningState { } /// Load the state varibles we need at the start and then update state at the end. -struct LocalState { - apex_remove: HashSet, - apex_extra: Vec, - last_signature_refresh: UnixTime, - key_tags: HashSet, - key_roll: Option, - previous_serial: Option, - next_min_expiration: Option, +pub struct LocalState { + pub apex_remove: HashSet, + pub apex_extra: Vec, + pub last_signature_refresh: UnixTime, + pub key_tags: HashSet, + pub key_roll: Option, + pub previous_serial: Option, + pub next_min_expiration: Option, } impl LocalState { - fn new(zone: &Arc) -> Result { + pub fn new(zone: &Arc) -> Result { let zone_state = zone .state .lock() @@ -2244,7 +2244,7 @@ impl LocalState { }) } - fn save(self, center: &Arc
, zone: &Arc) -> Result<(), SignerError> { + pub fn save(self, center: &Arc
, zone: &Arc) -> Result<(), SignerError> { let mut modified = false; let mut zone_state = zone diff --git a/src/units/zone_signer.rs b/src/units/zone_signer.rs index d3e48f5c9..8dd0cc9c6 100644 --- a/src/units/zone_signer.rs +++ b/src/units/zone_signer.rs @@ -1,5 +1,5 @@ use std::cmp::{Ordering, min}; -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::env::{self, VarError}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, RwLock}; @@ -7,6 +7,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bytes::Bytes; use cascade_zonedata::{OldRecord, RegularRecord, SignedZoneBuilder}; +use domain::base::Serial; use domain::base::iana::SecurityAlgorithm; use domain::base::name::FlattenInto; use domain::base::{CanonicalOrd, Name, Record}; @@ -23,7 +24,7 @@ use domain::dnssec::sign::keys::SigningKey; use domain::dnssec::sign::keys::keyset::{KeySet, KeyType, UnixTime}; use domain::dnssec::sign::records::RecordsIter; use domain::dnssec::sign::signatures::rrsigs::{GenerateRrsigConfig, sign_sorted_zone_records}; -use domain::new::base::{RType, Serial}; +use domain::new::base::{RType, Serial as NewBaseSerial}; use domain::new::rdata::RecordData; use domain::rdata::dnssec::Timestamp; use domain::rdata::{Dnskey, Nsec3param, ZoneRecordData}; @@ -50,7 +51,7 @@ use crate::api::{ use crate::center::Center; use crate::manager::{Terminated, record_zone_event}; use crate::policy::{PolicyVersion, SignerDenialPolicy, SignerSerialPolicy}; -use crate::signer::incremental::sign_incrementally; +use crate::signer::incremental::{LocalState, sign_incrementally}; use crate::signer::{ResigningTrigger, SigningTrigger}; use crate::units::http_server::KmipServerState; use crate::units::key_manager::{ @@ -60,7 +61,7 @@ use crate::util::{ AbortOnDrop, serialize_duration_as_secs, serialize_instant_as_duration_secs, serialize_opt_duration_as_secs, }; -use crate::zone::{HistoricalEvent, HistoricalEventType, Zone, ZoneHandle}; +use crate::zone::{HistoricalEvent, Zone, ZoneHandle}; // Re-signing zones before signatures expire works as follows: // - compute when the first zone needs to be re-signed. Loop over unsigned @@ -329,16 +330,15 @@ impl ZoneSigner { info!("[ZS]: Starting signing operation for zone '{zone_name}'"); let start = Instant::now(); - let (last_signed_serial, policy) = { + let mut local_state = LocalState::new(zone)?; + + let policy = { // Use a block to make sure that the mutex is clearly dropped. let zone_state = zone.state.lock().unwrap(); - let last_signed_serial = zone_state - .find_last_event(HistoricalEventType::SigningSucceeded, None) - .and_then(|item| item.serial) - .map(|serial| Serial::from(serial.0)); - (last_signed_serial, zone_state.policy.clone().unwrap()) + zone_state.policy.clone().unwrap() }; + let previous_serial = local_state.previous_serial; // // Lookup the zone to sign. @@ -351,9 +351,10 @@ impl ZoneSigner { .expect("a non-empty loaded instance must exist"); let loaded_serial = loaded.soa().rdata.serial; - let serial = match policy.signer.serial_policy { + let serial: Serial = match policy.signer.serial_policy { SignerSerialPolicy::Keep => { - if let Some(previous_serial) = last_signed_serial + let loaded_serial = Serial::from(Into::::into(loaded_serial)); + if let Some(previous_serial) = previous_serial && loaded_serial <= previous_serial { return Err(SignerError::KeepSerialPolicyViolated); @@ -362,26 +363,22 @@ impl ZoneSigner { loaded_serial } SignerSerialPolicy::Counter => { - // Select the maximum of 'last_signed_serial + 1' and - // 'loaded_serial'. - // - // TODO: This is a partial workaround to help users starting - // out with counter mode. For ongoing discussion, see - // . - let mut serial = loaded_serial; - if let Some(previous_serial) = last_signed_serial - && serial <= previous_serial - { - serial = previous_serial.inc(1); - } - serial + // Always increment the serial number, ignore the serial + // number in the unsigned zone. + let previous_serial = if let Some(serial) = previous_serial { + serial + } else { + Serial::from(0) + }; + + previous_serial.add(1) } SignerSerialPolicy::UnixTime => { - let mut serial = Serial::unix_time(); - if let Some(previous_serial) = last_signed_serial + let mut serial = Serial::now(); + if let Some(previous_serial) = previous_serial && serial <= previous_serial { - serial = previous_serial.inc(1); + serial = previous_serial.add(1); } serial @@ -394,15 +391,17 @@ impl ZoneSigner { * 100; let mut serial: Serial = serial.into(); - if let Some(previous_serial) = last_signed_serial + if let Some(previous_serial) = previous_serial && serial <= previous_serial { - serial = previous_serial.inc(1); + serial = previous_serial.add(1); } serial } }; + local_state.previous_serial = Some(serial); + let serial = NewBaseSerial::from(serial.into_int()); let new_soa = { let mut soa = loaded.soa().clone(); soa.rdata.serial = serial; @@ -410,7 +409,7 @@ impl ZoneSigner { }; info!( - "[ZS]: Serials for zone '{zone_name}': last signed={last_signed_serial:?}, current={loaded_serial}, serial policy={}, new={serial}", + "[ZS]: Serials for zone '{zone_name}': last signed={previous_serial:?}, current={loaded_serial}, serial policy={}, new={serial}", policy.signer.serial_policy ); @@ -504,6 +503,25 @@ impl ZoneSigner { )); } + // Save the current zone signing keys and clear key_roll + let mut key_tags = HashSet::new(); + for v in state.keyset.keys().values() { + let signer = match v.keytype() { + KeyType::Ksk(_) => false, + KeyType::Zsk(key_state) => key_state.signer(), + KeyType::Csk(_, key_state) => key_state.signer(), + KeyType::Include(_) => false, + }; + + if !signer { + continue; + } + + key_tags.insert(v.key_tag()); + } + local_state.key_tags = key_tags; + local_state.key_roll = None; + // // Sort them into DNSSEC order ready for NSEC(3) generation. // @@ -714,22 +732,7 @@ impl ZoneSigner { min_expiration.add(u32::from(sig.expiration).into()); } - - // Save the minimum of the expiration times. - { - // Use a block to make sure that the mutex is clearly dropped. - let mut zone_state = zone.state.lock().unwrap(); - - // Save as next_min_expiration. After the signed zone is approved - // this value should be move to min_expiration. - zone_state.next_min_expiration = saved_min_expiration.get(); - debug!( - "SIGNER: Determined min expiration time: {:?}", - zone_state.next_min_expiration - ); - - zone.mark_dirty(&mut zone_state, center); - } + local_state.next_min_expiration = saved_min_expiration.get(); let total_time = start.elapsed(); @@ -768,6 +771,9 @@ impl ZoneSigner { Some(domain::base::Serial(serial.into())), ); + local_state.last_signature_refresh = UnixTime::now(); + local_state.save(center, zone)?; + Ok(()) } @@ -1041,7 +1047,7 @@ pub struct InProgressStatus { } impl InProgressStatus { - fn new(requested_status: RequestedStatus, zone_serial: Serial) -> Self { + fn new(requested_status: RequestedStatus, zone_serial: NewBaseSerial) -> Self { Self { requested_at: requested_status.requested_at, zone_serial: domain::base::Serial(zone_serial.into()), @@ -1125,7 +1131,7 @@ impl ZoneSigningStatus { Self::Requested(RequestedStatus::new()) } - fn start(&mut self, zone_serial: Serial) -> Result<(), ()> { + fn start(&mut self, zone_serial: NewBaseSerial) -> Result<(), ()> { match *self { ZoneSigningStatus::Requested(s) => { *self = Self::InProgress(InProgressStatus::new(s, zone_serial)); From b18906ead65155ce3f2b2ce50320592011a7d943 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 6 May 2026 14:01:40 +0200 Subject: [PATCH 10/81] Improvements to the persist-restore system test. --- integration-tests/system-tests.yml | 15 ++ .../tests/persist-zone/action.yml | 223 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 integration-tests/tests/persist-zone/action.yml diff --git a/integration-tests/system-tests.yml b/integration-tests/system-tests.yml index 7e3fb8755..a2c43b201 100644 --- a/integration-tests/system-tests.yml +++ b/integration-tests/system-tests.yml @@ -301,3 +301,18 @@ jobs: accept-xfr-from: ${{ matrix.accept-xfr-from }} downstream-expects-tsig: ${{ matrix.downstream-expects-tsig }} downstream-uses-tsig: ${{ matrix.downstream-uses-tsig }} + + persist-zone: + name: Added zone should still exist after restart. + runs-on: ubuntu-latest + strategy: + matrix: + rust: [stable] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/set-build-profile + with: + build-profile: ${{ inputs.build-profile }} + - uses: ./integration-tests/tests/persist-zone + with: + log-level: ${{ inputs.log-level }} diff --git a/integration-tests/tests/persist-zone/action.yml b/integration-tests/tests/persist-zone/action.yml new file mode 100644 index 000000000..b172d7062 --- /dev/null +++ b/integration-tests/tests/persist-zone/action.yml @@ -0,0 +1,223 @@ +# Making reusable composite actions documented at +# https://docs.github.com/en/actions/tutorials/create-actions/create-a-composite-action#creating-a-composite-action-within-the-same-repository +name: 'Added zone should still exist after restart.' +description: 'Added zone should still exist after restart.' +defaults: + # see: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defaultsrunshell + run: + shell: bash --noprofile --norc -eo pipefail -x {0} +inputs: + log-level: + description: The level of logging that Cascade should output. + required: false + default: debug + type: choice + options: + - error + - warning + - info + - debug + - trace +runs: + using: "composite" + steps: + - uses: ./.github/actions/prepare-systest-env + + - uses: ./.github/actions/setup-and-start-cascade + with: + log-level: ${{ inputs.log-level }} + + - name: Add a zone served by the NSD primary + run: | + cascade zone add --policy default --source 127.0.0.1:1055 example.test + + # By default Casade policy uses the 'date-counter' serial policy which + # makes it possible to predict the serial number it will generate. + - name: Predict the SOA SERIAL that Cascade will generate for the signed zone + id: expected-serial + run: | + EXPECTED_SERIAL=$(date +'%Y%m%d00') + echo "serial=${EXPECTED_SERIAL}" >> "$GITHUB_OUTPUT" + + - name: Wait for Cascade to serve the signed zone + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + timeout=10 # seconds + start=$(date +%s) + until dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR | grep -q "${EXPECTED_SERIAL}" ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR + exit 1 + fi + sleep 1 + done + + - name: Save the signed zone + run: | + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.signed.log + + - name: Stop Cascade + run: | + pkill cascaded + + - name: Wait for Cascade to exit + run: | + timeout=10 # seconds + start=$(date +%s) + until ! cascade health; do + if (($(date +%s) > (start + timeout))); then + cascade health + echo "::error:: timeout: health check did not indicate Cascade had stopped" + exit 1 + fi + sleep 1 + done + + # Cascade should reload the signed zone data from the data it persisted + # to disk. + - name: Start Cascade again + run: | + CASCADE_DIR=${{ github.workspace }}/cascade-dir + cascaded --config "${CASCADE_DIR}/config.toml" --state "${CASCADE_DIR}/state.db" --daemonize &>"${CASCADE_DIR}/cascade-startup.log" + + - name: Wait for Cascade to become healthy again + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade health; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: health check did not indicate Cascade had started" + exit 1 + fi + sleep 1 + done + + - name: Check that Cascade still knows the zone + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone list | grep -q example.test; do + if (($(date +%s) > (start + timeout))); then + cascade zone list + echo "::error:: timeout: Cascade no longer knows the zone" + exit 1 + fi + sleep 1 + done + + - name: Wait for Cascade to serve the signed zone + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + timeout=10 # seconds + start=$(date +%s) + until dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR | grep -q "${EXPECTED_SERIAL}" ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR + exit 1 + fi + sleep 1 + done + + - name: Save the signed zone again + run: | + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.signed2.log + + - name: Check that Cascade did NOT resign the zone + run: | + diff -u example.test.signed.log example.test.signed2.log + + - name: Stop Cascade again + run: | + pkill cascaded + + - name: Wait for Cascade to exit again + run: | + timeout=10 # seconds + start=$(date +%s) + until ! cascade health; do + if (($(date +%s) > (start + timeout))); then + cascade health + echo "::error:: timeout: health check did not indicate Cascade had stopped" + exit 1 + fi + sleep 1 + done + + - name: Delete the persisted zone data that Cascade wrote to disk + run: | + CASCADE_DIR=${{ github.workspace }}/cascade-dir + rm "${CASCADE_DIR}"/zone-state/example.test.loaded.* + rm "${CASCADE_DIR}"/zone-state/example.test.signed.* + + - name: Start Cascade a 3rd time + run: | + CASCADE_DIR=${{ github.workspace }}/cascade-dir + cascaded --config "${CASCADE_DIR}/config.toml" --state "${CASCADE_DIR}/state.db" --daemonize &>"${CASCADE_DIR}/cascade-startup.log" + + - name: Wait for Cascade to become healthy a 3rd time + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade health; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: health check did not indicate Cascade had started" + exit 1 + fi + sleep 1 + done + + - name: Check that Cascade still knows the zone + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone list | grep -q example.test; do + if (($(date +%s) > (start + timeout))); then + cascade zone list + echo "::error:: timeout: Cascade no longer knows the zone" + exit 1 + fi + sleep 1 + done + + - name: Wait for Cascade to serve the re-signed zone with a newer serial number + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + timeout=10 # seconds + start=$(date +%s) + let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 1 )) + until dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR | grep -q "${EXPECTED_SERIAL}" ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR + exit 1 + fi + sleep 1 + done + + - name: Save the signed zone + run: | + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.signed3.log + + - name: Check that Cascade DID resign the zone + run: | + if diff -u example.test.signed.log example.test.signed3.log > unexpected-diff.log; then + echo "::error:: Cascade should have re-signed the zone" + exit 1 + fi + + - name: Edit the source zone and tell NSD to reload it. + run: | + ZONE_DIR=$(integration-tests/scripts/get-default-path.sh test-env:nameserver-base-dir) + ZONE_FILE="${ZONE_DIR}/nsd-primary/zones/example.test.primary-zone" + perl -pi -e 's/1 ; serial/2 ; serial/' ${ZONE_FILE} + perl -pi -e 's/www A 169.254.1.1/www A 192.168.0.1/' ${ZONE_FILE} + ./integration-tests/scripts/manage-test-environment.sh control nsd-primary reload example.test + + - name: Print log files on any failure in this job + uses: ./.github/actions/print-logfiles + if: failure() From 6b8548b8390bb72b01a2a333d49edf96b2c115ec Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 6 May 2026 14:02:24 +0200 Subject: [PATCH 11/81] Don't persist if nothing changed. --- src/persistence/persist.rs | 104 ++++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 42 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index dcb4496fc..4fd9a9a8f 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -12,6 +12,7 @@ use cascade_zonedata::{ }; use domain::new::base::wire::{BuildBytes, TruncationError}; +use tracing::debug; use crate::{ center::Center, @@ -29,27 +30,29 @@ pub fn persist_loaded( center: &Arc
, persister: LoadedZonePersister, ) -> LoadedZonePersisted { - // Determine the path to write to and update the record of written - // paths here as we don't want to give responsibility for working - // with ZoneState to the persistence crate. Accumulate a set of - // diffs per unsigned and signed zone, each stored at a path one - // suffixed by an index which rises by one when persisted. - // TODO: Don't keep an unlimited number of diffs. - // TODO: Compact diffs when idle? - let mut state = zone.state.lock().unwrap(); - let handle = ZoneHandle { - zone, - state: &mut state, - center, - }; - let next_idx = handle.state.persisted_loaded_diffs.len(); - let destination = center - .config - .zone_state_dir - .join(format!("{}.loaded.{next_idx}", zone.name)); - persist_to_file(destination.as_std_path(), persister.loaded_diff().clone()); - handle.state.persisted_loaded_diffs.push(destination.into()); - handle.zone.mark_dirty(handle.state, handle.center); + if !persister.loaded_diff().is_empty() { + // Determine the path to write to and update the record of written + // paths here as we don't want to give responsibility for working + // with ZoneState to the persistence crate. Accumulate a set of + // diffs per unsigned and signed zone, each stored at a path one + // suffixed by an index which rises by one when persisted. + // TODO: Don't keep an unlimited number of diffs. + // TODO: Compact diffs when idle? + let mut state = zone.state.lock().unwrap(); + let handle = ZoneHandle { + zone, + state: &mut state, + center, + }; + let next_idx = handle.state.persisted_loaded_diffs.len(); + let destination = center + .config + .zone_state_dir + .join(format!("{}.loaded.{next_idx}", zone.name)); + persist_to_file(destination.as_std_path(), persister.loaded_diff().clone()); + handle.state.persisted_loaded_diffs.push(destination.into()); + handle.zone.mark_dirty(handle.state, handle.center); + } persister.mark_complete() } @@ -64,27 +67,29 @@ pub fn persist_signed( center: &Arc
, persister: SignedZonePersister, ) -> SignedZonePersisted { - // Determine the path to write to and update the record of written - // paths here as we don't want to give responsibility for working - // with ZoneState to the persistence crate. Accumulate a set of - // diffs per unsigned and signed zone, each stored at a path one - // suffixed by an index which rises by one when persisted. - // TODO: Don't keep an unlimited number of diffs. - // TODO: Compact diffs when idle? - let mut state = zone.state.lock().unwrap(); - let handle = ZoneHandle { - zone, - state: &mut state, - center, - }; - let next_idx = handle.state.persisted_signed_diffs.len(); - let destination = center - .config - .zone_state_dir - .join(format!("{}.signed.{next_idx}", zone.name)); - persist_to_file(destination.as_std_path(), persister.signed_diff().clone()); - handle.state.persisted_loaded_diffs.push(destination.into()); - handle.zone.mark_dirty(handle.state, handle.center); + if !persister.signed_diff().is_empty() { + // Determine the path to write to and update the record of written + // paths here as we don't want to give responsibility for working + // with ZoneState to the persistence crate. Accumulate a set of + // diffs per unsigned and signed zone, each stored at a path one + // suffixed by an index which rises by one when persisted. + // TODO: Don't keep an unlimited number of diffs. + // TODO: Compact diffs when idle? + let mut state = zone.state.lock().unwrap(); + let handle = ZoneHandle { + zone, + state: &mut state, + center, + }; + let next_idx = handle.state.persisted_signed_diffs.len(); + let destination = center + .config + .zone_state_dir + .join(format!("{}.signed.{next_idx}", zone.name)); + persist_to_file(destination.as_std_path(), persister.signed_diff().clone()); + handle.state.persisted_signed_diffs.push(destination.into()); + handle.zone.mark_dirty(handle.state, handle.center); + } persister.mark_complete() } @@ -177,4 +182,19 @@ fn persist_to_file(destination: &Path, loaded_diff: Arc) { // Finish the AXFR/IXFR by writing the new SOA again write_rr(&mut buf, &added_soa, &mut f); + + debug!( + "Persisted zone to file '{}': {} records removed, {} records added", + destination.display(), + if !loaded_diff.removed_records.is_empty() { + loaded_diff.removed_records.len() + 1 + } else { + 0 + }, + if !loaded_diff.added_records.is_empty() { + loaded_diff.added_records.len() + 1 + } else { + 0 + }, + ); } From 5c3c6cf950de8e833f31c23271b8f74a81f09902 Mon Sep 17 00:00:00 2001 From: Philip Homburg Date: Wed, 6 May 2026 14:43:55 +0200 Subject: [PATCH 12/81] Switch to apex_remove and apex_extra. --- src/main.rs | 2 +- src/signer/incremental.rs | 9 ++------- src/units/zone_signer.rs | 26 ++++++++++---------------- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/src/main.rs b/src/main.rs index daa71076f..ffdb23924 100644 --- a/src/main.rs +++ b/src/main.rs @@ -426,7 +426,7 @@ fn check_dnst_version(config: &Config) -> bool { }; // Change this string and the match pattern to whatever version we require in the future - let required_version = ">0.1.0"; + let required_version = ">0.2.0"; let res = match (major, minor, patch) { // major = 0; minor >= 1; patch = * (0, 1.., ..) => true, diff --git a/src/signer/incremental.rs b/src/signer/incremental.rs index 5d2aa3c57..2844df041 100644 --- a/src/signer/incremental.rs +++ b/src/signer/incremental.rs @@ -523,10 +523,7 @@ impl WorkSpace<'_> { pub fn handle_keyset_changed(&mut self) -> bool { let mut apex_changed = false; - // Check the apex RRtypes that need to be removed. We - // should get that from keyset, but currently we don't. - // Just have a fixed list. - let apex_remove: HashSet = [Rtype::DNSKEY, Rtype::CDS, Rtype::CDNSKEY].into(); + let apex_remove = self.keyset_state.apex_remove.clone(); let curr_apex_remove = &self.local_state.apex_remove; if apex_remove != *curr_apex_remove { @@ -536,9 +533,7 @@ impl WorkSpace<'_> { } // Check records that need to be added to the apex. - let mut apex_extra = vec![]; - apex_extra.extend_from_slice(&self.keyset_state.dnskey_rrset); - apex_extra.extend_from_slice(&self.keyset_state.cds_rrset); + let mut apex_extra = self.keyset_state.apex_extra.clone(); apex_extra.sort(); let curr_apex_extra = &self.local_state.apex_extra; diff --git a/src/units/zone_signer.rs b/src/units/zone_signer.rs index 8dd0cc9c6..773152f0d 100644 --- a/src/units/zone_signer.rs +++ b/src/units/zone_signer.rs @@ -7,6 +7,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bytes::Bytes; use cascade_zonedata::{OldRecord, RegularRecord, SignedZoneBuilder}; +use domain::base::Rtype; use domain::base::Serial; use domain::base::iana::SecurityAlgorithm; use domain::base::name::FlattenInto; @@ -464,21 +465,15 @@ impl ZoneSigner { let state = std::fs::read_to_string(&state_path) .map_err(|_| SignerError::CannotReadStateFile(state_path.into_string()))?; let state: KeySetState = serde_json::from_str(&state).unwrap(); - for dnskey_rr in &state.dnskey_rrset { - let mut zonefile = Zonefile::new(); - zonefile.extend_from_slice(dnskey_rr.as_bytes()); - zonefile.extend_from_slice(b"\n"); - if let Ok(Some(Entry::Record(rec))) = zonefile.next_entry() { - let record: OldRecord = rec.flatten_into(); - new_records.push(record.clone().into()); - records.push(record); - } - } - // Also add CDS and CDNSKEY records plus their signatures. - for cds_rr in &state.cds_rrset { + local_state.apex_remove = state.apex_remove.clone(); + let mut apex_extra = state.apex_extra.clone(); + apex_extra.sort(); + local_state.apex_extra = apex_extra; + + for rr in &state.apex_extra { let mut zonefile = Zonefile::new(); - zonefile.extend_from_slice(cds_rr.as_bytes()); + zonefile.extend_from_slice(rr.as_bytes()); zonefile.extend_from_slice(b"\n"); if let Ok(Some(Entry::Record(rec))) = zonefile.next_entry() { let record: OldRecord = rec.flatten_into(); @@ -955,10 +950,9 @@ pub struct KeySetState { /// Domain KeySet state. pub keyset: KeySet, - pub dnskey_rrset: Vec, pub ds_rrset: Vec, - pub cds_rrset: Vec, - pub ns_rrset: Vec, + pub apex_remove: HashSet, + pub apex_extra: Vec, } pub struct MinTimestamp(Mutex>); From ce23145416188065f60c3b379c3a57004525feb0 Mon Sep 17 00:00:00 2001 From: Philip Homburg Date: Wed, 6 May 2026 15:06:35 +0200 Subject: [PATCH 13/81] Fmt. --- src/units/zone_signer.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/units/zone_signer.rs b/src/units/zone_signer.rs index 773152f0d..ada3dc1d9 100644 --- a/src/units/zone_signer.rs +++ b/src/units/zone_signer.rs @@ -466,10 +466,10 @@ impl ZoneSigner { .map_err(|_| SignerError::CannotReadStateFile(state_path.into_string()))?; let state: KeySetState = serde_json::from_str(&state).unwrap(); - local_state.apex_remove = state.apex_remove.clone(); - let mut apex_extra = state.apex_extra.clone(); - apex_extra.sort(); - local_state.apex_extra = apex_extra; + local_state.apex_remove = state.apex_remove.clone(); + let mut apex_extra = state.apex_extra.clone(); + apex_extra.sort(); + local_state.apex_extra = apex_extra; for rr in &state.apex_extra { let mut zonefile = Zonefile::new(); From e655ea196b84c868200122b12a918519aa583e68 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 6 May 2026 15:19:05 +0200 Subject: [PATCH 14/81] More work on the persist-restore system test. --- .../tests/persist-zone/action.yml | 69 +++++++++++-------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/integration-tests/tests/persist-zone/action.yml b/integration-tests/tests/persist-zone/action.yml index 9c52e59fd..e92649cd1 100644 --- a/integration-tests/tests/persist-zone/action.yml +++ b/integration-tests/tests/persist-zone/action.yml @@ -114,14 +114,6 @@ runs: timeout=10 # seconds start=$(date +%s) - # Due to a known issue in Cascade/dnst keyset, currently a second - # version of the zone gets signed and published with an incremented - # serial number but no other change in the zone. So we have to expect - # that. - # TODO: Remove this comment and the next line once the issue has been - # resolved. - let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 1 )) - until dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR | grep -q "${EXPECTED_SERIAL}" ; do if (($(date +%s) > (start + timeout))); then echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" @@ -134,18 +126,7 @@ runs: - name: Check that Cascade did NOT resign the zone run: | dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.signed2.log - - # Due to a known issue in Cascade/dnst keyset, currently a second - # version of the zone gets signed and published with an incremented - # serial number but no other change in the zone. So we have to expect - # that. - # TODO: Remove this comment and the next two lines once the issue has - # been resolved. - grep -Fv 'SOA' example.test.signed.log > diff-tmp1.log - grep -Fv 'SOA' example.test.signed2.log > diff-tmp2.log - - # diff -u example.test.signed.log example.test.signed2.log - diff -u diff-tmp1.log diff-tmp2.log + diff -u example.test.signed.log example.test.signed2.log - name: Stop Cascade again run: | @@ -208,14 +189,6 @@ runs: start=$(date +%s) let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 1 )) - # Due to a known issue in Cascade/dnst keyset, currently a second - # version of the zone gets signed and published with an incremented - # serial number but no other change in the zone. So we have to expect - # that. - # TODO: Remove this comment and the next line once the issue has been - # resolved. - let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 1 )) - until dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR | grep -q "${EXPECTED_SERIAL}" ; do if (($(date +%s) > (start + timeout))); then echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" @@ -240,10 +213,48 @@ runs: run: | ZONE_DIR=$(integration-tests/scripts/get-default-path.sh test-env:nameserver-base-dir) ZONE_FILE="${ZONE_DIR}/nsd-primary/zones/example.test.primary-zone" - perl -pi -e 's/1 ; serial/2 ; serial/' ${ZONE_FILE} + perl -pi -e 's/1 ; serial/12345 ; serial/' ${ZONE_FILE} perl -pi -e 's/www A 169.254.1.1/www A 192.168.0.1/' ${ZONE_FILE} ./integration-tests/scripts/manage-test-environment.sh control nsd-primary reload example.test + - name: Wait for Cascade to load the changed zone + run: | + timeout=10 # seconds + start=$(date +%s) + + until dig +noall +answer @127.0.0.1 -p 4540 example.test SOA | grep -q 12345 ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA SERIAL 12345" + dig +noall +answer @127.0.0.1 -p 4540 example.test SOA + exit 1 + fi + sleep 1 + done + + - name: Wait for Cascade to serve the re-signed zone with a newer serial number + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + timeout=10 # seconds + start=$(date +%s) + let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 2 )) + + until dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR | grep -Fq "${EXPECTED_SERIAL}" ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR + exit 1 + fi + sleep 1 + done + + - name: Verify that the modified A record is served in the published zone + run: | + # Cascade can only answer SOA and XFR queries at the moment so we + # cannot query it for the A record we are interested in. + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.final.log + cat example.test.final.log | grep -Fq www.example.test | grep -Fq "192.168.0.1" + - name: Print log files on any failure in this job uses: ./.github/actions/print-logfiles if: failure() From 538cc286d3ec9ca420d04d9d4b1eba4601c24f86 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 7 May 2026 21:58:57 +0200 Subject: [PATCH 15/81] Review feedback: Remove outdated TODO comment. --- src/server/service.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/service.rs b/src/server/service.rs index 8582ace00..f65b4b57a 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -152,7 +152,6 @@ mod compat { Box::pin(axfr(old_request, zone.clone())) as Response } - // TODO: Support IXFR. ZoneRequestKind::Ixfr { known_soa } => { Box::pin(ixfr(old_request, known_soa.rdata, zone.clone())) as Response } From ddfdc2ec65dafa4820c8c585dcdb740d78350fe4 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 7 May 2026 21:59:40 +0200 Subject: [PATCH 16/81] Review feedback: Better log message. --- src/server/service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/service.rs b/src/server/service.rs index f65b4b57a..df36db263 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -303,7 +303,7 @@ mod compat { ) -> ResponseStream { // Refuse IXFR requests over UDP. if request.transport_ctx().is_udp() { - tracing::warn!("Reject IXFR over UDP"); + tracing::warn!("Rejecting IXFR over UDP"); return error(request.message(), Rcode::NOTIMP); } From 941bf19389a8007574cac1c6ee79ffb1cc3f0729 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 7 May 2026 22:00:58 +0200 Subject: [PATCH 17/81] Review feedback: Remove unnecessary braces. --- src/server/service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/service.rs b/src/server/service.rs index df36db263..e9e65b2a8 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -336,7 +336,7 @@ mod compat { // ^^^^^^^^^^^^^^^ // Errata https://www.rfc-editor.org/errata/eid3196 points out that // this is NOT "just as in AXFR" as AXFR does not do that. - let our_soa_serial = { viewer.soa().rdata.serial }; + let our_soa_serial = viewer.soa().rdata.serial; if client_soa.serial >= our_soa_serial { trace!("Responding to IXFR with single SOA because query serial >= zone serial"); From 92da4ff7ceb644542606228ddd4be381e287f842 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 7 May 2026 22:04:21 +0200 Subject: [PATCH 18/81] Review feedback: Log at wrong level. Also add a guard and improve the actual logged output. --- src/server/service.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/server/service.rs b/src/server/service.rs index e9e65b2a8..2beefc97c 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -86,7 +86,7 @@ mod compat { tsig, }; use futures::Stream; - use tracing::trace; + use tracing::{Level, trace}; use crate::server::request::{RequestKind, ZoneRequestKind}; @@ -359,13 +359,19 @@ mod compat { // messages. // Currently we have only a single diff available. - for (i, d) in diffs.iter().enumerate() { - tracing::info!( - "Diff {i} for zone '{}': serial {} => serial {}", - zone.handle.name, - d.removed_soa.as_ref().unwrap().0.rdata.serial, - d.added_soa.as_ref().unwrap().0.rdata.serial, + if tracing::enabled!(Level::TRACE) { + trace!( + "IXFR out: {} diffs availablei for zone {}:", + diffs.len(), + zone.handle.name ); + for (i, d) in diffs.iter().enumerate() { + trace!( + "IXFR out: Diff #{i}: serial {} => serial {}", + d.removed_soa.as_ref().unwrap().0.rdata.serial, + d.added_soa.as_ref().unwrap().0.rdata.serial, + ); + } } // Find the diff, if we have it, that removes the SOA serial number From 54de82507b441b725df0e0b4040afb08f6c63f2c Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 7 May 2026 22:05:08 +0200 Subject: [PATCH 19/81] Review feedback: Errant period in comment. --- src/server/service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/service.rs b/src/server/service.rs index 2beefc97c..769006ac8 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -394,7 +394,7 @@ mod compat { // Stream the records in the background. tokio::task::spawn(async move { - // Collect the sequence of IXFR output records.. + // Collect the sequence of IXFR output records. let mut rrs = vec![new_soa.clone().into()]; for diff in &diffs[start_idx..] { rrs.push(diff.removed_soa.clone().unwrap().into()); From a7120d2027fe722574a7efc21295d35d4b3125d1 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 7 May 2026 22:40:28 +0200 Subject: [PATCH 20/81] Review feedback: Added some fn RustDocs. Primarily to cover the wider than perhaps expected use of the soa() fn. --- src/server/service.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/server/service.rs b/src/server/service.rs index 769006ac8..cb72310e8 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -196,9 +196,14 @@ mod compat { true } + /// Generate a SOA DNS message response stream for the given zone viewer. + /// + /// Note: Also used by [`axfr()`] and [`ixfr()`] as well as in response to + /// a direct SOA query. + /// + /// Returns an NXDOMAIN response if we have the zone but no data for it. fn soa(request: &Message>, viewer: &V) -> ResponseStream { if viewer.is_empty() { - // The zone is known to exist, but we don't have any data for it. return error(request, Rcode::NXDOMAIN); } let soa = viewer.soa().clone(); @@ -213,6 +218,7 @@ mod compat { Box::new(futures::stream::once(std::future::ready(result))) as _ } + /// Generate an AXFR DNS message response stream for the given zone. async fn axfr( request: Request, Option>>, zone: ServedZone, @@ -296,6 +302,7 @@ mod compat { Box::new(stream) as _ } + /// Generate an IXFR DNS message response stream for the given zone. async fn ixfr( request: Request, Option>>, client_soa: Soa>, From 87ff6a736c9653d415f8d278c7ee4c0ee6fe01ce Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 May 2026 00:28:47 +0200 Subject: [PATCH 21/81] Extend IXFR-out system test to use TSIG too. --- .../scripts/manage-test-environment.sh | 6 +- integration-tests/system-tests.yml | 3 +- integration-tests/tests/ixfr-out/action.yml | 140 ++++++++++++------ 3 files changed, 97 insertions(+), 52 deletions(-) diff --git a/integration-tests/scripts/manage-test-environment.sh b/integration-tests/scripts/manage-test-environment.sh index 01054b35f..32180fc8c 100755 --- a/integration-tests/scripts/manage-test-environment.sh +++ b/integration-tests/scripts/manage-test-environment.sh @@ -309,21 +309,21 @@ zone: name: notify-tsig.test zonefile: "notify-tsig.test.secondary-zone" allow-notify: 127.0.0.1 tsig-key - request-xfr: AXFR 127.0.0.1@${_cascade_port} NOKEY + request-xfr: 127.0.0.1@${_cascade_port} NOKEY provide-xfr: 127.0.0.1 NOKEY zone: name: xfr-tsig.test zonefile: "xfr-tsig.test.secondary-zone" allow-notify: 127.0.0.1 NOKEY - request-xfr: AXFR 127.0.0.1@${_cascade_port} tsig-key + request-xfr: 127.0.0.1@${_cascade_port} tsig-key provide-xfr: 127.0.0.1 NOKEY zone: name: notify-and-xfr-tsig.test zonefile: "notify-and-xfr-tsig.test.secondary-zone" allow-notify: 127.0.0.1 tsig-key - request-xfr: AXFR 127.0.0.1@${_cascade_port} tsig-key + request-xfr: 127.0.0.1@${_cascade_port} tsig-key provide-xfr: 127.0.0.1 NOKEY EOF diff --git a/integration-tests/system-tests.yml b/integration-tests/system-tests.yml index 184399852..8bf0bc696 100644 --- a/integration-tests/system-tests.yml +++ b/integration-tests/system-tests.yml @@ -171,7 +171,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - rust: [stable] + with-tsig: [true, false] steps: - uses: actions/checkout@v4 - uses: ./.github/actions/set-build-profile @@ -180,6 +180,7 @@ jobs: - uses: ./integration-tests/tests/ixfr-out with: log-level: ${{ inputs.log-level }} + with-tsig: ${{ inputs.with-tsig }} incremental-signing: name: Test incremental signing. diff --git a/integration-tests/tests/ixfr-out/action.yml b/integration-tests/tests/ixfr-out/action.yml index d6394463f..757a60ce4 100644 --- a/integration-tests/tests/ixfr-out/action.yml +++ b/integration-tests/tests/ixfr-out/action.yml @@ -18,6 +18,10 @@ inputs: - info - debug - trace + with-tsig: + description: Whether or not TSIG should be required for XFR out. + required: true + type: boolean runs: using: "composite" steps: @@ -29,38 +33,64 @@ runs: - name: Add a NOTIFY with TSIG outbound policy run: | POLICY_DIR=$(integration-tests/scripts/get-default-path.sh policy-dir) - cascade template policy | grep -Ev '(send-notify-to|accept-xfr-from)' > "${POLICY_DIR}/custom.toml" + cascade template policy | grep -Ev '(send-notify-to|accept-xfr-from)' > "${POLICY_DIR}/without-tsig.toml" + cascade template policy | grep -Ev '(send-notify-to|accept-xfr-from)' > "${POLICY_DIR}/with-tsig.toml" - sed -i -e 's/serial-policy = "date-counter"/serial-policy = "keep"/' "${POLICY_DIR}/custom.toml" - echo 'send-notify-to = ["127.0.0.1:1054"]' >> "${POLICY_DIR}/custom.toml" + sed -i -e 's/serial-policy = "date-counter"/serial-policy = "keep"/' "${POLICY_DIR}/without-tsig.toml" + sed -i -e 's/serial-policy = "date-counter"/serial-policy = "keep"/' "${POLICY_DIR}/with-tsig.toml" + + echo 'send-notify-to = ["127.0.0.1:1054"]' >> "${POLICY_DIR}/without-tsig.toml" + echo 'send-notify-to = ["127.0.0.1:1054^tsig-key"]' >> "${POLICY_DIR}/with-tsig.toml" - # TODO: Extend the test to use TSIG (driven by inputs ala the tsig-downstream test) - # once PRs #564 and #587 have been merged. - # echo 'send-notify-to = ["127.0.0.1:1054^tsig-key"]' >> "${POLICY_DIR}/custom.toml" - # cascade tsig add tsig-key hmac-sha256 "COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" + cascade tsig add tsig-key hmac-sha256 "COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" cascade policy reload - + + - name: Determine the downstrem secondary zone config to use + id: set-vars + run: | + if [[ "${{ inputs.with-tsig }}" == "true" ]]; then + ZONE_NAME="notify-and-xfr-tsig.test." + POLICY_NAME="with-tsig" + else + ZONE_NAME="example.test." + POLICY_NAME="without-tsig" + fi + echo "ZONE_NAME=${ZONE_NAME}" >> "$GITHUB_OUTPUT" + echo "POLICY_NAME=${POLICY_NAME}" >> "$GITHUB_OUTPUT" + + # The zone name has to match a zone configured in the downstream NSD + # nameserver. Zone 'notify-and-xfr-tsig.test' is a zone in the downstream + # NSD nameserver that is configured to expect TSIG to be used for the + # NOTIFY message it is sent, to use IXFR (as opposed to only AXFR) and for + # it to need to use TSIG to request an IXFR transfer from Cascade. - name: Make an initial zone based on RFC 1995 section 7 + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} run: | - tee example.test.zone <<'EOF' - EXAMPLE.TEST. IN SOA NS.EXAMPLE.TEST. mail.example.test. ( + cat >"${ZONE_NAME}zone" < (start + timeout))); then - cascade zone status example.test + cascade zone status ${ZONE_NAME} echo "timeout: zone status did not report published zone available" >&2 exit 1 fi @@ -68,13 +98,15 @@ runs: done - name: Check the SOA SERIAL at the NSD secondary + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} run: | timeout=10 # seconds start=$(date +%s) - until dig +short @127.0.0.1 -p 1054 example.test SOA | grep -q 'ns.example.test. mail.example.test. 1 60 60 3600 5'; do + until dig +short @127.0.0.1 -p 1054 ${ZONE_NAME} SOA | grep -q "ns.${ZONE_NAME} mail.${ZONE_NAME} 1 60 60 3600 5"; do if (($(date +%s) > (start + timeout))); then - cascade zone status example.test - dig +short @127.0.0.1 -p 1054 example.test SOA + cascade zone status ${ZONE_NAME} + dig +short @127.0.0.1 -p 1054 ${ZONE_NAME} SOA echo "::error:: timeout: NSD did not acquire the zone changes" exit 1 fi @@ -82,24 +114,28 @@ runs: done - name: Edit the source zone and tell Cascade to reload it. + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} run: | - tee example.test.zone <<'EOF' - example.test. IN SOA ns.example.test. mail.example.test. ( + cat >"${ZONE_NAME}zone" < (start + timeout))); then - cascade zone status example.test + cascade zone status ${ZONE_NAME} echo "timeout: zone status did not report published zone available" >&2 exit 1 fi @@ -107,52 +143,60 @@ runs: done - name: Check the SOA SERIAL at the NSD secondary + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} run: | timeout=10 # seconds start=$(date +%s) - until dig +short @127.0.0.1 -p 1054 example.test SOA | grep -q 'ns.example.test. mail.example.test. 2 60 60 3600 5'; do + until dig +short @127.0.0.1 -p 1054 ${ZONE_NAME} SOA | grep -q "ns.${ZONE_NAME} mail.${ZONE_NAME} 2 60 60 3600 5"; do if (($(date +%s) > (start + timeout))); then - cascade zone status example.test - dig +short @127.0.0.1 -p 1054 example.test SOA + dig @127.0.0.1 -p 1054 ${ZONE_NAME} SOA + cascade zone status ${ZONE_NAME} echo "::error:: timeout: NSD did not acquire the zone changes" exit 1 fi sleep 1 done - - name: Edit the source zone and tell Cascade to reload it. + - name: Edit the source zone again and tell Cascade to reload it. + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} run: | - tee example.test.zone <<'EOF' - EXAMPLE.TEST. IN SOA ns.example.test. mail.example.test. ( + cat >"${ZONE_NAME}zone" < (start + timeout))); then - cascade zone status example.test + cascade zone status ${ZONE_NAME} echo "timeout: zone status did not report published zone available" >&2 exit 1 fi sleep 1 done - - name: Check the SOA SERIAL at the NSD secondary + - name: Check the SOA SERIAL again at the NSD secondary + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} run: | timeout=10 # seconds start=$(date +%s) - until dig +short @127.0.0.1 -p 1054 example.test SOA | grep -q 'ns.example.test. mail.example.test. 3 60 60 3600 5'; do + until dig +short @127.0.0.1 -p 1054 ${ZONE_NAME} SOA | grep -q "ns.${ZONE_NAME} mail.${ZONE_NAME} 3 60 60 3600 5"; do if (($(date +%s) > (start + timeout))); then - cascade zone status example.test - dig +short @127.0.0.1 -p 1054 example.test SOA + cascade zone status ${ZONE_NAME} + dig +short @127.0.0.1 -p 1054 ${ZONE_NAME} SOA echo "::error:: timeout: NSD did not acquire the zone changes" exit 1 fi @@ -168,15 +212,15 @@ runs: # TODO: Verify the AXFR and IXFR response content is as expected / # matching that of NSD. - - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test AXFR > cascade-axfr.log + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${{ steps.set-vars.outputs.ZONE_NAME }} AXFR > cascade-axfr.log - - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=1 >cascade-ixfr-1.log + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${{ steps.set-vars.outputs.ZONE_NAME }} -t IXFR=1 >cascade-ixfr-1.log - - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=2 >cascade-ixfr-2.log + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${{ steps.set-vars.outputs.ZONE_NAME }} -t IXFR=2 >cascade-ixfr-2.log - - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=3 >cascade-ixfr-3.log + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${{ steps.set-vars.outputs.ZONE_NAME }} -t IXFR=3 >cascade-ixfr-3.log - - run: dig +onesoa +noall +answer @127.0.0.1 -p 1054 example.test AXFR >nsd-axfr.log + - run: dig +onesoa +noall +answer @127.0.0.1 -p 1054 ${{ steps.set-vars.outputs.ZONE_NAME }} AXFR >nsd-axfr.log - name: Print log files on any failure in this job uses: ./.github/actions/print-logfiles From c2d1a7bf55d922cd7c69e1ed3256a19935033f79 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 May 2026 13:35:30 +0200 Subject: [PATCH 22/81] FIX: system-test should pass the `with-tsig` input correctly to the ixfr-out test. --- integration-tests/system-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/system-tests.yml b/integration-tests/system-tests.yml index 8bf0bc696..1b9d54989 100644 --- a/integration-tests/system-tests.yml +++ b/integration-tests/system-tests.yml @@ -180,7 +180,7 @@ jobs: - uses: ./integration-tests/tests/ixfr-out with: log-level: ${{ inputs.log-level }} - with-tsig: ${{ inputs.with-tsig }} + with-tsig: ${{ matrix.with-tsig }} incremental-signing: name: Test incremental signing. From cd989d17f86b2078464aa11abddc93bf3ca680ef Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 May 2026 13:36:33 +0200 Subject: [PATCH 23/81] (WIP) Extend the ixfr-out test to actually verify produced output. Currently fails due to an unrelated TSIG bug causing dig to fail. --- .../keys/Kexample.test.+015+00580.key | 1 + .../keys/Kexample.test.+015+00580.private | 3 + .../Knotify-and-xfr-tsig.test.+015+10995.key | 1 + ...otify-and-xfr-tsig.test.+015+10995.private | 3 + .../ixfr-out/policies/with-tsig.toml | 32 +++++ .../ixfr-out/policies/without-tsig.toml | 23 ++++ .../reference-output/example.test.axfr | 17 +++ .../reference-output/example.test.ixfr-1-3 | 73 +++++++++++ .../notify-and-xfr-tsig.test.axfr | 17 +++ .../notify-and-xfr-tsig.test.ixfr-1-3 | 27 ++++ integration-tests/tests/ixfr-out/action.yml | 115 +++++++++++++----- 11 files changed, 280 insertions(+), 32 deletions(-) create mode 100644 integration-tests/ixfr-out/keys/Kexample.test.+015+00580.key create mode 100644 integration-tests/ixfr-out/keys/Kexample.test.+015+00580.private create mode 100644 integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.key create mode 100644 integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.private create mode 100644 integration-tests/ixfr-out/policies/with-tsig.toml create mode 100644 integration-tests/ixfr-out/policies/without-tsig.toml create mode 100644 integration-tests/ixfr-out/reference-output/example.test.axfr create mode 100644 integration-tests/ixfr-out/reference-output/example.test.ixfr-1-3 create mode 100644 integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.axfr create mode 100644 integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-1-3 diff --git a/integration-tests/ixfr-out/keys/Kexample.test.+015+00580.key b/integration-tests/ixfr-out/keys/Kexample.test.+015+00580.key new file mode 100644 index 000000000..224ce50d5 --- /dev/null +++ b/integration-tests/ixfr-out/keys/Kexample.test.+015+00580.key @@ -0,0 +1 @@ +example.test. IN DNSKEY 256 3 15 u7Tg6xf6Xgt0y+yUT8CQi1Nyy+tRopVHnZOFQz0zQ5A= diff --git a/integration-tests/ixfr-out/keys/Kexample.test.+015+00580.private b/integration-tests/ixfr-out/keys/Kexample.test.+015+00580.private new file mode 100644 index 000000000..c6ba81b21 --- /dev/null +++ b/integration-tests/ixfr-out/keys/Kexample.test.+015+00580.private @@ -0,0 +1,3 @@ +Private-key-format: v1.2 +Algorithm: 15 (ED25519) +PrivateKey: qCwt/EY9s1vGn0uj+4dlMQ5waOuFfIYpZZVrX43jdcc= diff --git a/integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.key b/integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.key new file mode 100644 index 000000000..9ef332abc --- /dev/null +++ b/integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.key @@ -0,0 +1 @@ +notify-and-xfr-tsig.test. IN DNSKEY 256 3 15 0efFuDXcYUyhaTUWgir/RaWrzAJWAa58VuEF+391Taw= diff --git a/integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.private b/integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.private new file mode 100644 index 000000000..b385fd508 --- /dev/null +++ b/integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.private @@ -0,0 +1,3 @@ +Private-key-format: v1.2 +Algorithm: 15 (ED25519) +PrivateKey: Nx9EHQ1RBhngQMCRQUxMsiD2ybaWRCiEdcDlW5isKoo= diff --git a/integration-tests/ixfr-out/policies/with-tsig.toml b/integration-tests/ixfr-out/policies/with-tsig.toml new file mode 100644 index 000000000..cc32f479d --- /dev/null +++ b/integration-tests/ixfr-out/policies/with-tsig.toml @@ -0,0 +1,32 @@ +# Setup incremental signing to produce predictable outputs so that we can +# easily compare the generated zone against expected output. +# +# Enable sending of NOTIFY messages using TSIG authentication to the NSD +# secondary. +version = "v1" + +[signer.denial] +type = "nsec" + +[signer] +serial-policy = "keep" +signature-inception-offset = 0 +signature-lifetime = 100000000 + +[key-manager.records] +dnskey.signature-inception-offset = 0 +cds.signature-inception-offset = 0 +dnskey.signature-lifetime = 100000000 +cds.signature-lifetime = 100000000 + +[server.outbound] +# NSD secondary listens on port 1054. +send-notify-to = ["127.0.0.1:1054^tsig-key"] + +# Require that the XFR request from the NSD secondary be signed with the +# specified TSIG key. Without this, a TSIG signed XFR request with a known +# key will still be accepted, but an XFR request that is NOT TSIG signed +# would also be accepted. In the test we verify that TSIG is required by +# Cascade by attempting a dig without the required TSIG key and showing +# that it fails. +accept-xfr-from = ["127.0.0.1^tsig-key"] diff --git a/integration-tests/ixfr-out/policies/without-tsig.toml b/integration-tests/ixfr-out/policies/without-tsig.toml new file mode 100644 index 000000000..07c1d9359 --- /dev/null +++ b/integration-tests/ixfr-out/policies/without-tsig.toml @@ -0,0 +1,23 @@ +# Setup incremental signing to produce predictable outputs so that we can +# easily compare the generated zone against expected output. +# +# Enable sending of NOTIFY messages to the NSD secondary. +version = "v1" + +[signer.denial] +type = "nsec" + +[signer] +serial-policy = "keep" +signature-inception-offset = 0 +signature-lifetime = 100000000 + +[key-manager.records] +dnskey.signature-inception-offset = 0 +cds.signature-inception-offset = 0 +dnskey.signature-lifetime = 100000000 +cds.signature-lifetime = 100000000 + +[server.outbound] +# NSD secondary listens on port 1054. +send-notify-to = ["127.0.0.1:1054"] diff --git a/integration-tests/ixfr-out/reference-output/example.test.axfr b/integration-tests/ixfr-out/reference-output/example.test.axfr new file mode 100644 index 000000000..c9577dd58 --- /dev/null +++ b/integration-tests/ixfr-out/reference-output/example.test.axfr @@ -0,0 +1,17 @@ +example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 +example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. iVSTY7uTaal9mRd44phalz9+oHlQtNDGPrQb4rvx3SkdTMT21l9PDCVL BxomZOiC51WhuQX+8FxSiLQcN+sfAw== +example.test. 3600 IN NS NS.example.test. +example.test. 3600 IN RRSIG NS 15 2 3600 20231114221320 20200913122640 580 example.test. hUD4MZwiVtXmHs+VueFIIrTUKYzWfOiNWm2nTR1gtsHTYnGjRrVEH/ic 3XeWDtKK0EedUE5iOKIEfQyYpaTLAg== +example.test. 3600 IN DNSKEY 256 3 15 u7Tg6xf6Xgt0y+yUT8CQi1Nyy+tRopVHnZOFQz0zQ5A= +example.test. 3600 IN RRSIG DNSKEY 15 2 3600 20231114221320 20200913122640 580 example.test. xGC6Pj6iN7tOjmhnLxmw4yQt7CCxCVouXvdB1P3lsQwN0iFfxgamkU7j EqIQcPpl20erYZ4XoWwtOdoeaXwtAg== +example.test. 5 IN NSEC JAIN-BB.example.test. NS SOA RRSIG NSEC DNSKEY +example.test. 5 IN RRSIG NSEC 15 2 5 20231114221320 20200913122640 580 example.test. P/YK6hlW/FOz43gKHqT4zoxRBdKFinx8GktOFDKuM+CWNyfBkjUZEI04 e3pa5/GUNcLKwRYAyWlNQnONPuusCQ== +NS.example.test. 3600 IN A 133.69.136.1 +NS.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. EsKLrDOszkF8dHi6K8XYed5EbqU9fSlNO2+25pBO/qZk1nZm1RyDO15X KWoCf1jUUNsTR4sZkiu3OGV7oazcAA== +NS.example.test. 5 IN NSEC example.test. A RRSIG NSEC +NS.example.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 580 example.test. hO9nn4+rxV08CFGkyXYzqGrRYNVOptJXZOaQLp6OSCMtte2iSQCq7UxJ p2KK2Q7aegqYKm6nNFoABmEYjZNlDQ== +JAIN-BB.example.test. 3600 IN A 133.69.136.3 +JAIN-BB.example.test. 3600 IN A 192.41.197.2 +JAIN-BB.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. gPZnGC1IeAz1VtEvYEV9jMLw7kFhwPKX9kMtH32/xC7zyYNOfmcWqffZ xbobEY8c/oS9z4WIiK8k4uFi90rQCA== +JAIN-BB.example.test. 5 IN NSEC NS.example.test. A RRSIG NSEC +JAIN-BB.example.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 580 example.test. /S/FZGh/OBQjwaVRPXJChDNkraEeD7qtVvIAa8db4bYmylI4KWOsH991 XDjye2aOv1+qPZxXIfzuX/3ER8JUBA== diff --git a/integration-tests/ixfr-out/reference-output/example.test.ixfr-1-3 b/integration-tests/ixfr-out/reference-output/example.test.ixfr-1-3 new file mode 100644 index 000000000..56beb1143 --- /dev/null +++ b/integration-tests/ixfr-out/reference-output/example.test.ixfr-1-3 @@ -0,0 +1,73 @@ +; The input zone was based on RFC 1995 section 7. This diff should look +; similar to "the following incremental message" from that section, but with +; DNSSEC changes present as well. Our zone name differs to that of the example +; in RFC 1995 as we have to match the zone name configured in our secondary NSD +; instance. + +; === Start of IXFR +example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 + +; [DIFF 1]: "NEZU.JAIN.AD.JP. is removed and JAIN-BB.JAIN.AD.JP. is added." +; [DIFF 1]: Records from serial 1 to be removed. + +; Changing the SOA serial requires that we regenerate the RRSIG, so the old +; RRSIG has to be removed. +example.test. 3600 IN SOA NS.example.test. mail.example.test. 1 60 60 3600 5 +example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. TlLDWFfpydqCQjy1oDPBXugGWraoyvvk83KdTsiq441t0/hYKmL2mFEH 2nwJ6fcuZT2hLpvl9gwk3bJCXrNdAQ== + +; Remove the A record and its associated RRSIG. +NEZU.example.test. 3600 IN A 133.69.136.5 +NEZU.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. k232/sABO752SeaFp3jHkIJMUGlDA0NG+iQh1gpGVsi07XqDce+JzNX4 NiWmz06kVu+SKu1HFHNvGJ3enh5/DQ== + +; Remove the A record from the NSEC type bitmap too, and also remove NSEC +; RRSIG as a new one will be added to replace it. +NEZU.example.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 580 example.test. BCpvhGfe+GW4GnyJBBiqjMpiVZhCXv9ZMdh+RF7qf07V3jIePWJThAy2 yV5O4I7NhKgl6rqpOyv2+aSoW8tVDw== +NEZU.example.test. 5 IN NSEC NS.example.test. A RRSIG NSEC + +; As the entire NEZU.example.test. label was removed this also requires the +; next owner name in the NSEC chain to be changed so we have to remove the +; current NSEC on the previous owner and its associated RRSIG, new ones will +; be added. +example.test. 5 IN NSEC NEZU.example.test. NS SOA RRSIG NSEC DNSKEY +example.test. 5 IN RRSIG NSEC 15 2 5 20231114221320 20200913122640 580 example.test. Hyrs5CKpXfCCNC9OK9+07Ei5qhRjQ9EQZK3up84BCwlflhOCGQppddzM eHK0Klgl0ywKaD7dfV2w3SWvP7vTDA== + +; [DIFF 1]: Records to be added for serial 2. + +; The new SOA with the updated SERIAL and its associated RRSIG. +example.test. 3600 IN SOA ns.example.test. mail.example.test. 2 60 60 3600 5 +example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. fYRpxmxV5HQl7wIPmuN+lrYQJalFuxi9iI0DX2/mM4kdh7q9GGutFTOZ kbdY4D+AZf25s08CZR1CPpd9o2Q3Dg== + +; A changed NSEC and associated RRSIG due to changes in the NSEC chain. +example.test. 5 IN NSEC JAIN-BB.example.test. NS SOA RRSIG NSEC DNSKEY +example.test. 5 IN RRSIG NSEC 15 2 5 20231114221320 20200913122640 580 example.test. P/YK6hlW/FOz43gKHqT4zoxRBdKFinx8GktOFDKuM+CWNyfBkjUZEI04 e3pa5/GUNcLKwRYAyWlNQnONPuusCQ== + +; Add new label JAIN-BB.example.test with two A records, associated NSEC and RRSIGs. +JAIN-BB.example.test. 3600 IN A 133.69.136.4 +JAIN-BB.example.test. 3600 IN A 192.41.197.2 +JAIN-BB.example.test. 5 IN NSEC NS.example.test. A RRSIG NSEC +JAIN-BB.example.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 580 example.test. /S/FZGh/OBQjwaVRPXJChDNkraEeD7qtVvIAa8db4bYmylI4KWOsH991 XDjye2aOv1+qPZxXIfzuX/3ER8JUBA== +JAIN-BB.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. XLFJlxjJDzmdwqF2BgxWN7Swfty8HD3ILb6Mo7fHKrfYt6x3uxXIUIjO JFVwFBhavLTrOlOcXdBGoNTvceqTDg== + +; [DIFF 2]: "One of the IP addresses of JAIN-BB.JAIN.AD.JP. is changed." +; [DIFF 2]: Records from serial 2 to be removed. + +; The old SOA and associated RRSIG. +example.test. 3600 IN SOA ns.example.test. mail.example.test. 2 60 60 3600 5 +example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. fYRpxmxV5HQl7wIPmuN+lrYQJalFuxi9iI0DX2/mM4kdh7q9GGutFTOZ kbdY4D+AZf25s08CZR1CPpd9o2Q3Dg== + +; The old A record and associated RRSIG. +JAIN-BB.example.test. 3600 IN A 133.69.136.4 +JAIN-BB.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. XLFJlxjJDzmdwqF2BgxWN7Swfty8HD3ILb6Mo7fHKrfYt6x3uxXIUIjO JFVwFBhavLTrOlOcXdBGoNTvceqTDg== + +; [DIFF 2]: Records to be added for serial 3. + +; The new SOA and associated RRSIG. +example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 +example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. iVSTY7uTaal9mRd44phalz9+oHlQtNDGPrQb4rvx3SkdTMT21l9PDCVL BxomZOiC51WhuQX+8FxSiLQcN+sfAw== + +; The updated A record and associated RRSIG. +JAIN-BB.example.test. 3600 IN A 133.69.136.3 +JAIN-BB.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. gPZnGC1IeAz1VtEvYEV9jMLw7kFhwPKX9kMtH32/xC7zyYNOfmcWqffZ xbobEY8c/oS9z4WIiK8k4uFi90rQCA== + +; End of IXFR +example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 diff --git a/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.axfr b/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.axfr new file mode 100644 index 000000000..08e54a056 --- /dev/null +++ b/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.axfr @@ -0,0 +1,17 @@ +notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 +notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. caF70ZxIDm7UIXcj4RmkY5G+rcNFJp+wKpg17pUEz+N4P8+uxp3tYjYj 145UTqd36AUv4jsJgqEVN9roaZAbAw== +notify-and-xfr-tsig.test. 3600 IN NS NS.notify-and-xfr-tsig.test. +notify-and-xfr-tsig.test. 3600 IN RRSIG NS 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. YDqsJSHx81jK/GSqDdnNDG3JN9v9SH6jgmsgbQNElcaSCXqMrR2Q3QFq wrYW06FgnadRgTySRZ2evzS3dYGaDw== +notify-and-xfr-tsig.test. 3600 IN DNSKEY 256 3 15 0efFuDXcYUyhaTUWgir/RaWrzAJWAa58VuEF+391Taw= +notify-and-xfr-tsig.test. 3600 IN RRSIG DNSKEY 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. CIiRWX2DYaZRidc0nDl4zE0vmUCK2XFX2ekrJc1DVljIestEaJBAR2V1 ZQYOtCtb8y8AA/RCH8UFN2DYSM6+CQ== +notify-and-xfr-tsig.test. 5 IN NSEC JAIN-BB.notify-and-xfr-tsig.test. NS SOA RRSIG NSEC DNSKEY +notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 2 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. z7aQTz7VOeeSwwftFsHr+f2BG1k1mVTGztiqZNUAYBkT5R+IlhWpzNdR KvpUSMcdgSNSsjKLQciFq/vYSqg7DA== +NS.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.1 +NS.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. ziPT0QEZ/gGcVltE17tqgf3jqo9SfesIffux+pIZwC3vO8eg1MnzPJOv Kmdl9vJQqS03FjwT+6r3xtjIVIfwAA== +NS.notify-and-xfr-tsig.test. 5 IN NSEC notify-and-xfr-tsig.test. A RRSIG NSEC +NS.notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. nLNfo5CuJQd8qYbDywdefe0pFQ881jn3W6WEfNnkMg1daJk2ENcZUkiJ aeR7Hv7FPzTyavE2SQbuHfHrYDKoAA== +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.3 +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 192.41.197.2 +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0X5PtAfOES8cyYc9kFoXfFB5w5AHBtTSlw8V2wd+UehtSgHMDX0ebUig C65dQahRoe6oELz7UNY7rv+BLOMrAA== +JAIN-BB.notify-and-xfr-tsig.test. 5 IN NSEC NS.notify-and-xfr-tsig.test. A RRSIG NSEC +JAIN-BB.notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. Kn1CEC2iYr/uSRAZvM9SV2HRR0B8l8ObkvqYufiGO0en+OSLRmfgzviT Mdhdai9onUYr7ndQFHI8bSav5MBoCQ== diff --git a/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-1-3 b/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-1-3 new file mode 100644 index 000000000..33df5ea3e --- /dev/null +++ b/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-1-3 @@ -0,0 +1,27 @@ +notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 +notify-and-xfr-tsig.test. 3600 IN SOA NS.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 1 60 60 3600 5 +NEZU.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.5 +notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 2 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0BtPWx9VOtcE1BMD3cLk1FR1YaT/1v32VuuxXh/XQgS97Nj80H+Qm57V ORpk/V6wkw2exuvz3ko2s2BfkaQqAQ== +notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. xzwl8jSWvas1L2Uqzfq8kGkPZzvTQyvrmv8Q3q+jVdTj7FIYCOXN3moZ MvasxJgo6euVesldGA0WzQ0UaIxyBw== +notify-and-xfr-tsig.test. 5 IN NSEC NEZU.notify-and-xfr-tsig.test. NS SOA RRSIG NSEC DNSKEY +NEZU.notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. kNDs5mxnK6tKyDu1fBIAg1SnynqRQM7afK+hmPkLJD0SkvKEItomMbui gKlgDdtA7YWA8zTh4j7bwUq/tBlYAQ== +NEZU.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. Etgfp3BL11ReT8j0UHQ5epj6l+mAH2+1Fmv/p74EQ3EsbpPiGx3c+96u XeRbWRCVVY/yCqH7E5j0NPHvFYhBAg== +NEZU.notify-and-xfr-tsig.test. 5 IN NSEC NS.notify-and-xfr-tsig.test. A RRSIG NSEC +notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 2 60 60 3600 5 +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.4 +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 192.41.197.2 +notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 2 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. z7aQTz7VOeeSwwftFsHr+f2BG1k1mVTGztiqZNUAYBkT5R+IlhWpzNdR KvpUSMcdgSNSsjKLQciFq/vYSqg7DA== +notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0Qn9RoFrnZrhs8qy/GFWrSQ0ZUx11s60vVAzhBR0MhX9k8731t6Is5ic VwajxP8l3NM1PSvB0Eqamo0cyy6rCA== +notify-and-xfr-tsig.test. 5 IN NSEC JAIN-BB.notify-and-xfr-tsig.test. NS SOA RRSIG NSEC DNSKEY +JAIN-BB.notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. Kn1CEC2iYr/uSRAZvM9SV2HRR0B8l8ObkvqYufiGO0en+OSLRmfgzviT Mdhdai9onUYr7ndQFHI8bSav5MBoCQ== +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. SRcvE4FT1NE9uVZrrFv1f+xinA37/CZWac6bJ62REhIE2e90rbDr1i/e n31YVDZQOHeVTKZCuueVKXgPYyztCw== +JAIN-BB.notify-and-xfr-tsig.test. 5 IN NSEC NS.notify-and-xfr-tsig.test. A RRSIG NSEC +notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 2 60 60 3600 5 +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.4 +notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0Qn9RoFrnZrhs8qy/GFWrSQ0ZUx11s60vVAzhBR0MhX9k8731t6Is5ic VwajxP8l3NM1PSvB0Eqamo0cyy6rCA== +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. SRcvE4FT1NE9uVZrrFv1f+xinA37/CZWac6bJ62REhIE2e90rbDr1i/e n31YVDZQOHeVTKZCuueVKXgPYyztCw== +notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.3 +notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. caF70ZxIDm7UIXcj4RmkY5G+rcNFJp+wKpg17pUEz+N4P8+uxp3tYjYj 145UTqd36AUv4jsJgqEVN9roaZAbAw== +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0X5PtAfOES8cyYc9kFoXfFB5w5AHBtTSlw8V2wd+UehtSgHMDX0ebUig C65dQahRoe6oELz7UNY7rv+BLOMrAA== +notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 diff --git a/integration-tests/tests/ixfr-out/action.yml b/integration-tests/tests/ixfr-out/action.yml index 757a60ce4..e120fb45c 100644 --- a/integration-tests/tests/ixfr-out/action.yml +++ b/integration-tests/tests/ixfr-out/action.yml @@ -29,21 +29,7 @@ runs: - uses: ./.github/actions/setup-and-start-cascade with: log-level: ${{ inputs.log-level }} - - - name: Add a NOTIFY with TSIG outbound policy - run: | - POLICY_DIR=$(integration-tests/scripts/get-default-path.sh policy-dir) - cascade template policy | grep -Ev '(send-notify-to|accept-xfr-from)' > "${POLICY_DIR}/without-tsig.toml" - cascade template policy | grep -Ev '(send-notify-to|accept-xfr-from)' > "${POLICY_DIR}/with-tsig.toml" - - sed -i -e 's/serial-policy = "date-counter"/serial-policy = "keep"/' "${POLICY_DIR}/without-tsig.toml" - sed -i -e 's/serial-policy = "date-counter"/serial-policy = "keep"/' "${POLICY_DIR}/with-tsig.toml" - - echo 'send-notify-to = ["127.0.0.1:1054"]' >> "${POLICY_DIR}/without-tsig.toml" - echo 'send-notify-to = ["127.0.0.1:1054^tsig-key"]' >> "${POLICY_DIR}/with-tsig.toml" - - cascade tsig add tsig-key hmac-sha256 "COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" - cascade policy reload + fake-time: 1600000000 - name: Determine the downstrem secondary zone config to use id: set-vars @@ -51,19 +37,35 @@ runs: if [[ "${{ inputs.with-tsig }}" == "true" ]]; then ZONE_NAME="notify-and-xfr-tsig.test." POLICY_NAME="with-tsig" + KEY_NAME="Knotify-and-xfr-tsig.test.+015+10995.key" else ZONE_NAME="example.test." POLICY_NAME="without-tsig" + KEY_NAME="Kexample.test.+015+00580.key" fi echo "ZONE_NAME=${ZONE_NAME}" >> "$GITHUB_OUTPUT" echo "POLICY_NAME=${POLICY_NAME}" >> "$GITHUB_OUTPUT" + echo "KEY_NAME=${KEY_NAME}" >> "$GITHUB_OUTPUT" + echo "TSIG_SPEC=hmac-sha256:tsig-key:COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" >> "$GITHUB_OUTPUT" + + - name: Add the TSIG key + if: ${{ inputs.with-tsig }} + run: | + cascade tsig add ${{ steps.set-vars.outputs.TSIG_SPEC }} + + - name: Add policy ${{ steps.set-vars.outputs.POLICY_NAME }} and reload + run: | + POLICY_DIR=$(integration-tests/scripts/get-default-path.sh policy-dir) + TEST_DIR="${PWD}/integration-tests/ixfr-out" + cp "${TEST_DIR}/policies/${{ steps.set-vars.outputs.POLICY_NAME }}.toml" "${POLICY_DIR}/" + cascade policy reload # The zone name has to match a zone configured in the downstream NSD # nameserver. Zone 'notify-and-xfr-tsig.test' is a zone in the downstream # NSD nameserver that is configured to expect TSIG to be used for the # NOTIFY message it is sent, to use IXFR (as opposed to only AXFR) and for # it to need to use TSIG to request an IXFR transfer from Cascade. - - name: Make an initial zone based on RFC 1995 section 7 + - name: Make a ${{ steps.set-vars.outputs.ZONE_NAME }} zone based on RFC 1995 section 7 env: ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} run: | @@ -75,14 +77,19 @@ runs: NEZU.${ZONE_NAME} IN A 133.69.136.5 EOF - - name: Add the zone using the custom policy + - name: Add the ${{ steps.set-vars.outputs.ZONE_NAME }} zone using policy ${{ steps.set-vars.outputs.POLICY_NAME }} env: ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} POLICY_NAME: ${{ steps.set-vars.outputs.POLICY_NAME }} + KEY_NAME: ${{ steps.set-vars.outputs.KEY_NAME }} run: | - cascade zone add --policy ${POLICY_NAME} --source "${ZONE_NAME}zone" ${ZONE_NAME} + TEST_DIR="${PWD}/integration-tests/ixfr-out" + KEY_FILE="${TEST_DIR}/keys/${KEY_NAME}" + # Use a pre-created key so that generated signatures can be matched + # against reference output. + cascade zone add --policy ${POLICY_NAME} --source "${ZONE_NAME}zone" --import-csk-file "${KEY_FILE}" ${ZONE_NAME} - - name: Check zone status + - name: Check zone ${{ steps.set-vars.outputs.ZONE_NAME }} status env: ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} run: | @@ -97,7 +104,7 @@ runs: sleep 1 done - - name: Check the SOA SERIAL at the NSD secondary + - name: Expect serial 1 of zone ${{ steps.set-vars.outputs.ZONE_NAME }} at the NSD secondary env: ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} run: | @@ -113,21 +120,21 @@ runs: sleep 1 done - - name: Edit the source zone and tell Cascade to reload it. + - name: Edit and reload serial 2 of zone ${{ steps.set-vars.outputs.ZONE_NAME }} env: ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} run: | cat >"${ZONE_NAME}zone" <"${ZONE_NAME}zone" < cascade-axfr.log + - name: Verify that TSIG is required by Cascade + if: ${{ inputs.with-tsig }} + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} + run: | + # This should not succeed as it lacks the required TSIG key. + # Note that dig will exit with code 0 even if the request fails, + # because it considers an error response still to be successful + # receipt of a response, so we have to check the actual output. + dig @127.0.0.1 -p 4542 ${ZONE_NAME} AXFR &> dig-no-tsig.log + if grep -Fq 'Transfer failed' dig-no-tsig.log; then + exit 0 + else + echo "::error:: dig unexpectedly was able to AXFR the zone without a TSIG key" + exit 1 + fi + + - name: Verify Cascade AXFR out + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} + run: | + EXTRA_ARGS= + if [[ "${{ inputs.with-tsig }}" == "true" ]]; then + EXTRA_ARGS="-y ${{ steps.set-vars.outputs.TSIG_SPEC }}" + fi + echo "EXTRA_ARGS: ${EXTRA_ARGS}" + dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${EXTRA_ARGS} ${ZONE_NAME} AXFR &> cascade-axfr.log + TEST_DIR="${PWD}/integration-tests/ixfr-out" + LC_COLLATE=C sort "${TEST_DIR}/reference-output/${ZONE_NAME}axfr" | egrep -v '^;|^$' > reference.output.sorted + LC_COLLATE=C sort cascade-axfr.log > output.sorted + diff -u -w output.sorted reference.output.sorted - - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${{ steps.set-vars.outputs.ZONE_NAME }} -t IXFR=1 >cascade-ixfr-1.log + - name: Verify Cascade IXFR serial 1..=3 out + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} + run: | + EXTRA_ARGS= + if [[ "${{ inputs.with-tsig }}" == "true" ]]; then + EXTRA_ARGS="-y ${{ steps.set-vars.outputs.TSIG_SPEC }}" + fi + dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${ZONE_NAME} -t IXFR=1 ${EXTRA_ARGS} &> cascade-ixfr-1-3.log + TEST_DIR="${PWD}/integration-tests/ixfr-out" + LC_COLLATE=C sort "${TEST_DIR}/reference-output/${ZONE_NAME}ixfr-1-3" | egrep -v '^;|^$' > reference.output.sorted + LC_COLLATE=C sort cascade-ixfr-1-3.log > output.sorted + diff -u -w output.sorted reference.output.sorted - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${{ steps.set-vars.outputs.ZONE_NAME }} -t IXFR=2 >cascade-ixfr-2.log @@ -222,6 +271,8 @@ runs: - run: dig +onesoa +noall +answer @127.0.0.1 -p 1054 ${{ steps.set-vars.outputs.ZONE_NAME }} AXFR >nsd-axfr.log + - run: exit 1 + - name: Print log files on any failure in this job uses: ./.github/actions/print-logfiles if: failure() From c733215a5aaeafb4bf55f96c905415eab34cd4ba Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 May 2026 13:37:08 +0200 Subject: [PATCH 24/81] Add more logging of request handling w.r.t. ACL processing. --- src/server/service.rs | 73 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 15 deletions(-) diff --git a/src/server/service.rs b/src/server/service.rs index cb72310e8..5d5a1f46f 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -86,7 +86,7 @@ mod compat { tsig, }; use futures::Stream; - use tracing::{Level, trace}; + use tracing::{Level, debug, trace}; use crate::server::request::{RequestKind, ZoneRequestKind}; @@ -167,6 +167,21 @@ mod compat { ) -> bool { let zone_state = zone.handle.state.lock().unwrap(); + if tracing::enabled!(Level::TRACE) { + let tsig_key = old_request.metadata().as_ref().map(|key| key.name()); + trace!( + "Received request {} from {} for {} in zone {} with TSIG key {tsig_key:?}", + old_request.message().header().id(), + old_request.client_addr().ip(), + old_request + .message() + .qtype() + .map(|rtype| rtype.to_string()) + .unwrap_or("".to_string()), + zone.handle.name, + ); + } + if let Some(acls) = zone_state .policy .as_ref() @@ -188,6 +203,27 @@ mod compat { } // No ACL matched, reject the request. + if tracing::enabled!(Level::DEBUG) { + let extra = if tracing::enabled!(Level::TRACE) { + &format!( + " (TSIG key={wanted_tsig_key_name:?}) [no matching ACL found: {acls:?}]" + ) + } else { + "" + }; + debug!( + "Rejecting request {} from {} for {} in zone {}: access denied{extra}", + old_request.message().header().id(), + old_request.client_addr().ip(), + old_request + .message() + .qtype() + .map(|rtype| rtype.to_string()) + .unwrap_or("".to_string()), + zone.handle.name, + ); + } + return false; } } @@ -365,27 +401,32 @@ mod compat { // response message and will have to be split into multiple response // messages. - // Currently we have only a single diff available. if tracing::enabled!(Level::TRACE) { trace!( - "IXFR out: {} diffs availablei for zone {}:", + "IXFR out: {} diffs available for zone {}:", diffs.len(), zone.handle.name ); - for (i, d) in diffs.iter().enumerate() { + for (i, (loaded_diff, signed_diff)) in diffs.iter().enumerate() { trace!( - "IXFR out: Diff #{i}: serial {} => serial {}", - d.removed_soa.as_ref().unwrap().0.rdata.serial, - d.added_soa.as_ref().unwrap().0.rdata.serial, + "IXFR out: Diff #{i}: serial {} => serial {}, loaded -{}+{}, signed -{}+{}", + signed_diff.removed_soa.as_ref().unwrap().0.rdata.serial, + signed_diff.added_soa.as_ref().unwrap().0.rdata.serial, + loaded_diff.removed_records.len(), + loaded_diff.added_records.len(), + signed_diff.removed_records.len(), + signed_diff.added_records.len(), ); } } // Find the diff, if we have it, that removes the SOA serial number // that the client currently has. That will be the start of the diff - // that we need to serve. - let start_idx = diffs.iter().position(|d| { - d.removed_soa.as_ref().map(|rr| rr.0.rdata.serial) == Some(client_soa.serial) + // that we need to serve. The SOA serial has to match the one seen by + // the client, i.e. the one we published in the signed zone, not the + // one from the loaded zone which could be completely different. + let start_idx = diffs.iter().position(|(_, signed_diff)| { + signed_diff.removed_soa.as_ref().map(|rr| rr.0.rdata.serial) == Some(client_soa.serial) }); let Some(start_idx) = start_idx else { @@ -403,11 +444,13 @@ mod compat { tokio::task::spawn(async move { // Collect the sequence of IXFR output records. let mut rrs = vec![new_soa.clone().into()]; - for diff in &diffs[start_idx..] { - rrs.push(diff.removed_soa.clone().unwrap().into()); - rrs.extend(diff.removed_records.clone()); - rrs.push(diff.added_soa.clone().unwrap().into()); - rrs.extend(diff.added_records.clone()); + for (loaded_diff, signed_diff) in &diffs[start_idx..] { + rrs.push(signed_diff.removed_soa.clone().unwrap().into()); + rrs.extend(loaded_diff.removed_records.clone()); + rrs.extend(signed_diff.removed_records.clone()); + rrs.push(signed_diff.added_soa.clone().unwrap().into()); + rrs.extend(loaded_diff.added_records.clone()); + rrs.extend(signed_diff.added_records.clone()); } rrs.push(new_soa.into()); From 4e4827090a5b9b9a9e33c2686685d92c30f6400b Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 May 2026 13:37:39 +0200 Subject: [PATCH 25/81] FIX: include loaded RRs in the IXFR out, not just signed RRs. --- src/persistence/persist.rs | 32 ++++++++++++++++++++++++++------ src/zone/storage.rs | 5 +++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index bf9f9dda0..67be2aadf 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -38,15 +38,35 @@ pub fn persist_signed( // TODO let _ = center; - // Store the signed diff in-memory for serving IXFR. - // Only push a diff if a SOA was removed, otherwise this is not a diff to - // a previous version of the zone but actually the entire new zone content - // compared to an empty zone. Also don't store a diff to the same serial. + // Store the diffs in-memory for serving IXFR. + // + // Only store a diff if the SOA from the previous version of the signed + // zone was removed and a new one added, otherwise this is not a diff to a + // previous version of the zone but actually a snapshot of the zone after + // having been signed for the first time. + let loaded_diff = persister.loaded_diff(); let signed_diff = persister.signed_diff(); - if signed_diff.removed_soa.is_some() && signed_diff.removed_soa != signed_diff.added_soa { + if let Some(loaded_diff) = loaded_diff + && signed_diff.removed_soa.is_some() + && signed_diff.removed_soa != signed_diff.added_soa + { let mut state = zone.state.lock().unwrap(); - state.storage.diffs.push(signed_diff.clone()); + + // Store anything that changed when the zone was re-loaded, i.e. + // unsigned zone content changes. Note that the SOA SERIAL is not + // required to change unless using 'keep' policy and so we should not + // require the SOA to have been removed and a new one added. + + // Store anything that changed when the zone was re-signed, i.e. + // changes DNSSEC RRs that can be caused by unsigned content changes + // or changing from NSEC <-> NSEC3 or using a new key to sign with or + // just regenerating signatures to avoid them expiring. Signed zones + // MUST always have a new SOA SERIAL compared to the previous version + // of the signed zone. + + let complete_diff = (loaded_diff.clone(), signed_diff.clone()); + state.storage.diffs.push(complete_diff); } persister.mark_complete() diff --git a/src/zone/storage.rs b/src/zone/storage.rs index a8538e5d1..370dcc9f8 100644 --- a/src/zone/storage.rs +++ b/src/zone/storage.rs @@ -925,8 +925,9 @@ pub struct StorageState { // current i.e. published zone instance. pub published_loaded_soa: Option, - /// Diffs from one serial to another. - pub diffs: Vec>, + /// Diffs from one serial to another. Each diff consists of changes in the + /// loaded part and changes in the signed part. + pub diffs: Vec<(Arc, Arc)>, /// Ongoing background tasks. /// From 50d445bde34d3e50f82abec9d59581bbfe89dc27 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 May 2026 13:53:19 +0200 Subject: [PATCH 26/81] Various updates. - Merges in the ixfr-out and full-signer-update-state branches as these were needed for local testing, but should be obsoleted / synced with main when PRs #631 ("Save last serial and key tags in zone state")and #605 ("Add IXFR out support") get merged. - Extends the persist-restore system test to cover more cases. Should perhaps be split out into separate smaller tests. - Actually adds restored diffs to the zone storage to be served by IXFR out. Will need updating to match the changes/fixes that have since been made in PR #605. - Fixes an issue where persisted diffs from multiple SOA serials, e.g. 1..2..3 would be condensed on restore so that only a single IXFR diff from 1..3 would be available instead of two diffs from 1..2 and 2..3 being available. - Clears the set of known persisted data file paths for a zone if any of those files are missing or cannot be parsed during restoration. --- crates/zonedata/src/diff.rs | 2 +- crates/zonedata/src/restorer.rs | 5 + .../scripts/manage-test-environment.sh | 3 +- integration-tests/system-tests.yml | 15 + integration-tests/tests/ixfr-out/action.yml | 183 +++++++++++ .../tests/persist-zone/action.yml | 291 +++++++++++++++++- src/persistence/persist.rs | 40 ++- src/persistence/restore.rs | 128 ++++++-- src/persistence/zone.rs | 11 +- src/server/request.rs | 1 - src/server/service.rs | 158 +++++++++- src/signer/incremental.rs | 20 +- src/units/zone_signer.rs | 101 +++--- src/zone/storage.rs | 6 +- 14 files changed, 844 insertions(+), 120 deletions(-) create mode 100644 integration-tests/tests/ixfr-out/action.yml diff --git a/crates/zonedata/src/diff.rs b/crates/zonedata/src/diff.rs index a66c99b8a..8645db4d6 100644 --- a/crates/zonedata/src/diff.rs +++ b/crates/zonedata/src/diff.rs @@ -19,7 +19,7 @@ use crate::{RegularRecord, SoaRecord}; /// [`DiffData`] can be used to store the data for an old zone (where it is the /// base, and the next newer zone is the target). This is perfect for serving /// IXFR requests. -#[derive(Clone, Default)] +#[derive(Clone, Debug, Default)] pub struct DiffData { /// The SOA record to remove. /// diff --git a/crates/zonedata/src/restorer.rs b/crates/zonedata/src/restorer.rs index 98f6d6974..406327b18 100644 --- a/crates/zonedata/src/restorer.rs +++ b/crates/zonedata/src/restorer.rs @@ -469,6 +469,11 @@ impl SignedZoneRestorer { .find(|inst| inst.soa.is_some()) .map(|inst| SignedZoneReader::new(curr_loaded, inst)) } + + /// The diff from the preceding signed instance to the current one. + pub fn take_diff(&mut self) -> Option> { + self.diff.take() + } } impl SignedZoneRestorer { diff --git a/integration-tests/scripts/manage-test-environment.sh b/integration-tests/scripts/manage-test-environment.sh index 349ce5d69..01054b35f 100755 --- a/integration-tests/scripts/manage-test-environment.sh +++ b/integration-tests/scripts/manage-test-environment.sh @@ -298,8 +298,7 @@ pattern: name: secondary zonefile: "%s.secondary-zone" allow-notify: 127.0.0.1 NOKEY - # Until Cascade supports IXFR we always use AXFR - request-xfr: AXFR 127.0.0.1@${_cascade_port} NOKEY + request-xfr: 127.0.0.1@${_cascade_port} NOKEY provide-xfr: 127.0.0.1 NOKEY zone: diff --git a/integration-tests/system-tests.yml b/integration-tests/system-tests.yml index a2c43b201..5a3d40934 100644 --- a/integration-tests/system-tests.yml +++ b/integration-tests/system-tests.yml @@ -166,6 +166,21 @@ jobs: with: log-level: ${{ inputs.log-level }} + ixfr-out: + name: Serve a zone via IXFR to the NSD secondary. + runs-on: ubuntu-latest + strategy: + matrix: + rust: [stable] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/set-build-profile + with: + build-profile: ${{ inputs.build-profile }} + - uses: ./integration-tests/tests/ixfr-out + with: + log-level: ${{ inputs.log-level }} + incremental-signing: name: Test incremental signing. runs-on: ubuntu-latest diff --git a/integration-tests/tests/ixfr-out/action.yml b/integration-tests/tests/ixfr-out/action.yml new file mode 100644 index 000000000..d6394463f --- /dev/null +++ b/integration-tests/tests/ixfr-out/action.yml @@ -0,0 +1,183 @@ +# Making reusable composite actions documented at +# https://docs.github.com/en/actions/tutorials/create-actions/create-a-composite-action#creating-a-composite-action-within-the-same-repository +name: 'Serve a zone via IXFR to the NSD secondary.' +description: 'Serve a zone via IXFR to the NSD secondary.' +defaults: + # see: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defaultsrunshell + run: + shell: bash --noprofile --norc -eo pipefail -x {0} +inputs: + log-level: + description: The level of logging that Cascade should output. + required: false + default: debug + type: choice + options: + - error + - warning + - info + - debug + - trace +runs: + using: "composite" + steps: + - uses: ./.github/actions/prepare-systest-env + - uses: ./.github/actions/setup-and-start-cascade + with: + log-level: ${{ inputs.log-level }} + + - name: Add a NOTIFY with TSIG outbound policy + run: | + POLICY_DIR=$(integration-tests/scripts/get-default-path.sh policy-dir) + cascade template policy | grep -Ev '(send-notify-to|accept-xfr-from)' > "${POLICY_DIR}/custom.toml" + + sed -i -e 's/serial-policy = "date-counter"/serial-policy = "keep"/' "${POLICY_DIR}/custom.toml" + echo 'send-notify-to = ["127.0.0.1:1054"]' >> "${POLICY_DIR}/custom.toml" + + # TODO: Extend the test to use TSIG (driven by inputs ala the tsig-downstream test) + # once PRs #564 and #587 have been merged. + # echo 'send-notify-to = ["127.0.0.1:1054^tsig-key"]' >> "${POLICY_DIR}/custom.toml" + # cascade tsig add tsig-key hmac-sha256 "COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" + cascade policy reload + + - name: Make an initial zone based on RFC 1995 section 7 + run: | + tee example.test.zone <<'EOF' + EXAMPLE.TEST. IN SOA NS.EXAMPLE.TEST. mail.example.test. ( + 1 60 60 3600 5) + IN NS NS.EXAMPLE.TEST. + NS.EXAMPLE.TEST. IN A 133.69.136.1 + NEZU.EXAMPLE.TEST. IN A 133.69.136.5 + EOF + + - name: Add the zone using the custom policy + run: | + cascade zone add --policy custom --source $PWD/example.test.zone example.test + + - name: Check zone status + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone status example.test | grep -q "Published zone available"; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example.test + echo "timeout: zone status did not report published zone available" >&2 + exit 1 + fi + sleep 1 + done + + - name: Check the SOA SERIAL at the NSD secondary + run: | + timeout=10 # seconds + start=$(date +%s) + until dig +short @127.0.0.1 -p 1054 example.test SOA | grep -q 'ns.example.test. mail.example.test. 1 60 60 3600 5'; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example.test + dig +short @127.0.0.1 -p 1054 example.test SOA + echo "::error:: timeout: NSD did not acquire the zone changes" + exit 1 + fi + sleep 1 + done + + - name: Edit the source zone and tell Cascade to reload it. + run: | + tee example.test.zone <<'EOF' + example.test. IN SOA ns.example.test. mail.example.test. ( + 2 60 60 3600 5) + IN NS NS.EXAMPLE.TEST. + NS.EXAMPLE.TEST. IN A 133.69.136.1 + JAIN-BB.EXAMPLE.TEST. IN A 133.69.136.4 + IN A 192.41.197.2 + EOF + cascade zone reload example.test + + - name: Check zone status + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone status example.test | grep -q "Published zone available"; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example.test + echo "timeout: zone status did not report published zone available" >&2 + exit 1 + fi + sleep 1 + done + + - name: Check the SOA SERIAL at the NSD secondary + run: | + timeout=10 # seconds + start=$(date +%s) + until dig +short @127.0.0.1 -p 1054 example.test SOA | grep -q 'ns.example.test. mail.example.test. 2 60 60 3600 5'; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example.test + dig +short @127.0.0.1 -p 1054 example.test SOA + echo "::error:: timeout: NSD did not acquire the zone changes" + exit 1 + fi + sleep 1 + done + + - name: Edit the source zone and tell Cascade to reload it. + run: | + tee example.test.zone <<'EOF' + EXAMPLE.TEST. IN SOA ns.example.test. mail.example.test. ( + 3 60 60 3600 5) + IN NS NS.EXAMPLE.TEST. + NS.EXAMPLE.TEST. IN A 133.69.136.1 + JAIN-BB.EXAMPLE.TEST. IN A 133.69.136.3 + IN A 192.41.197.2 + EOF + cascade zone reload example.test + + - name: Check zone status + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone status example.test | grep -q "Published zone available"; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example.test + echo "timeout: zone status did not report published zone available" >&2 + exit 1 + fi + sleep 1 + done + + - name: Check the SOA SERIAL at the NSD secondary + run: | + timeout=10 # seconds + start=$(date +%s) + until dig +short @127.0.0.1 -p 1054 example.test SOA | grep -q 'ns.example.test. mail.example.test. 3 60 60 3600 5'; do + if (($(date +%s) > (start + timeout))); then + cascade zone status example.test + dig +short @127.0.0.1 -p 1054 example.test SOA + echo "::error:: timeout: NSD did not acquire the zone changes" + exit 1 + fi + sleep 1 + done + + # Note: There is no NSD metric that I am aware of that we can use to + # verify that it received IXFR from Cascade instead of AXFR, nor is there + # any Cascade Prometheus metric or CLI command that we can use to check + # this either. Increasing the NSD log level to 9 doesn't cause it to + # report whether it received IXFR or AXFR either. + + # TODO: Verify the AXFR and IXFR response content is as expected / + # matching that of NSD. + + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test AXFR > cascade-axfr.log + + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=1 >cascade-ixfr-1.log + + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=2 >cascade-ixfr-2.log + + - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 example.test -t IXFR=3 >cascade-ixfr-3.log + + - run: dig +onesoa +noall +answer @127.0.0.1 -p 1054 example.test AXFR >nsd-axfr.log + + - name: Print log files on any failure in this job + uses: ./.github/actions/print-logfiles + if: failure() diff --git a/integration-tests/tests/persist-zone/action.yml b/integration-tests/tests/persist-zone/action.yml index e92649cd1..997edef50 100644 --- a/integration-tests/tests/persist-zone/action.yml +++ b/integration-tests/tests/persist-zone/action.yml @@ -27,6 +27,10 @@ runs: with: log-level: ${{ inputs.log-level }} + # ------------------------------------------------------------------------ + # 1. Add an upstream NSD source zone and sign it. + # ------------------------------------------------------------------------ + - name: Add a zone served by the NSD primary run: | cascade zone add --policy default --source 127.0.0.1:1055 example.test @@ -54,10 +58,18 @@ runs: sleep 1 done - - name: Save the signed zone + - name: Write the signed zone to a file so we can compare it later. run: | dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.signed.log + # ------------------------------------------------------------------------ + # 2. Stop and restart Cascade so we can test that it: + # - Restores the signed zone correctly from persisted data on disk. + # - Does NOT re-sign the already signed zone, i.e. an AXFR of the + # published zone should be identical to the AXFR of the published + # zone that we saved to disk in the step above. + # ------------------------------------------------------------------------ + - name: Stop Cascade run: | pkill cascaded @@ -123,11 +135,19 @@ runs: sleep 1 done - - name: Check that Cascade did NOT resign the zone + - name: Check that Cascade did NOT re-sign the zone run: | dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.signed2.log diff -u example.test.signed.log example.test.signed2.log + # ------------------------------------------------------------------------ + # 3. Stop and restart Cascade again so we can test that it: + # - If persisted state files are deleted, does Cascade handle this + # correctly? It should attempt to load the persisted data files (as + # has paths to them in its main state file), find that they are + # missing and then ignore them and fully re-sign the zone. + # ------------------------------------------------------------------------ + - name: Stop Cascade again run: | pkill cascaded @@ -198,17 +218,26 @@ runs: sleep 1 done - - name: Save the signed zone + - name: Save the signed zone for later comparison run: | dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.signed3.log - - name: Check that Cascade DID resign the zone + - name: Check that Cascade DID resign the zone, not just bump the serial number run: | if diff -u example.test.signed2.log example.test.signed3.log > unexpected-diff.log; then echo "::error:: Cascade should have re-signed the zone" exit 1 fi + # ------------------------------------------------------------------------ + # 4. Update the zone at the source and tell NSD to reload it. + # This should cause Cascade to generate, persist and serve an IXFR + # diff for serial 01 -> 02. Note: it cannot serve the diff from + # 00 -> 01 because that diff was lost and cannot be recreated because + # a full zone signing needed to be done again and could not re-use the + # 00 serial number. + # ------------------------------------------------------------------------ + - name: Edit the source zone and tell NSD to reload it. run: | ZONE_DIR=$(integration-tests/scripts/get-default-path.sh test-env:nameserver-base-dir) @@ -253,7 +282,259 @@ runs: # Cascade can only answer SOA and XFR queries at the moment so we # cannot query it for the A record we are interested in. dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.final.log - cat example.test.final.log | grep -Fq www.example.test | grep -Fq "192.168.0.1" + grep -F www.example.test example.test.final.log | grep -Fq "192.168.0.1" + + - name: Dump IXFR ${{ steps.expected-serial.outputs.serial }} + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + let CLIENT_SERIAL=$(( EXPECTED_SERIAL + 0 )) + OUT_FILE="example.test.ixfr.from-${CLIENT_SERIAL}.log" + dig +noall +answer @127.0.0.1 -p 4542 -t ixfr=${CLIENT_SERIAL} example.test > "${OUT_FILE}" + + + # ------------------------------------------------------------------------ + # 5. Update the zone at the source and tell NSD to reload it again. + # This should create a second IXFR diff. + # ------------------------------------------------------------------------ + + - name: Edit the source zone and tell NSD to reload it. + run: | + ZONE_DIR=$(integration-tests/scripts/get-default-path.sh test-env:nameserver-base-dir) + ZONE_FILE="${ZONE_DIR}/nsd-primary/zones/example.test.primary-zone" + perl -pi -e 's/12345 ; serial/45678 ; serial/' ${ZONE_FILE} + perl -pi -e 's/www A 192.168.0.1/www A 192.168.0.2/' ${ZONE_FILE} + ./integration-tests/scripts/manage-test-environment.sh control nsd-primary reload example.test + + - name: Wait for Cascade to load the changed zone + run: | + timeout=10 # seconds + start=$(date +%s) + + until dig +noall +answer @127.0.0.1 -p 4540 example.test SOA | grep -q 45678 ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA SERIAL 12345" + dig +noall +answer @127.0.0.1 -p 4540 example.test SOA + exit 1 + fi + sleep 1 + done + + - name: Wait for Cascade to serve the re-signed zone with a newer serial number + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + timeout=10 # seconds + start=$(date +%s) + let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 3 )) + + until dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR | grep -Fq "${EXPECTED_SERIAL}" ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR + exit 1 + fi + sleep 1 + done + + - name: Verify that the modified A record is served in the published zone + run: | + # Cascade can only answer SOA and XFR queries at the moment so we + # cannot query it for the A record we are interested in. + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.final.log + grep -F www.example.test example.test.final.log | grep -Fq "192.168.0.2" + + # Note: Cascade cannot serve the diff from 00 -> 01 because that diff was + # lost and cannot be recreated because a full zone signing needed to be + # done again. This creates only a snapshot of the zone, not a diff against + # a previous signed zone. + - name: "Verify that IXFR for 00 -> 01 is NOT available" + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + # Attempting to get the diff from 00 -> 01 should return an AXFR + # (denoted by a single leading SOA and a single trailing SOA) + let CLIENT_SERIAL=$(( EXPECTED_SERIAL + 0 )) + OUT_FILE="example.test.ixfr.from-${CLIENT_SERIAL}.log" + dig +noall +answer @127.0.0.1 -p 4542 -t ixfr=${CLIENT_SERIAL} example.test > "${OUT_FILE}" + + NUM_SOA=$(grep -E 'example.test.\s+5\s+IN\s+SOA\s+ns1.example.test.\smail.example.test.\s2026050703\s60\s60\s3600\s5' "${OUT_FILE}" | wc -l) + if [[ ${NUM_SOA} -ne 2 ]]; then + echo "::error:: Expected 2 SOA RRs but got ${NUM_SOA} RRs." + exit 1 + fi + + - name: "Verify that IXFR for 01 -> 03 is available" + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + let CLIENT_SERIAL=$(( EXPECTED_SERIAL + 1 )) + OUT_FILE="example.test.ixfr.from-${CLIENT_SERIAL}.log" + EXPECTED_OUT_FILE="expected.out" + + let SER_1=$(( EXPECTED_SERIAL + 1)) + let SER_2=$(( EXPECTED_SERIAL + 2)) + let SER_3=$(( EXPECTED_SERIAL + 3)) + + cat >"${EXPECTED_OUT_FILE}" < "${OUT_FILE}" + sed -e 's/\s\+/ /g' "${OUT_FILE}" | grep -F '5 IN SOA' > diff.in + diff -u diff.in "${EXPECTED_OUT_FILE}" + + - name: "Verify that IXFR for 02 -> 03 is available" + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + let CLIENT_SERIAL=$(( EXPECTED_SERIAL + 2 )) + OUT_FILE="example.test.ixfr.from-${CLIENT_SERIAL}.log" + EXPECTED_OUT_FILE="expected.out" + + let SER_2=$(( EXPECTED_SERIAL + 2)) + let SER_3=$(( EXPECTED_SERIAL + 3)) + + cat >"${EXPECTED_OUT_FILE}" < "${OUT_FILE}" + sed -e 's/\s\+/ /g' "${OUT_FILE}" | grep -F '5 IN SOA' > diff.in + diff -u diff.in "${EXPECTED_OUT_FILE}" + + - name: Write the signed zone to a file so we can compare it later. + run: | + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.signed4.log + + # ------------------------------------------------------------------------ + # 6. Stop and restart Cascade again so we can test that it still serves + # the same IXFRs as before. + # ------------------------------------------------------------------------ + + - name: Stop Cascade again + run: | + pkill cascaded + + - name: Wait for Cascade to exit again + run: | + timeout=10 # seconds + start=$(date +%s) + until ! cascade health; do + if (($(date +%s) > (start + timeout))); then + cascade health + echo "::error:: timeout: health check did not indicate Cascade had stopped" + exit 1 + fi + sleep 1 + done + + - name: Start Cascade a 4th time + run: | + CASCADE_DIR=${{ github.workspace }}/cascade-dir + cascaded --config "${CASCADE_DIR}/config.toml" --state "${CASCADE_DIR}/state.db" --daemonize &>"${CASCADE_DIR}/cascade-startup.log" + + - name: Wait for Cascade to become healthy a 4th time + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade health; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: health check did not indicate Cascade had started" + exit 1 + fi + sleep 1 + done + + - name: Check that Cascade still knows the zone + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone list | grep -q example.test; do + if (($(date +%s) > (start + timeout))); then + cascade zone list + echo "::error:: timeout: Cascade no longer knows the zone" + exit 1 + fi + sleep 1 + done + + - name: Wait for Cascade to serve the re-signed zone with a newer serial number + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + timeout=10 # seconds + start=$(date +%s) + let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 3 )) + + until dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR | grep -q "${EXPECTED_SERIAL}" ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR + exit 1 + fi + sleep 1 + done + + - name: Check that Cascade did NOT re-sign the zone + run: | + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.signed5.log + diff -u example.test.signed4.log example.test.signed5.log + + - name: "Verify again that IXFR for 01 -> 03 is available" + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + let CLIENT_SERIAL=$(( EXPECTED_SERIAL + 1 )) + OUT_FILE="example.test.ixfr.from-${CLIENT_SERIAL}.log" + EXPECTED_OUT_FILE="expected.out" + + let SER_1=$(( EXPECTED_SERIAL + 1)) + let SER_2=$(( EXPECTED_SERIAL + 2)) + let SER_3=$(( EXPECTED_SERIAL + 3)) + + cat >"${EXPECTED_OUT_FILE}" < "${OUT_FILE}" + sed -e 's/\s\+/ /g' "${OUT_FILE}" | grep -F '5 IN SOA' > diff.in + diff -u diff.in "${EXPECTED_OUT_FILE}" + + - name: "Verify again that IXFR for 02 -> 03 is available" + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + let CLIENT_SERIAL=$(( EXPECTED_SERIAL + 2 )) + OUT_FILE="example.test.ixfr.from-${CLIENT_SERIAL}.log" + EXPECTED_OUT_FILE="expected.out" + + let SER_2=$(( EXPECTED_SERIAL + 2)) + let SER_3=$(( EXPECTED_SERIAL + 3)) + + cat >"${EXPECTED_OUT_FILE}" < "${OUT_FILE}" + sed -e 's/\s\+/ /g' "${OUT_FILE}" | grep -F '5 IN SOA' > diff.in + diff -u diff.in "${EXPECTED_OUT_FILE}" - name: Print log files on any failure in this job uses: ./.github/actions/print-logfiles diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 4fd9a9a8f..8327fe996 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -12,7 +12,7 @@ use cascade_zonedata::{ }; use domain::new::base::wire::{BuildBytes, TruncationError}; -use tracing::debug; +use tracing::trace; use crate::{ center::Center, @@ -68,6 +68,8 @@ pub fn persist_signed( persister: SignedZonePersister, ) -> SignedZonePersisted { if !persister.signed_diff().is_empty() { + let signed_diff = persister.signed_diff(); + // Determine the path to write to and update the record of written // paths here as we don't want to give responsibility for working // with ZoneState to the persistence crate. Accumulate a set of @@ -86,16 +88,26 @@ pub fn persist_signed( .config .zone_state_dir .join(format!("{}.signed.{next_idx}", zone.name)); - persist_to_file(destination.as_std_path(), persister.signed_diff().clone()); + + // Write the diff to disk as a binary AXFR snapshot or binary IXFR + // diff. + persist_to_file(destination.as_std_path(), signed_diff.clone()); handle.state.persisted_signed_diffs.push(destination.into()); handle.zone.mark_dirty(handle.state, handle.center); + + // If this is a diff rather than a snapshort, Store the diff in-memory + // for serving via IXFR. A diff has a removed SOA. + if signed_diff.removed_soa.is_some() { + assert_ne!(signed_diff.removed_soa, signed_diff.added_soa); + state.storage.diffs.push(signed_diff.clone()); + } } persister.mark_complete() } //------------ persist_to_file() ---------------------------------------------- -fn persist_to_file(destination: &Path, loaded_diff: Arc) { +fn persist_to_file(destination: &Path, diff: Arc) { // Write the diff in AXFR / IXFR wire format to disk. let f = File::create_new(destination).unwrap_or_else(|err| { panic!( @@ -143,7 +155,7 @@ fn persist_to_file(destination: &Path, loaded_diff: Arc) { writer.write_all(&buf[0..num_bytes_to_write]).unwrap(); } - let added_soa = loaded_diff.added_soa.clone().unwrap(); + let added_soa = diff.added_soa.clone().unwrap(); // IXFR format has the form: // - New SOA @@ -163,11 +175,11 @@ fn persist_to_file(destination: &Path, loaded_diff: Arc) { write_rr(&mut buf, &added_soa, &mut f); // Start deleted records block by writing the old SOA, if any. - if let Some(removed_soa) = &loaded_diff.removed_soa { + if let Some(removed_soa) = &diff.removed_soa { write_rr(&mut buf, removed_soa, &mut f); // Write the deleted records. - for r in &loaded_diff.removed_records { + for r in &diff.removed_records { write_rr(&mut buf, r, &mut f); } @@ -176,23 +188,25 @@ fn persist_to_file(destination: &Path, loaded_diff: Arc) { } // Write the added records. - for r in &loaded_diff.added_records { + for r in &diff.added_records { write_rr(&mut buf, r, &mut f); } // Finish the AXFR/IXFR by writing the new SOA again write_rr(&mut buf, &added_soa, &mut f); - debug!( - "Persisted zone to file '{}': {} records removed, {} records added", + trace!( + "Persisted zone to file '{}': SOA {:?} -> {:?}: {} records removed, {} records added", destination.display(), - if !loaded_diff.removed_records.is_empty() { - loaded_diff.removed_records.len() + 1 + diff.removed_soa.as_ref().map(|v| v.rdata.serial), + diff.added_soa.as_ref().map(|v| v.rdata.serial), + if !diff.removed_records.is_empty() { + diff.removed_records.len() + 1 } else { 0 }, - if !loaded_diff.added_records.is_empty() { - loaded_diff.added_records.len() + 1 + if !diff.added_records.is_empty() { + diff.added_records.len() + 1 } else { 0 }, diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 97b3daf85..eeae8c083 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -14,7 +14,7 @@ use cascade_zonedata::{ use domain::{ new::{ base::{ - RType, Record, + RType, Record, Serial, name::{NameBuf, RevNameBuf}, parse::{ParseMessageBytes, SplitMessageBytes}, }, @@ -22,6 +22,7 @@ use domain::{ }, utils::dst::UnsizedCopy, }; +use tracing::trace; use crate::{center::Center, zone::Zone}; @@ -54,29 +55,54 @@ pub fn restore_loaded( let (soa, records) = load_axfr_wire_dump(loaded_source.as_std_path(), &mut buf).map_err(|err| { io::Error::other(format!( - "Failed to load persisted snapshot for zone '{}' from '{loaded_source}': {err}", + "Failed to load persisted loaded snapshot for zone '{}' from '{loaded_source}': {err}", zone.name )) })?; let mut loaded_replacer = restorer.fill().ok_or(io::Error::other(format!("Unable to restore persisted snapshot for zone '{}' from '{loaded_source}': Could not acquire replacer", zone.name)))?; - loaded_replacer.set_soa(soa).unwrap(); + loaded_replacer.set_soa(soa.clone()).unwrap(); loaded_replacer.set_records(records).unwrap(); loaded_replacer.apply().unwrap(); + trace!( + "Restored loaded snapshot for SOA serial {} for zone '{}' from file '{loaded_source}'", + soa.rdata.serial, zone.name + ); if count > 1 { - let mut loaded_patcher = restorer.patch().unwrap(); // TODO: SAFETY + let mut loaded_patcher = restorer.patch().ok_or(io::Error::other(format!( + "Failed to load persisted signed diff for zone '{}' from '{loaded_source}'", + zone.name + )))?; let mut source = loaded_source.to_path_buf(); + let mut all_serials = vec![]; for i in 1..count { source.set_extension(i.to_string()); let loaded_patcher = &mut loaded_patcher; - load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { - apply_ixfr_event_to_loaded_data(loaded_patcher, event); + let (start_serial, end_serial) = load_ixfr_wire_dump( + source.as_std_path(), + &mut buf, + |event| { + apply_ixfr_event_to_loaded_data(loaded_patcher, event); + } + ).map_err(|err| { + io::Error::other(format!( + "Failed to load persisted loaded diff for zone '{}' from '{loaded_source}': {err}", + zone.name + )) })?; - loaded_patcher.next_patchset().map_err(|err| io::Error::other(format!("Unable to restore persisted diff {i} for zone '{}' from '{source}': Could not move to next patch set: {err}", zone.name)))?; + loaded_patcher.next_patchset().map_err(|err| io::Error::other(format!("Unable to restore loaded persisted diff {i} for zone '{}' from '{source}': Could not move to next patch set: {err}", zone.name)))?; + + let start_serial: u32 = start_serial.into(); + let end_serial: u32 = end_serial.into(); + all_serials.push((start_serial, end_serial)); } - loaded_patcher.apply().map_err(|err| io::Error::other(format!("Unable to restore persisted diffs for zone '{}': Could apply the collected changes: {err}", zone.name)))?; + loaded_patcher.apply().map_err(|err| io::Error::other(format!("Unable to restore persisted loaded diffs for zone '{}': Could apply the collected changes: {err}", zone.name)))?; + trace!( + "Restored loaded diff for SOA serial {} for zone '{}' from file '{loaded_source}' with diff serials: {all_serials:?}", + soa.rdata.serial, zone.name + ); } } @@ -94,7 +120,7 @@ pub fn restore_signed( center: &Arc
, restorer: &mut SignedZoneRestorer, ) -> io::Result<()> { - let state = zone.state.lock().unwrap(); + let mut state = zone.state.lock().unwrap(); if !state.persisted_loaded_diffs.is_empty() { // Determine the paths to read from. Each zone is persisted as an AXFR // plus zero or more IXFRs. The restorer takes a base path ending in @@ -108,36 +134,64 @@ pub fn restore_signed( let count = state.persisted_signed_diffs.len(); let mut buf = Vec::::new(); - // Extract the initial unsigned integer number file extension. - let n = signed_source - .extension() - .unwrap() - .to_string() - .parse::() - .unwrap(); - // Process the initial "signed" AXFR wire format dump. - let (soa, records) = load_axfr_wire_dump(signed_source.as_std_path(), &mut buf).unwrap(); - let mut signed_replacer = restorer.fill().unwrap(); // TODO: SAFETY - signed_replacer.set_soa(soa).unwrap(); + let (soa, records) = load_axfr_wire_dump(signed_source.as_std_path(), &mut buf).map_err(|err| { + io::Error::other(format!( + "Failed to load persisted signed snapshot for zone '{}' from '{signed_source}': {err}", + zone.name + )) + })?; + let mut signed_replacer = restorer.fill().ok_or(io::Error::other(format!("Unable to restore persisted signed snapshot for zone '{}' from '{signed_source}': Could not acquire replacer", zone.name)))?; + signed_replacer.set_soa(soa.clone()).unwrap(); signed_replacer.set_records(records).unwrap(); signed_replacer.apply().unwrap(); + trace!( + "Restored signed snapshot for SOA serial {} for zone '{}' from file '{signed_source}'", + soa.rdata.serial, zone.name + ); // Process zero or more "signed" IXFR wire format dumps. if count > 1 { - let mut signed_patcher = restorer.patch().unwrap(); // TODO: SAFETY let mut source = signed_source.to_path_buf(); + let mut all_serials = vec![]; for i in 1..count { - source.set_extension((n + i).to_string()); + let mut signed_patcher = restorer.patch().ok_or(io::Error::other(format!( + "Failed to load persisted signed diff for zone '{}' from '{signed_source}'", + zone.name + )))?; + source.set_extension(i.to_string()); + + let (start_serial, end_serial) = + load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { + apply_ixfr_event_to_signed_data(&mut signed_patcher, event); + }) + .map_err(|err| { + io::Error::other(format!( + "Failed to load persisted signed diff for zone '{}' from '{signed_source}': {err}", + zone.name + )) + })?; + + signed_patcher.next_patchset().map_err(|err| io::Error::other(format!("Unable to restore persisted signed diff {i} for zone '{}' from '{source}': Could not move to next patch set: {err}", zone.name)))?; - load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { - apply_ixfr_event_to_signed_data(&mut signed_patcher, event); - }) - .unwrap(); + signed_patcher.apply().map_err(|err| io::Error::other(format!("Unable to restore persisted signed diffs for zone '{}': Could apply the collected changes: {err}", zone.name)))?; - signed_patcher.next_patchset().unwrap() + if let Some(diff) = restorer.take_diff() { + state.storage.diffs.push(diff.into()); + trace!( + "Stored IXFR diff for SOA serial {} for zone '{}' from file '{signed_source}': serial {start_serial} -> {end_serial}", + soa.rdata.serial, zone.name + ); + } + + let start_serial: u32 = start_serial.into(); + let end_serial: u32 = end_serial.into(); + all_serials.push((start_serial, end_serial)); } - signed_patcher.apply().unwrap(); + trace!( + "Restored signed diff for SOA serial {} for zone '{}' from file '{signed_source}' with diff serials: {all_serials:?}", + soa.rdata.serial, zone.name + ); } } io::Result::Ok(()) @@ -234,7 +288,11 @@ fn load_axfr_wire_dump( Ok((start_soa, records)) } -fn load_ixfr_wire_dump(source: &Path, buf: &mut Vec, mut rr_handler: F) -> io::Result<()> +fn load_ixfr_wire_dump( + source: &Path, + buf: &mut Vec, + mut rr_handler: F, +) -> io::Result<(Serial, Serial)> where F: FnMut(IxfrEvent), { @@ -245,6 +303,8 @@ where io::Error::other(format!("Failed to parse persisted diff initial SOA: {err}")) })?; + let mut oldest_soa = None; + // Parse one or more diff sequences. loop { // Parse one diff sequence: @@ -273,6 +333,11 @@ where ))); } + if oldest_soa.is_none() { + let soa = Soa::::parse_message_bytes(r.rdata.bytes(), 0).unwrap(); + oldest_soa = Some(soa); + } + let r = RegularRecord(r.transform(|name| name.unsized_copy_into(), |data| data)); rr_handler(IxfrEvent::Remove(r)); @@ -304,11 +369,12 @@ where )) })?; - let r = RegularRecord(r.transform(|name| name.unsized_copy_into(), |data| data)); + let r = + RegularRecord(r.transform(|name| name.unsized_copy_into(), |data| data.clone())); if r.rtype == RType::SOA { if is_same_soa(&start_soa, &start_soa_rdata, &r) { // This SOA signals the end of the IXFR dump. - return Ok(()); + return Ok((oldest_soa.unwrap().serial, start_soa.rdata.serial)); } rr_handler(IxfrEvent::EndOfUpdate); diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index d5383c50d..aaf49a411 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use cascade_zonedata::{LoadedZonePersister, LoadedZoneRestorer, SignedZonePersister}; -use tracing::{debug, info, trace, trace_span}; +use tracing::{debug, info, trace, trace_span, warn}; use crate::{ center::Center, @@ -62,9 +62,11 @@ impl ZonePersistenceHandle<'_> { "'restore_loaded()' always completes restoration on successful return" ) }), - Err(_) => { - trace!("Abandoning loaded restoration"); + Err(err) => { + warn!("Abandoning loaded restoration: {err}"); let mut state = zone.state.lock().unwrap(); + state.persisted_loaded_diffs.clear(); + state.persisted_signed_diffs.clear(); let mut handle = ZoneHandle { zone: &zone, state: &mut state, @@ -97,6 +99,8 @@ impl ZonePersistenceHandle<'_> { Err(_) => { trace!("Abandoning signed restoration"); let mut state = zone.state.lock().unwrap(); + state.persisted_loaded_diffs.clear(); + state.persisted_signed_diffs.clear(); let mut handle = ZoneHandle { zone: &zone, state: &mut state, @@ -110,6 +114,7 @@ impl ZonePersistenceHandle<'_> { info!("Restored the zone's persisted data"); let mut state = zone.state.lock().unwrap(); + trace!("Restored diffs: {:?}", state.persisted_loaded_diffs); let mut handle = ZoneHandle { zone: &zone, state: &mut state, diff --git a/src/server/request.rs b/src/server/request.rs index 44c4a248a..078026d8a 100644 --- a/src/server/request.rs +++ b/src/server/request.rs @@ -139,7 +139,6 @@ pub enum ZoneRequestKind { Axfr, /// An IXFR request. - #[expect(dead_code)] Ixfr { /// The SOA record known to the client. known_soa: Record<(), Soa>>, diff --git a/src/server/service.rs b/src/server/service.rs index 89a618421..8582ace00 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -79,10 +79,14 @@ mod compat { message::Request, service::{CallResult, Service, ServiceResult}, }, - new::base::wire::ParseBytesZC, + new::{ + base::{name::Name, wire::ParseBytesZC}, + rdata::Soa, + }, tsig, }; use futures::Stream; + use tracing::trace; use crate::server::request::{RequestKind, ZoneRequestKind}; @@ -149,10 +153,9 @@ mod compat { } // TODO: Support IXFR. - ZoneRequestKind::Ixfr { .. } => Box::pin(std::future::ready(error( - old_request.message(), - Rcode::NOTIMP, - ))), + ZoneRequestKind::Ixfr { known_soa } => { + Box::pin(ixfr(old_request, known_soa.rdata, zone.clone())) as Response + } } } } @@ -294,6 +297,151 @@ mod compat { Box::new(stream) as _ } + async fn ixfr( + request: Request, Option>>, + client_soa: Soa>, + zone: ServedZone, + ) -> ResponseStream { + // Refuse IXFR requests over UDP. + if request.transport_ctx().is_udp() { + tracing::warn!("Reject IXFR over UDP"); + return error(request.message(), Rcode::NOTIMP); + } + + // Save a cheap clone of the zone to avoid a borrow checker error. + let zone_clone = zone.clone(); + + // Obtain a read lock to read the zone for an extended duration. + let viewer = zone.viewer.read_owned().await; + + if viewer.is_empty() { + // The zone is known to exist, but we don't have any data for it. + return error(request.message(), Rcode::NOTAUTH); + } + + // Remember the latest SOA. + let new_soa = viewer.soa().clone(); + + // https://datatracker.ietf.org/doc/html/rfc1995#section-4 + // 4. Response Format + // "If incremental zone transfer is not available, the entire zone + // is returned. The first and the last RR of the response is the + // SOA record of the zone. I.e. the behavior is the same as an + // AXFR response except the query type is IXFR." + + // https://datatracker.ietf.org/doc/html/rfc1995#section-2 + // 2. Brief Description of the Protocol + // "If an IXFR query with the same or newer version number than that + // of the server is received, it is replied to with a single SOA + // record of the server's current version, just as in AXFR." + // ^^^^^^^^^^^^^^^ + // Errata https://www.rfc-editor.org/errata/eid3196 points out that + // this is NOT "just as in AXFR" as AXFR does not do that. + let our_soa_serial = { viewer.soa().rdata.serial }; + + if client_soa.serial >= our_soa_serial { + trace!("Responding to IXFR with single SOA because query serial >= zone serial"); + return soa(request.message(), &*viewer); + } + + let diffs = zone.handle.state.lock().unwrap().storage.diffs.clone(); + + // TODO: Add something like the Bind `max-ixfr-ratio` option that + // "sets the size threshold (expressed as a percentage of the size of + // the full zone) beyond which named chooses to use an AXFR response + // rather than IXFR when answering zone transfer requests"? + + // Note: Unlike RFC 5936 for AXFR, neither RFC 1995 nor RFC 9103 say + // anything about whether an IXFR response can consist of more than + // one response message, but given the 2^16 byte maximum response + // size of a TCP DNS message and the 2^16 maximum number of ANSWER + // RRs allowed per DNS response, large zones may not fit in a single + // response message and will have to be split into multiple response + // messages. + + // Currently we have only a single diff available. + for (i, d) in diffs.iter().enumerate() { + tracing::info!( + "Diff {i} for zone '{}': serial {} => serial {}", + zone.handle.name, + d.removed_soa.as_ref().unwrap().0.rdata.serial, + d.added_soa.as_ref().unwrap().0.rdata.serial, + ); + } + + // Find the diff, if we have it, that removes the SOA serial number + // that the client currently has. That will be the start of the diff + // that we need to serve. + let start_idx = diffs.iter().position(|d| { + d.removed_soa.as_ref().map(|rr| rr.0.rdata.serial) == Some(client_soa.serial) + }); + + let Some(start_idx) = start_idx else { + tracing::trace!( + "Falling back from IXFR to AXFR because no diff is available for zone '{}' from serial {}", + zone.handle.name, + client_soa.serial, + ); + return axfr(request, zone_clone).await; + }; + + let (tx, mut rx) = tokio::sync::mpsc::channel(1024); + + // Stream the records in the background. + tokio::task::spawn(async move { + // Collect the sequence of IXFR output records.. + let mut rrs = vec![new_soa.clone().into()]; + for diff in &diffs[start_idx..] { + rrs.push(diff.removed_soa.clone().unwrap().into()); + rrs.extend(diff.removed_records.clone()); + rrs.push(diff.added_soa.clone().unwrap().into()); + rrs.extend(diff.added_records.clone()); + } + rrs.push(new_soa.into()); + + // Divide the records into DNS messages. + let mut rr_iter = rrs.into_iter().peekable(); + let mut max_message_size = u16::MAX; // TCP + max_message_size -= request.num_reserved_bytes(); + let messages = std::iter::from_fn(move || { + rr_iter.peek()?; + + let mut builder = MessageBuilder::new_stream_vec(); + builder.set_push_limit(max_message_size as usize); + let mut builder = builder + .start_answer(request.message(), Rcode::NOERROR) + .unwrap(); + builder.header_mut().set_aa(true); + + while let Some(record) = rr_iter.peek() { + match builder.push(OldRecord::from(record.clone())) { + // On success, consume the record. + Ok(()) => { + let _ = rr_iter.next(); + } + + // Once the message runs out of space, stop. + Err(_) => break, + } + } + + let response = builder.additional(); + Some(CallResult::new(response)) + }); + + for message in messages { + if tx.send(message).await.is_err() { + // The channel has closed; stop. + break; + } + } + }); + + let stream = futures::stream::poll_fn(move |cx| rx.poll_recv(cx).map(|m| m.map(Ok))); + + Box::new(stream) as _ + } + fn error(request: &Message>, rcode: Rcode) -> ResponseStream { let response = MessageBuilder::new_stream_vec() .start_error(request, rcode) diff --git a/src/signer/incremental.rs b/src/signer/incremental.rs index 57cc4cf48..2844df041 100644 --- a/src/signer/incremental.rs +++ b/src/signer/incremental.rs @@ -2211,18 +2211,18 @@ impl IncrementalSigningState { } /// Load the state varibles we need at the start and then update state at the end. -struct LocalState { - apex_remove: HashSet, - apex_extra: Vec, - last_signature_refresh: UnixTime, - key_tags: HashSet, - key_roll: Option, - previous_serial: Option, - next_min_expiration: Option, +pub struct LocalState { + pub apex_remove: HashSet, + pub apex_extra: Vec, + pub last_signature_refresh: UnixTime, + pub key_tags: HashSet, + pub key_roll: Option, + pub previous_serial: Option, + pub next_min_expiration: Option, } impl LocalState { - fn new(zone: &Arc) -> Result { + pub fn new(zone: &Arc) -> Result { let zone_state = zone .state .lock() @@ -2239,7 +2239,7 @@ impl LocalState { }) } - fn save(self, center: &Arc
, zone: &Arc) -> Result<(), SignerError> { + pub fn save(self, center: &Arc
, zone: &Arc) -> Result<(), SignerError> { let mut modified = false; let mut zone_state = zone diff --git a/src/units/zone_signer.rs b/src/units/zone_signer.rs index 63ee19a8a..dd0aabb6d 100644 --- a/src/units/zone_signer.rs +++ b/src/units/zone_signer.rs @@ -1,5 +1,5 @@ use std::cmp::{Ordering, min}; -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::env::{self, VarError}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, RwLock}; @@ -25,7 +25,7 @@ use domain::dnssec::sign::keys::SigningKey; use domain::dnssec::sign::keys::keyset::{KeySet, KeyType, UnixTime}; use domain::dnssec::sign::records::RecordsIter; use domain::dnssec::sign::signatures::rrsigs::{GenerateRrsigConfig, sign_sorted_zone_records}; -use domain::new::base::RType; +use domain::new::base::{RType, Serial as NewBaseSerial}; use domain::new::rdata::RecordData; use domain::rdata::dnssec::Timestamp; use domain::rdata::{Dnskey, Nsec3param, ZoneRecordData}; @@ -52,7 +52,7 @@ use crate::api::{ use crate::center::Center; use crate::manager::{Terminated, record_zone_event}; use crate::policy::{PolicyVersion, SignerDenialPolicy, SignerSerialPolicy}; -use crate::signer::incremental::sign_incrementally; +use crate::signer::incremental::{LocalState, sign_incrementally}; use crate::signer::{ResigningTrigger, SigningTrigger}; use crate::units::http_server::KmipServerState; use crate::units::key_manager::{ @@ -62,7 +62,7 @@ use crate::util::{ AbortOnDrop, serialize_duration_as_secs, serialize_instant_as_duration_secs, serialize_opt_duration_as_secs, }; -use crate::zone::{HistoricalEvent, HistoricalEventType, Zone, ZoneHandle}; +use crate::zone::{HistoricalEvent, Zone, ZoneHandle}; // Re-signing zones before signatures expire works as follows: // - compute when the first zone needs to be re-signed. Loop over unsigned @@ -331,16 +331,15 @@ impl ZoneSigner { info!("[ZS]: Starting signing operation for zone '{zone_name}'"); let start = Instant::now(); - let (last_signed_serial, policy) = { + let mut local_state = LocalState::new(zone)?; + + let policy = { // Use a block to make sure that the mutex is clearly dropped. let zone_state = zone.state.lock().unwrap(); - let last_signed_serial = zone_state - .find_last_event(HistoricalEventType::SigningSucceeded, None) - .and_then(|item| item.serial) - .map(|serial| Serial::from(serial.0)); - (last_signed_serial, zone_state.policy.clone().unwrap()) + zone_state.policy.clone().unwrap() }; + let previous_serial = local_state.previous_serial; // // Lookup the zone to sign. @@ -353,9 +352,10 @@ impl ZoneSigner { .expect("a non-empty loaded instance must exist"); let loaded_serial = loaded.soa().rdata.serial; - let serial = match policy.signer.serial_policy { + let serial: Serial = match policy.signer.serial_policy { SignerSerialPolicy::Keep => { - if let Some(previous_serial) = last_signed_serial + let loaded_serial = Serial::from(Into::::into(loaded_serial)); + if let Some(previous_serial) = previous_serial && loaded_serial <= previous_serial { return Err(SignerError::KeepSerialPolicyViolated); @@ -364,26 +364,21 @@ impl ZoneSigner { loaded_serial } SignerSerialPolicy::Counter => { - // Select the maximum of 'last_signed_serial + 1' and - // 'loaded_serial'. - // - // TODO: This is a partial workaround to help users starting - // out with counter mode. For ongoing discussion, see - // . - let mut serial = loaded_serial; - if let Some(previous_serial) = last_signed_serial - && serial <= previous_serial - { - serial = previous_serial.inc(1); - } - serial + // Always increment the serial number, ignore the serial + // number in the unsigned zone. + let previous_serial = if let Some(serial) = previous_serial { + serial + } else { + Serial::from(0) + }; + previous_serial.add(1) } SignerSerialPolicy::UnixTime => { - let mut serial = Serial::unix_time(); - if let Some(previous_serial) = last_signed_serial + let mut serial = Serial::now(); + if let Some(previous_serial) = previous_serial && serial <= previous_serial { - serial = previous_serial.inc(1); + serial = previous_serial.add(1); } serial @@ -396,15 +391,17 @@ impl ZoneSigner { * 100; let mut serial: Serial = serial.into(); - if let Some(previous_serial) = last_signed_serial + if let Some(previous_serial) = previous_serial && serial <= previous_serial { - serial = previous_serial.inc(1); + serial = previous_serial.add(1); } serial } }; + local_state.previous_serial = Some(serial); + let serial = NewBaseSerial::from(serial.into_int()); let new_soa = { let mut soa = loaded.soa().clone(); soa.rdata.serial = serial; @@ -412,7 +409,7 @@ impl ZoneSigner { }; info!( - "[ZS]: Serials for zone '{zone_name}': last signed={last_signed_serial:?}, current={loaded_serial}, serial policy={}, new={serial}", + "[ZS]: Serials for zone '{zone_name}': last signed={previous_serial:?}, current={loaded_serial}, serial policy={}, new={serial}", policy.signer.serial_policy ); @@ -500,6 +497,25 @@ impl ZoneSigner { )); } + // Save the current zone signing keys and clear key_roll + let mut key_tags = HashSet::new(); + for v in state.keyset.keys().values() { + let signer = match v.keytype() { + KeyType::Ksk(_) => false, + KeyType::Zsk(key_state) => key_state.signer(), + KeyType::Csk(_, key_state) => key_state.signer(), + KeyType::Include(_) => false, + }; + + if !signer { + continue; + } + + key_tags.insert(v.key_tag()); + } + local_state.key_tags = key_tags; + local_state.key_roll = None; + // // Sort them into DNSSEC order ready for NSEC(3) generation. // @@ -711,21 +727,7 @@ impl ZoneSigner { min_expiration.add(u32::from(sig.expiration).into()); } - // Save the minimum of the expiration times. - { - // Use a block to make sure that the mutex is clearly dropped. - let mut zone_state = zone.state.lock().unwrap(); - - // Save as next_min_expiration. After the signed zone is approved - // this value should be move to min_expiration. - zone_state.next_min_expiration = saved_min_expiration.get(); - debug!( - "SIGNER: Determined min expiration time: {:?}", - zone_state.next_min_expiration - ); - - zone.mark_dirty(&mut zone_state, center); - } + local_state.next_min_expiration = saved_min_expiration.get(); let total_time = start.elapsed(); @@ -764,6 +766,9 @@ impl ZoneSigner { Some(domain::base::Serial(serial.into())), ); + local_state.last_signature_refresh = UnixTime::now(); + local_state.save(center, zone)?; + Ok(()) } @@ -1036,7 +1041,7 @@ pub struct InProgressStatus { } impl InProgressStatus { - fn new(requested_status: RequestedStatus, zone_serial: Serial) -> Self { + fn new(requested_status: RequestedStatus, zone_serial: NewBaseSerial) -> Self { Self { requested_at: requested_status.requested_at, zone_serial: domain::base::Serial(zone_serial.into()), @@ -1120,7 +1125,7 @@ impl ZoneSigningStatus { Self::Requested(RequestedStatus::new()) } - fn start(&mut self, zone_serial: Serial) -> Result<(), ()> { + fn start(&mut self, zone_serial: NewBaseSerial) -> Result<(), ()> { match *self { ZoneSigningStatus::Requested(s) => { *self = Self::InProgress(InProgressStatus::new(s, zone_serial)); diff --git a/src/zone/storage.rs b/src/zone/storage.rs index aa8013d51..a8538e5d1 100644 --- a/src/zone/storage.rs +++ b/src/zone/storage.rs @@ -26,7 +26,7 @@ use std::{fmt, sync::Arc}; use cascade_zonedata::{ - LoadedZoneBuilder, LoadedZoneBuilt, LoadedZonePersisted, LoadedZonePersister, + DiffData, LoadedZoneBuilder, LoadedZoneBuilt, LoadedZonePersisted, LoadedZonePersister, LoadedZoneRestored, LoadedZoneRestorer, LoadedZoneReviewer, SignedZoneBuilder, SignedZoneBuilt, SignedZonePersisted, SignedZonePersister, SignedZoneRestored, SignedZoneRestorer, SignedZoneReviewer, SoaRecord, ZoneCleaner, ZoneDataStorage, @@ -925,6 +925,9 @@ pub struct StorageState { // current i.e. published zone instance. pub published_loaded_soa: Option, + /// Diffs from one serial to another. + pub diffs: Vec>, + /// Ongoing background tasks. /// /// When the zone data needs to be cleaned or persisted, a background task @@ -944,6 +947,7 @@ impl StorageState { signed_review_soa: None, published_soa: None, published_loaded_soa: None, + diffs: Default::default(), background_tasks: Default::default(), } } From d399f1dbd4ebf1cdc6a172bdbf1c0b91f5b11b03 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 May 2026 20:22:23 +0200 Subject: [PATCH 27/81] Adjust test condition. As step.if doesn't seem to be honoured by nektos/act. --- integration-tests/tests/ixfr-out/action.yml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/integration-tests/tests/ixfr-out/action.yml b/integration-tests/tests/ixfr-out/action.yml index e120fb45c..270e6be6a 100644 --- a/integration-tests/tests/ixfr-out/action.yml +++ b/integration-tests/tests/ixfr-out/action.yml @@ -220,21 +220,19 @@ runs: # matching that of NSD. - name: Verify that TSIG is required by Cascade - if: ${{ inputs.with-tsig }} env: ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} run: | + if [[ "${{ inputs.with-tsig }}" == "false" ]]; then + exit 0 + fi + # This should not succeed as it lacks the required TSIG key. # Note that dig will exit with code 0 even if the request fails, # because it considers an error response still to be successful # receipt of a response, so we have to check the actual output. - dig @127.0.0.1 -p 4542 ${ZONE_NAME} AXFR &> dig-no-tsig.log - if grep -Fq 'Transfer failed' dig-no-tsig.log; then - exit 0 - else - echo "::error:: dig unexpectedly was able to AXFR the zone without a TSIG key" - exit 1 - fi + dig @127.0.0.1 +yaml -p 4542 ${ZONE_NAME} AXFR &> dig-no-tsig.log + grep -Fq "status: REFUSED" dig-no-tsig.log - name: Verify Cascade AXFR out env: @@ -244,7 +242,6 @@ runs: if [[ "${{ inputs.with-tsig }}" == "true" ]]; then EXTRA_ARGS="-y ${{ steps.set-vars.outputs.TSIG_SPEC }}" fi - echo "EXTRA_ARGS: ${EXTRA_ARGS}" dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${EXTRA_ARGS} ${ZONE_NAME} AXFR &> cascade-axfr.log TEST_DIR="${PWD}/integration-tests/ixfr-out" LC_COLLATE=C sort "${TEST_DIR}/reference-output/${ZONE_NAME}axfr" | egrep -v '^;|^$' > reference.output.sorted @@ -271,8 +268,6 @@ runs: - run: dig +onesoa +noall +answer @127.0.0.1 -p 1054 ${{ steps.set-vars.outputs.ZONE_NAME }} AXFR >nsd-axfr.log - - run: exit 1 - - name: Print log files on any failure in this job uses: ./.github/actions/print-logfiles if: failure() From cfd91bead084cb4f554bb8a3045026689eeac407 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 May 2026 22:40:08 +0200 Subject: [PATCH 28/81] Review feedback: signal upgrade to TCP on UDP IXFR request. Not via TCP but via single SOA response, per RFC 1995. Also adds a system test using dig. --- integration-tests/tests/ixfr-out/action.yml | 18 ++++++++++--- src/server/service.rs | 28 ++++++++++++++------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/integration-tests/tests/ixfr-out/action.yml b/integration-tests/tests/ixfr-out/action.yml index 270e6be6a..c06801133 100644 --- a/integration-tests/tests/ixfr-out/action.yml +++ b/integration-tests/tests/ixfr-out/action.yml @@ -216,9 +216,6 @@ runs: # this either. Increasing the NSD log level to 9 doesn't cause it to # report whether it received IXFR or AXFR either. - # TODO: Verify the AXFR and IXFR response content is as expected / - # matching that of NSD. - - name: Verify that TSIG is required by Cascade env: ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} @@ -248,6 +245,21 @@ runs: LC_COLLATE=C sort cascade-axfr.log > output.sorted diff -u -w output.sorted reference.output.sorted + # A UDP IXFR request should result in a single SOA response "to inform the + # client that a TCP query should be initiated". + - name: Verify that UDP is not supported + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} + run: | + EXTRA_ARGS= + if [[ "${{ inputs.with-tsig }}" == "true" ]]; then + EXTRA_ARGS="-y ${{ steps.set-vars.outputs.TSIG_SPEC }}" + fi + SOA_RR=$(dig +short +notcp +ignore @127.0.0.1 -p 4542 ${ZONE_NAME} -t IXFR=1 ${EXTRA_ARGS} 2>&1 | tee dig-udp-ixfr.log) + if [[ "${SOA_RR}" != "ns.${ZONE_NAME} mail.${ZONE_NAME} 3 60 60 3600 5" ]]; then + exit 1 + fi + - name: Verify Cascade IXFR serial 1..=3 out env: ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} diff --git a/src/server/service.rs b/src/server/service.rs index 5d5a1f46f..974343f64 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -344,12 +344,6 @@ mod compat { client_soa: Soa>, zone: ServedZone, ) -> ResponseStream { - // Refuse IXFR requests over UDP. - if request.transport_ctx().is_udp() { - tracing::warn!("Rejecting IXFR over UDP"); - return error(request.message(), Rcode::NOTIMP); - } - // Save a cheap clone of the zone to avoid a borrow checker error. let zone_clone = zone.clone(); @@ -361,6 +355,23 @@ mod compat { return error(request.message(), Rcode::NOTAUTH); } + // UDP is unlikely to work for any but the smallest of diffs, + // especially with a DNSSEC signed zone because even removal of a + // single A record can result in many RRs being changed due to the + // impact on the NSEC(3) chain, plus any change has to include a SOA + // SERIAL bump causing both the SOA RR and its RRSIG to also be in + // every IXFR diff. However, RFC 1995 says that "Transport of a query + // may be by either UDP or TCP" so we can't refuse UDP entirely. We + // can however return a single SOA record per RFC 1995 "to inform the + // client that a TCP query should be initiated". + if request.transport_ctx().is_udp() { + trace!( + "Signalling UDP IXR client at {} to retry by TCP", + request.client_addr().ip() + ); + return soa(request.message(), &*viewer); + } + // Remember the latest SOA. let new_soa = viewer.soa().clone(); @@ -430,10 +441,9 @@ mod compat { }); let Some(start_idx) = start_idx else { - tracing::trace!( + trace!( "Falling back from IXFR to AXFR because no diff is available for zone '{}' from serial {}", - zone.handle.name, - client_soa.serial, + zone.handle.name, client_soa.serial, ); return axfr(request, zone_clone).await; }; From 01fac00a0d3b919917e60b720c281d815f421f30 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 May 2026 22:42:36 +0200 Subject: [PATCH 29/81] Review feedback: Duplicate spawn comment. --- src/server/service.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/server/service.rs b/src/server/service.rs index 974343f64..fa67a8a3c 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -280,7 +280,7 @@ mod compat { // prepare the messages in an async function (as a Tokio task) and send // them over a channel from there. // - // In the future, AXFRs could be implemented by spawning an OS thread + // In the future, IXFRs could be implemented by spawning an OS thread // and doing all the work there. This is incompatible with the API of // `domain::net::server`, as the underlying TCP connection cannot be // extracted, but we plan to stop using that API anyway. @@ -450,6 +450,19 @@ mod compat { let (tx, mut rx) = tokio::sync::mpsc::channel(1024); + // NOTE: The following code is a bit tricky. Ideally, we would elide + // the channel and return the `messages` iterator as an async `Stream`; + // but the iterator borrows from `viewer` via `.non_soa_records()`, and + // this prevents the iterator from satisfying `'static`. Rust actually + // _does_ have machinery to work around this, in async functions, so we + // prepare the messages in an async function (as a Tokio task) and send + // them over a channel from there. + // + // In the future, AXFRs could be implemented by spawning an OS thread + // and doing all the work there. This is incompatible with the API of + // `domain::net::server`, as the underlying TCP connection cannot be + // extracted, but we plan to stop using that API anyway. + // Stream the records in the background. tokio::task::spawn(async move { // Collect the sequence of IXFR output records. From 79059904c8a62b6a11dc80bbc64088d1eb0da3e3 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 May 2026 22:45:07 +0200 Subject: [PATCH 30/81] Review feedback: Note the potential to use flat_map() to avoid a vec allocation. --- src/server/service.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/service.rs b/src/server/service.rs index fa67a8a3c..127215b56 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -467,6 +467,8 @@ mod compat { tokio::task::spawn(async move { // Collect the sequence of IXFR output records. let mut rrs = vec![new_soa.clone().into()]; + // TODO: Use diffs[..].iter().flat_map() here to avoid the + // intermediate Vec allocation? for (loaded_diff, signed_diff) in &diffs[start_idx..] { rrs.push(signed_diff.removed_soa.clone().unwrap().into()); rrs.extend(loaded_diff.removed_records.clone()); From 02efdfcb2d6d2e041fc1026fa3ea0464f7af7c75 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 May 2026 22:48:36 +0200 Subject: [PATCH 31/81] Review feedback: Remove irrelevant comment. --- src/server/service.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/server/service.rs b/src/server/service.rs index 127215b56..a7a79226a 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -387,9 +387,6 @@ mod compat { // "If an IXFR query with the same or newer version number than that // of the server is received, it is replied to with a single SOA // record of the server's current version, just as in AXFR." - // ^^^^^^^^^^^^^^^ - // Errata https://www.rfc-editor.org/errata/eid3196 points out that - // this is NOT "just as in AXFR" as AXFR does not do that. let our_soa_serial = viewer.soa().rdata.serial; if client_soa.serial >= our_soa_serial { From baf4c64c60d8fd9ffe0bcd4411e7738549ffad63 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 8 May 2026 23:35:55 +0200 Subject: [PATCH 32/81] Verify more XFR outputs against expectations. --- .../reference-output/example.test.ixfr-2-3 | 10 +++++ .../reference-output/example.test.ixfr-3-3 | 1 + .../notify-and-xfr-tsig.test.ixfr-2-3 | 10 +++++ .../notify-and-xfr-tsig.test.ixfr-3-3 | 1 + integration-tests/tests/ixfr-out/action.yml | 45 +++++++++++++++++-- 5 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 integration-tests/ixfr-out/reference-output/example.test.ixfr-2-3 create mode 100644 integration-tests/ixfr-out/reference-output/example.test.ixfr-3-3 create mode 100644 integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-2-3 create mode 100644 integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-3-3 diff --git a/integration-tests/ixfr-out/reference-output/example.test.ixfr-2-3 b/integration-tests/ixfr-out/reference-output/example.test.ixfr-2-3 new file mode 100644 index 000000000..56f9a7289 --- /dev/null +++ b/integration-tests/ixfr-out/reference-output/example.test.ixfr-2-3 @@ -0,0 +1,10 @@ +example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 +example.test. 3600 IN SOA ns.example.test. mail.example.test. 2 60 60 3600 5 +example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. fYRpxmxV5HQl7wIPmuN+lrYQJalFuxi9iI0DX2/mM4kdh7q9GGutFTOZ kbdY4D+AZf25s08CZR1CPpd9o2Q3Dg== +JAIN-BB.example.test. 3600 IN A 133.69.136.4 +JAIN-BB.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. XLFJlxjJDzmdwqF2BgxWN7Swfty8HD3ILb6Mo7fHKrfYt6x3uxXIUIjO JFVwFBhavLTrOlOcXdBGoNTvceqTDg== +example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 +example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. iVSTY7uTaal9mRd44phalz9+oHlQtNDGPrQb4rvx3SkdTMT21l9PDCVL BxomZOiC51WhuQX+8FxSiLQcN+sfAw== +JAIN-BB.example.test. 3600 IN A 133.69.136.3 +JAIN-BB.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. gPZnGC1IeAz1VtEvYEV9jMLw7kFhwPKX9kMtH32/xC7zyYNOfmcWqffZ xbobEY8c/oS9z4WIiK8k4uFi90rQCA== +example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 diff --git a/integration-tests/ixfr-out/reference-output/example.test.ixfr-3-3 b/integration-tests/ixfr-out/reference-output/example.test.ixfr-3-3 new file mode 100644 index 000000000..ebe4c34bc --- /dev/null +++ b/integration-tests/ixfr-out/reference-output/example.test.ixfr-3-3 @@ -0,0 +1 @@ +example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 diff --git a/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-2-3 b/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-2-3 new file mode 100644 index 000000000..9b3966fe2 --- /dev/null +++ b/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-2-3 @@ -0,0 +1,10 @@ +notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 +notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 2 60 60 3600 5 +notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0Qn9RoFrnZrhs8qy/GFWrSQ0ZUx11s60vVAzhBR0MhX9k8731t6Is5ic VwajxP8l3NM1PSvB0Eqamo0cyy6rCA== +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.4 +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. SRcvE4FT1NE9uVZrrFv1f+xinA37/CZWac6bJ62REhIE2e90rbDr1i/e n31YVDZQOHeVTKZCuueVKXgPYyztCw== +notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 +notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. caF70ZxIDm7UIXcj4RmkY5G+rcNFJp+wKpg17pUEz+N4P8+uxp3tYjYj 145UTqd36AUv4jsJgqEVN9roaZAbAw== +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.3 +JAIN-BB.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0X5PtAfOES8cyYc9kFoXfFB5w5AHBtTSlw8V2wd+UehtSgHMDX0ebUig C65dQahRoe6oELz7UNY7rv+BLOMrAA== +notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 diff --git a/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-3-3 b/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-3-3 new file mode 100644 index 000000000..1808ea302 --- /dev/null +++ b/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-3-3 @@ -0,0 +1 @@ +notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 diff --git a/integration-tests/tests/ixfr-out/action.yml b/integration-tests/tests/ixfr-out/action.yml index c06801133..a627272be 100644 --- a/integration-tests/tests/ixfr-out/action.yml +++ b/integration-tests/tests/ixfr-out/action.yml @@ -274,11 +274,50 @@ runs: LC_COLLATE=C sort cascade-ixfr-1-3.log > output.sorted diff -u -w output.sorted reference.output.sorted - - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${{ steps.set-vars.outputs.ZONE_NAME }} -t IXFR=2 >cascade-ixfr-2.log + - name: Verify Cascade IXFR serial 2..=3 out + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} + run: | + EXTRA_ARGS= + if [[ "${{ inputs.with-tsig }}" == "true" ]]; then + EXTRA_ARGS="-y ${{ steps.set-vars.outputs.TSIG_SPEC }}" + fi + dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${ZONE_NAME} -t IXFR=2 ${EXTRA_ARGS} &> cascade-ixfr-2-3.log + TEST_DIR="${PWD}/integration-tests/ixfr-out" + LC_COLLATE=C sort "${TEST_DIR}/reference-output/${ZONE_NAME}ixfr-2-3" | egrep -v '^;|^$' > reference.output.sorted + LC_COLLATE=C sort cascade-ixfr-2-3.log > output.sorted + diff -u -w output.sorted reference.output.sorted + + - name: Verify Cascade IXFR serial 3..=3 out + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} + run: | + EXTRA_ARGS= + if [[ "${{ inputs.with-tsig }}" == "true" ]]; then + EXTRA_ARGS="-y ${{ steps.set-vars.outputs.TSIG_SPEC }}" + fi + dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${ZONE_NAME} -t IXFR=3 ${EXTRA_ARGS} &> cascade-ixfr-3-3.log + TEST_DIR="${PWD}/integration-tests/ixfr-out" + LC_COLLATE=C sort "${TEST_DIR}/reference-output/${ZONE_NAME}ixfr-3-3" | egrep -v '^;|^$' > reference.output.sorted + LC_COLLATE=C sort cascade-ixfr-3-3.log > output.sorted + diff -u -w output.sorted reference.output.sorted - - run: dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${{ steps.set-vars.outputs.ZONE_NAME }} -t IXFR=3 >cascade-ixfr-3.log + - name: Verify NSD AXFR out + env: + ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} + run: | + dig +onesoa +noall +answer @127.0.0.1 -p 1054 ${ZONE_NAME} AXFR &> nsd-axfr.log + TEST_DIR="${PWD}/integration-tests/ixfr-out" - - run: dig +onesoa +noall +answer @127.0.0.1 -p 1054 ${{ steps.set-vars.outputs.ZONE_NAME }} AXFR >nsd-axfr.log + # Pass -f to sort and -i to diff to normalize case in the Cascade and + # NSD output being compared, as NSD outputs lower case labels while + # Cascade preserves label case. -f tells sort not to order differently + # based on case, and -i tells diff to consider differing case to be + # equal. + + LC_COLLATE=C sort -f "${TEST_DIR}/reference-output/${ZONE_NAME}axfr" | egrep -v '^;|^$' > reference.output.sorted + LC_COLLATE=C sort -f nsd-axfr.log > output.sorted + diff -u -w -i output.sorted reference.output.sorted - name: Print log files on any failure in this job uses: ./.github/actions/print-logfiles From e730fad95a96446a87426e0201de8983a677811b Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Sat, 9 May 2026 13:56:37 +0200 Subject: [PATCH 33/81] Comment out code. --- src/persistence/persist.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 8327fe996..761efc694 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -102,6 +102,10 @@ pub fn persist_signed( state.storage.diffs.push(signed_diff.clone()); } } + + // let state = zone.state.lock().unwrap(); + // tracing::trace!("Persisted signed diff: {signed_diff:?}"); + // tracing::info!("XIMON: Persist: # diffs = {}", state.storage.diffs.len()); persister.mark_complete() } From 2c52bcf96f28c5fb9b7148b2ad264d79d2781774 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Sat, 9 May 2026 22:47:12 +0200 Subject: [PATCH 34/81] Restore both loaded and signed IXFR diff parts. The signed diff is not available when the loaded diff is restored but should be stored with the corresponding loaded diff, so make the signed diff an Option to be made Some as soon as it is restored. --- crates/zonedata/src/restorer.rs | 5 + .../tests/persist-zone/action.yml | 6 +- src/persistence/persist.rs | 7 +- src/persistence/restore.rs | 120 +++++++++++------- src/persistence/zone.rs | 4 +- src/server/service.rs | 10 +- src/zone/storage.rs | 2 +- 7 files changed, 103 insertions(+), 51 deletions(-) diff --git a/crates/zonedata/src/restorer.rs b/crates/zonedata/src/restorer.rs index 406327b18..566ee7907 100644 --- a/crates/zonedata/src/restorer.rs +++ b/crates/zonedata/src/restorer.rs @@ -197,6 +197,11 @@ impl LoadedZoneRestorer { .find(|inst| inst.soa.is_some()) .map(LoadedZoneReader::new) } + + /// The diff from the preceding loaded instance to the current one. + pub fn take_diff(&mut self) -> Option> { + self.diff.take() + } } impl LoadedZoneRestorer { diff --git a/integration-tests/tests/persist-zone/action.yml b/integration-tests/tests/persist-zone/action.yml index 997edef50..6a5095cac 100644 --- a/integration-tests/tests/persist-zone/action.yml +++ b/integration-tests/tests/persist-zone/action.yml @@ -350,15 +350,15 @@ runs: # a previous signed zone. - name: "Verify that IXFR for 00 -> 01 is NOT available" env: - EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + CLIENT_SERIAL: ${{ steps.expected-serial.outputs.serial }} run: | # Attempting to get the diff from 00 -> 01 should return an AXFR # (denoted by a single leading SOA and a single trailing SOA) - let CLIENT_SERIAL=$(( EXPECTED_SERIAL + 0 )) + let EXPECTED_SERIAL=$(( CLIENT_SERIAL + 3 )) OUT_FILE="example.test.ixfr.from-${CLIENT_SERIAL}.log" dig +noall +answer @127.0.0.1 -p 4542 -t ixfr=${CLIENT_SERIAL} example.test > "${OUT_FILE}" - NUM_SOA=$(grep -E 'example.test.\s+5\s+IN\s+SOA\s+ns1.example.test.\smail.example.test.\s2026050703\s60\s60\s3600\s5' "${OUT_FILE}" | wc -l) + NUM_SOA=$(grep -E 'example.test.\s+5\s+IN\s+SOA\s+ns1.example.test.\smail.example.test.\s'${EXPECTED_SERIAL}'\s60\s60\s3600\s5' "${OUT_FILE}" | wc -l) if [[ ${NUM_SOA} -ne 2 ]]; then echo "::error:: Expected 2 SOA RRs but got ${NUM_SOA} RRs." exit 1 diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 2f82e0ca0..38ed258c5 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -118,8 +118,13 @@ pub fn persist_signed( // expiring. Signed zones MUST always have a new SOA SERIAL // compared to the previous version of the signed zone. - let complete_diff = (loaded_diff.clone(), signed_diff.clone()); + let complete_diff = (loaded_diff.clone(), Some(signed_diff.clone())); state.storage.diffs.push(complete_diff); + trace!( + "Stored IXFR diff for SOA serial {} -> {}", + loaded_diff.removed_soa.as_ref().unwrap().rdata.serial, + signed_diff.added_soa.as_ref().unwrap().rdata.serial, + ); } } persister.mark_complete() diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index eeae8c083..3cab0b551 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -37,7 +37,7 @@ pub fn restore_loaded( center: &Arc
, restorer: &mut LoadedZoneRestorer, ) -> io::Result<()> { - let state = zone.state.lock().unwrap(); + let mut state = zone.state.lock().unwrap(); if !state.persisted_loaded_diffs.is_empty() { // Determine the paths to read from. Each zone is persisted as an AXFR // plus zero or more IXFRs. The restorer takes a base path ending in @@ -54,12 +54,11 @@ pub fn restore_loaded( // Process the initial "loaded" AXFR wire format dump. let (soa, records) = load_axfr_wire_dump(loaded_source.as_std_path(), &mut buf).map_err(|err| { - io::Error::other(format!( - "Failed to load persisted loaded snapshot for zone '{}' from '{loaded_source}': {err}", - zone.name - )) + io::Error::other(format!("Loading snapshot '{loaded_source}' failed: {err}")) })?; - let mut loaded_replacer = restorer.fill().ok_or(io::Error::other(format!("Unable to restore persisted snapshot for zone '{}' from '{loaded_source}': Could not acquire replacer", zone.name)))?; + let mut loaded_replacer = restorer.fill().ok_or(io::Error::other( + "Internal error: Could not acquire replacer".to_string(), + ))?; loaded_replacer.set_soa(soa.clone()).unwrap(); loaded_replacer.set_records(records).unwrap(); loaded_replacer.apply().unwrap(); @@ -69,36 +68,47 @@ pub fn restore_loaded( ); if count > 1 { - let mut loaded_patcher = restorer.patch().ok_or(io::Error::other(format!( - "Failed to load persisted signed diff for zone '{}' from '{loaded_source}'", - zone.name - )))?; let mut source = loaded_source.to_path_buf(); let mut all_serials = vec![]; + + state.storage.diffs.clear(); + for i in 1..count { + let mut loaded_patcher = restorer + .patch() + .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; source.set_extension(i.to_string()); - let loaded_patcher = &mut loaded_patcher; - let (start_serial, end_serial) = load_ixfr_wire_dump( - source.as_std_path(), - &mut buf, - |event| { - apply_ixfr_event_to_loaded_data(loaded_patcher, event); - } - ).map_err(|err| { - io::Error::other(format!( - "Failed to load persisted loaded diff for zone '{}' from '{loaded_source}': {err}", - zone.name - )) + let (start_serial, end_serial) = + load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { + apply_ixfr_event_to_loaded_data(&mut loaded_patcher, event); + }) + .map_err(|err| { + io::Error::other(format!("Loading diff '{loaded_source}' failed: {err}",)) + })?; + + loaded_patcher.next_patchset().map_err(|err| { + io::Error::other(format!("Internal error: Next patchset failed: {err}")) + })?; + + loaded_patcher.apply().map_err(|err| { + io::Error::other(format!("Internal error: Apply failed: {err}")) })?; - loaded_patcher.next_patchset().map_err(|err| io::Error::other(format!("Unable to restore loaded persisted diff {i} for zone '{}' from '{source}': Could not move to next patch set: {err}", zone.name)))?; + if let Some(diff) = restorer.take_diff() { + // Store the loaded diff to be used as part of serving an IXFR. + state.storage.diffs.push((diff.into(), None)); + + trace!( + "Stored IXFR loaded diff for SOA serial {} from file '{loaded_source}': serial {start_serial} -> {end_serial}", + soa.rdata.serial, + ); + } let start_serial: u32 = start_serial.into(); let end_serial: u32 = end_serial.into(); all_serials.push((start_serial, end_serial)); } - loaded_patcher.apply().map_err(|err| io::Error::other(format!("Unable to restore persisted loaded diffs for zone '{}': Could apply the collected changes: {err}", zone.name)))?; trace!( "Restored loaded diff for SOA serial {} for zone '{}' from file '{loaded_source}' with diff serials: {all_serials:?}", soa.rdata.serial, zone.name @@ -135,13 +145,15 @@ pub fn restore_signed( let mut buf = Vec::::new(); // Process the initial "signed" AXFR wire format dump. - let (soa, records) = load_axfr_wire_dump(signed_source.as_std_path(), &mut buf).map_err(|err| { + let (soa, records) = + load_axfr_wire_dump(signed_source.as_std_path(), &mut buf).map_err(|err| { io::Error::other(format!( - "Failed to load persisted signed snapshot for zone '{}' from '{signed_source}': {err}", - zone.name + "Loading snapshot from '{signed_source}' failed: {err}" )) })?; - let mut signed_replacer = restorer.fill().ok_or(io::Error::other(format!("Unable to restore persisted signed snapshot for zone '{}' from '{signed_source}': Could not acquire replacer", zone.name)))?; + let mut signed_replacer = restorer.fill().ok_or(io::Error::other( + "Internal error: Could not acquire replacer".to_string(), + ))?; signed_replacer.set_soa(soa.clone()).unwrap(); signed_replacer.set_records(records).unwrap(); signed_replacer.apply().unwrap(); @@ -154,33 +166,55 @@ pub fn restore_signed( if count > 1 { let mut source = signed_source.to_path_buf(); let mut all_serials = vec![]; + + // Load each diff and apply it to the zone, retrieving a single + // DiffData per signed diff. Store each signed DiffData alongside + // the corresponding loaded DiffData that was restored earlier + // in restore_loaded(). These DiffData's will be used to respond + // to IXFR requests, while at the same time also building up the + // entire signed zone that should be served for AXFR requests. for i in 1..count { - let mut signed_patcher = restorer.patch().ok_or(io::Error::other(format!( - "Failed to load persisted signed diff for zone '{}' from '{signed_source}'", - zone.name - )))?; + let mut signed_patcher = restorer + .patch() + .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; source.set_extension(i.to_string()); let (start_serial, end_serial) = load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { apply_ixfr_event_to_signed_data(&mut signed_patcher, event); }) - .map_err(|err| { - io::Error::other(format!( - "Failed to load persisted signed diff for zone '{}' from '{signed_source}': {err}", - zone.name - )) - })?; + .map_err(|err| { + io::Error::other(format!("Loading diff '{signed_source}' failed: {err}")) + })?; - signed_patcher.next_patchset().map_err(|err| io::Error::other(format!("Unable to restore persisted signed diff {i} for zone '{}' from '{source}': Could not move to next patch set: {err}", zone.name)))?; + signed_patcher.next_patchset().map_err(|err| { + io::Error::other(format!("Internal error: Next patchset failed: {err}")) + })?; - signed_patcher.apply().map_err(|err| io::Error::other(format!("Unable to restore persisted signed diffs for zone '{}': Could apply the collected changes: {err}", zone.name)))?; + signed_patcher.apply().map_err(|err| { + io::Error::other(format!("Internal error: Apply failed: {err}")) + })?; if let Some(diff) = restorer.take_diff() { - state.storage.diffs.push(diff.into()); + // Get the diff pair (loaded diff and missing signed diff) + // that this signed diff needs to be inserted into. + let diffs = state + .storage + .diffs + .get_mut(i - 1) + .ok_or(io::Error::other(format!("Missing loaded diff {i}")))?; + + // Insert the signed diff alongside the loaded diff, unless the + // signed diff already unexpectedly exists. + if diffs.1.is_some() { + return Err(io::Error::other(format!("Signed diff {i} already exists"))); + } else { + diffs.1 = Some(diff.into()); + } + trace!( - "Stored IXFR diff for SOA serial {} for zone '{}' from file '{signed_source}': serial {start_serial} -> {end_serial}", - soa.rdata.serial, zone.name + "Stored signed diff for SOA serial {} from file '{signed_source}': serial {start_serial} -> {end_serial}", + soa.rdata.serial, ); } diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index aaf49a411..26bf0a979 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -96,8 +96,8 @@ impl ZonePersistenceHandle<'_> { "'restore_signed()' always completes restoration on successful return" ) }), - Err(_) => { - trace!("Abandoning signed restoration"); + Err(err) => { + warn!("Abandoning signed restoration: {err}"); let mut state = zone.state.lock().unwrap(); state.persisted_loaded_diffs.clear(); state.persisted_signed_diffs.clear(); diff --git a/src/server/service.rs b/src/server/service.rs index a7a79226a..51ad0d176 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -416,6 +416,7 @@ mod compat { zone.handle.name ); for (i, (loaded_diff, signed_diff)) in diffs.iter().enumerate() { + let signed_diff = signed_diff.as_ref().unwrap(); trace!( "IXFR out: Diff #{i}: serial {} => serial {}, loaded -{}+{}, signed -{}+{}", signed_diff.removed_soa.as_ref().unwrap().0.rdata.serial, @@ -434,7 +435,13 @@ mod compat { // the client, i.e. the one we published in the signed zone, not the // one from the loaded zone which could be completely different. let start_idx = diffs.iter().position(|(_, signed_diff)| { - signed_diff.removed_soa.as_ref().map(|rr| rr.0.rdata.serial) == Some(client_soa.serial) + signed_diff + .as_ref() + .unwrap() + .removed_soa + .as_ref() + .map(|rr| rr.0.rdata.serial) + == Some(client_soa.serial) }); let Some(start_idx) = start_idx else { @@ -467,6 +474,7 @@ mod compat { // TODO: Use diffs[..].iter().flat_map() here to avoid the // intermediate Vec allocation? for (loaded_diff, signed_diff) in &diffs[start_idx..] { + let signed_diff = signed_diff.as_ref().unwrap(); rrs.push(signed_diff.removed_soa.clone().unwrap().into()); rrs.extend(loaded_diff.removed_records.clone()); rrs.extend(signed_diff.removed_records.clone()); diff --git a/src/zone/storage.rs b/src/zone/storage.rs index 370dcc9f8..5fb5f047e 100644 --- a/src/zone/storage.rs +++ b/src/zone/storage.rs @@ -927,7 +927,7 @@ pub struct StorageState { /// Diffs from one serial to another. Each diff consists of changes in the /// loaded part and changes in the signed part. - pub diffs: Vec<(Arc, Arc)>, + pub diffs: Vec<(Arc, Option>)>, /// Ongoing background tasks. /// From a7888aa9aed9519cc7230d3cbbbf505447b5fcac Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Sun, 10 May 2026 22:14:15 +0200 Subject: [PATCH 35/81] Forget & remove persisted zone data if it cannot be loaded. --- src/persistence/zone.rs | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 26bf0a979..c30446f59 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -65,8 +65,7 @@ impl ZonePersistenceHandle<'_> { Err(err) => { warn!("Abandoning loaded restoration: {err}"); let mut state = zone.state.lock().unwrap(); - state.persisted_loaded_diffs.clear(); - state.persisted_signed_diffs.clear(); + clear_persisted_zone_data(¢er, &mut state); let mut handle = ZoneHandle { zone: &zone, state: &mut state, @@ -99,8 +98,7 @@ impl ZonePersistenceHandle<'_> { Err(err) => { warn!("Abandoning signed restoration: {err}"); let mut state = zone.state.lock().unwrap(); - state.persisted_loaded_diffs.clear(); - state.persisted_signed_diffs.clear(); + clear_persisted_zone_data(¢er, &mut state); let mut handle = ZoneHandle { zone: &zone, state: &mut state, @@ -200,6 +198,36 @@ impl ZonePersistenceHandle<'_> { } } +fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { + // We can't use the persisted data so remove the paths from state and also + // the corresponding files on disk. + let zone_state_dir = center.config.zone_state_dir.canonicalize().unwrap(); + for p in state + .persisted_loaded_diffs + .iter() + .chain(state.persisted_signed_diffs.iter()) + { + if p.exists() { + if let Ok(ref p) = p.canonicalize() { + if p.starts_with(&zone_state_dir) { + info!( + "Removing unusable persisted zone data file '{}'", + p.display() + ); + if let Err(err) = std::fs::remove_file(p) { + warn!( + "Failed to remove unusable persisted zone data file '{}': {err}", + p.display() + ); + } + } + } + } + } + state.persisted_loaded_diffs.clear(); + state.persisted_signed_diffs.clear(); +} + //----------- PersistenceState ------------------------------------------------- /// State related to data persistence for a zone. From aea51c650e906bbadd38cbf6a500ff77aa809f04 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Sun, 10 May 2026 23:01:57 +0200 Subject: [PATCH 36/81] Better progress reporting. - Don't hold the zone state lock while restoring as it blocks the zone status command. - Log restoring started at INFO level as well as restoring complete. - Only log restoring complete if restoring actually happened. - Log the zone being restored at INFO level, not just TRACE level. --- crates/api/src/lib.rs | 1 + crates/cli/src/commands/zone.rs | 11 +++++++---- src/persistence/restore.rs | 22 ++++++++++++++++------ src/persistence/zone.rs | 2 +- src/units/http_server.rs | 8 +++++++- src/zone/storage.rs | 7 +++++++ 6 files changed, 39 insertions(+), 12 deletions(-) diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index eada3c89c..56cadb143 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -457,6 +457,7 @@ pub struct LastPublishedZone { #[derive(Deserialize, Serialize, Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Progress { Waiting, + Restoring, Loading, LoadedReview, HaltLoaded, diff --git a/crates/cli/src/commands/zone.rs b/crates/cli/src/commands/zone.rs index 4184483b9..890d01ec5 100644 --- a/crates/cli/src/commands/zone.rs +++ b/crates/cli/src/commands/zone.rs @@ -576,7 +576,7 @@ impl Zone { // Output information per step progressed until the first still // in-progress/aborted step or show all steps if all have completed. println!(""); - print_status(zone.progress, &zone, &policy); + print_status(&zone, &policy); if zone.last_published.is_some() { println!(""); @@ -620,8 +620,11 @@ impl Zone { } } -pub fn print_status(current: Progress, zone: &ZoneStatus, policy: &PolicyInfo) { - let progress = match zone.progress { +pub fn print_status(zone: &ZoneStatus, policy: &PolicyInfo) { + let current = zone.progress; + + let progress = match current { + Progress::Restoring => "restoring", Progress::Waiting => "idle", Progress::Loading => "loading", Progress::LoadedReview => "waiting for loaded review", @@ -634,7 +637,7 @@ pub fn print_status(current: Progress, zone: &ZoneStatus, policy: &PolicyInfo) { println!("status: {}{progress}{}", ansi::BLUE, ansi::RESET); - if current == Progress::Waiting { + if matches!(current, Progress::Waiting | Progress::Restoring) { return; } diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 3cab0b551..a42b0d8eb 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -22,13 +22,13 @@ use domain::{ }, utils::dst::UnsizedCopy, }; -use tracing::trace; +use tracing::{info, trace}; use crate::{center::Center, zone::Zone}; /// Restore the loaded instance data of a zone. #[tracing::instrument( - level = "trace", + level = "info", skip_all, fields(zone = %zone.name), )] @@ -39,6 +39,9 @@ pub fn restore_loaded( ) -> io::Result<()> { let mut state = zone.state.lock().unwrap(); if !state.persisted_loaded_diffs.is_empty() { + info!("Restoring loaded zone from persisted data"); + state.storage.diffs.clear(); + // Determine the paths to read from. Each zone is persisted as an AXFR // plus zero or more IXFRs. The restorer takes a base path ending in // an unsigned integer number and loads that file plus N more, where @@ -50,6 +53,7 @@ pub fn restore_loaded( .join(format!("{}.loaded.0", zone.name)); let count = state.persisted_loaded_diffs.len(); let mut buf = Vec::::new(); + drop(state); // Process the initial "loaded" AXFR wire format dump. let (soa, records) = @@ -71,8 +75,6 @@ pub fn restore_loaded( let mut source = loaded_source.to_path_buf(); let mut all_serials = vec![]; - state.storage.diffs.clear(); - for i in 1..count { let mut loaded_patcher = restorer .patch() @@ -97,6 +99,7 @@ pub fn restore_loaded( if let Some(diff) = restorer.take_diff() { // Store the loaded diff to be used as part of serving an IXFR. + let mut state = zone.state.lock().unwrap(); state.storage.diffs.push((diff.into(), None)); trace!( @@ -114,6 +117,8 @@ pub fn restore_loaded( soa.rdata.serial, zone.name ); } + + info!("Restored loaded zone snapshot and {} diffs", count - 1); } io::Result::Ok(()) @@ -121,7 +126,7 @@ pub fn restore_loaded( /// Restore the loaded instance data of a zone. #[tracing::instrument( - level = "trace", + level = "info", skip_all, fields(zone = %zone.name), )] @@ -130,8 +135,10 @@ pub fn restore_signed( center: &Arc
, restorer: &mut SignedZoneRestorer, ) -> io::Result<()> { - let mut state = zone.state.lock().unwrap(); + let state = zone.state.lock().unwrap(); if !state.persisted_loaded_diffs.is_empty() { + info!("Restoring signed zone from persisted data"); + // Determine the paths to read from. Each zone is persisted as an AXFR // plus zero or more IXFRs. The restorer takes a base path ending in // an unsigned integer number and loads that file plus N more, where @@ -143,6 +150,7 @@ pub fn restore_signed( .join(format!("{}.signed.0", zone.name)); let count = state.persisted_signed_diffs.len(); let mut buf = Vec::::new(); + drop(state); // Process the initial "signed" AXFR wire format dump. let (soa, records) = @@ -196,6 +204,7 @@ pub fn restore_signed( })?; if let Some(diff) = restorer.take_diff() { + let mut state = zone.state.lock().unwrap(); // Get the diff pair (loaded diff and missing signed diff) // that this signed diff needs to be inserted into. let diffs = state @@ -227,6 +236,7 @@ pub fn restore_signed( soa.rdata.serial, zone.name ); } + info!("Restored signed zone snapshot and {} diffs", count - 1); } io::Result::Ok(()) } diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index c30446f59..c4dcae4a7 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -40,7 +40,7 @@ impl ZonePersistenceHandle<'_> { /// A background task will be spawned to restore the zone's data (for both /// the loaded and signed instances). #[tracing::instrument( - level = "trace", + level = "info", skip_all, fields(zone = %self.zone.name), )] diff --git a/src/units/http_server.rs b/src/units/http_server.rs index 9eee13e3c..a6357c497 100644 --- a/src/units/http_server.rs +++ b/src/units/http_server.rs @@ -455,7 +455,13 @@ impl HttpServer { .map(|r| Serial::from(u32::from(r.rdata.serial))); progress = match zone_state.machine { - ZoneStateMachine::Waiting(..) => Progress::Waiting, + ZoneStateMachine::Waiting(..) => { + if zone_state.storage.is_restoring() { + Progress::Restoring + } else { + Progress::Waiting + } + } ZoneStateMachine::Loading(..) => Progress::Loading, ZoneStateMachine::LoadedReview(..) => Progress::LoadedReview, ZoneStateMachine::HaltLoaded(..) => Progress::HaltLoaded, diff --git a/src/zone/storage.rs b/src/zone/storage.rs index 5fb5f047e..5998a6a56 100644 --- a/src/zone/storage.rs +++ b/src/zone/storage.rs @@ -952,6 +952,13 @@ impl StorageState { background_tasks: Default::default(), } } + + pub fn is_restoring(&self) -> bool { + matches!( + self.machine, + ZoneDataStorage::RestoringLoaded(_) | ZoneDataStorage::RestoringSigned(_) + ) + } } impl Default for StorageState { From 45abfe9085c6b9f116ade64ea0b76d746e1f7f35 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Sun, 10 May 2026 23:35:44 +0200 Subject: [PATCH 37/81] FIX: Persist zone state last_published metadata. Otherwise zone status of a restored zone doesn't show that it is published. --- src/zone/mod.rs | 2 +- src/zone/state/mod.rs | 2 ++ src/zone/state/v1.rs | 6 +++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/zone/mod.rs b/src/zone/mod.rs index ff5ecccb9..1abdf5922 100644 --- a/src/zone/mod.rs +++ b/src/zone/mod.rs @@ -331,7 +331,7 @@ impl Default for ZoneState { } } -#[derive(Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct LastPublished { pub loaded_serial: Serial, pub signed_serial: Serial, diff --git a/src/zone/state/mod.rs b/src/zone/state/mod.rs index abe468e0d..e51a3faf2 100644 --- a/src/zone/state/mod.rs +++ b/src/zone/state/mod.rs @@ -84,6 +84,7 @@ impl Spec { match self { Self::V1(v1::Spec { policy, + last_published, source, min_expiration, next_min_expiration, @@ -115,6 +116,7 @@ impl Spec { Ok(ZoneState { policy, + last_published, min_expiration, next_min_expiration, apex_remove, diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index 0425005c9..4e41c6325 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -15,7 +15,7 @@ use crate::loader::Source; use crate::policy::file::v1::{NameserverCommsSpec, OutboundSpec}; use crate::policy::{AutoConfig, DsAlgorithm, KeyParameters}; use crate::tsig::TsigStore; -use crate::zone::HistoryItem; +use crate::zone::{HistoryItem, LastPublished}; use crate::{ policy::{ KeyManagerPolicy, LoaderPolicy, PolicyVersion, ReviewPolicy, ServerPolicy, @@ -38,6 +38,9 @@ pub struct Spec { /// version of the policy that is not yet in use. pub policy: Option, + /// Metadata related to the last published zone version. + pub last_published: Option, + /// The source of the zone. pub source: ZoneLoadSourceSpec, @@ -110,6 +113,7 @@ impl Spec { pub fn build(zone: &ZoneState) -> Self { Self { policy: zone.policy.as_ref().map(|p| PolicySpec::build(p)), + last_published: zone.last_published.clone(), source: ZoneLoadSourceSpec::build(&zone.loader.source), min_expiration: zone.min_expiration, next_min_expiration: zone.next_min_expiration, From 2fc7facd3388ffc86ce496afcc18850c23fa682f Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Sun, 10 May 2026 23:37:29 +0200 Subject: [PATCH 38/81] Clippy. --- src/persistence/zone.rs | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index c4dcae4a7..999b105a3 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -110,7 +110,6 @@ impl ZonePersistenceHandle<'_> { } }; - info!("Restored the zone's persisted data"); let mut state = zone.state.lock().unwrap(); trace!("Restored diffs: {:?}", state.persisted_loaded_diffs); let mut handle = ZoneHandle { @@ -207,20 +206,19 @@ fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { .iter() .chain(state.persisted_signed_diffs.iter()) { - if p.exists() { - if let Ok(ref p) = p.canonicalize() { - if p.starts_with(&zone_state_dir) { - info!( - "Removing unusable persisted zone data file '{}'", - p.display() - ); - if let Err(err) = std::fs::remove_file(p) { - warn!( - "Failed to remove unusable persisted zone data file '{}': {err}", - p.display() - ); - } - } + if p.exists() + && let Ok(ref p) = p.canonicalize() + && p.starts_with(&zone_state_dir) + { + info!( + "Removing unusable persisted zone data file '{}'", + p.display() + ); + if let Err(err) = std::fs::remove_file(p) { + warn!( + "Failed to remove unusable persisted zone data file '{}': {err}", + p.display() + ); } } } From 492efa1c2ed1f3a23a1da9f5083a29ec1bfd4dbb Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 11 May 2026 10:18:35 +0200 Subject: [PATCH 39/81] Rename diffs -> diff_paths. --- src/persistence/persist.rs | 14 ++++++++++---- src/persistence/restore.rs | 8 ++++---- src/persistence/zone.rs | 10 +++++----- src/zone/mod.rs | 8 ++++---- src/zone/state/mod.rs | 4 ++-- src/zone/state/v1.rs | 4 ++-- 6 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 38ed258c5..62b6d8a26 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -44,13 +44,16 @@ pub fn persist_loaded( state: &mut state, center, }; - let next_idx = handle.state.persisted_loaded_diffs.len(); + let next_idx = handle.state.persisted_loaded_diff_paths.len(); let destination = center .config .zone_state_dir .join(format!("{}.loaded.{next_idx}", zone.name)); persist_to_file(destination.as_std_path(), persister.loaded_diff().clone()); - handle.state.persisted_loaded_diffs.push(destination.into()); + handle + .state + .persisted_loaded_diff_paths + .push(destination.into()); handle.zone.mark_dirty(handle.state, handle.center); } persister.mark_complete() @@ -84,7 +87,7 @@ pub fn persist_signed( state: &mut state, center, }; - let next_idx = handle.state.persisted_signed_diffs.len(); + let next_idx = handle.state.persisted_signed_diff_paths.len(); let destination = center .config .zone_state_dir @@ -93,7 +96,10 @@ pub fn persist_signed( // Write the diff to disk as a binary AXFR snapshot or binary IXFR // diff. persist_to_file(destination.as_std_path(), signed_diff.clone()); - handle.state.persisted_signed_diffs.push(destination.into()); + handle + .state + .persisted_signed_diff_paths + .push(destination.into()); handle.zone.mark_dirty(handle.state, handle.center); // Store the diffs in-memory for serving IXFR. diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index a42b0d8eb..2ecdedd76 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -38,7 +38,7 @@ pub fn restore_loaded( restorer: &mut LoadedZoneRestorer, ) -> io::Result<()> { let mut state = zone.state.lock().unwrap(); - if !state.persisted_loaded_diffs.is_empty() { + if !state.persisted_loaded_diff_paths.is_empty() { info!("Restoring loaded zone from persisted data"); state.storage.diffs.clear(); @@ -51,7 +51,7 @@ pub fn restore_loaded( .config .zone_state_dir .join(format!("{}.loaded.0", zone.name)); - let count = state.persisted_loaded_diffs.len(); + let count = state.persisted_loaded_diff_paths.len(); let mut buf = Vec::::new(); drop(state); @@ -136,7 +136,7 @@ pub fn restore_signed( restorer: &mut SignedZoneRestorer, ) -> io::Result<()> { let state = zone.state.lock().unwrap(); - if !state.persisted_loaded_diffs.is_empty() { + if !state.persisted_loaded_diff_paths.is_empty() { info!("Restoring signed zone from persisted data"); // Determine the paths to read from. Each zone is persisted as an AXFR @@ -148,7 +148,7 @@ pub fn restore_signed( .config .zone_state_dir .join(format!("{}.signed.0", zone.name)); - let count = state.persisted_signed_diffs.len(); + let count = state.persisted_signed_diff_paths.len(); let mut buf = Vec::::new(); drop(state); diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 999b105a3..32f776638 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -111,7 +111,7 @@ impl ZonePersistenceHandle<'_> { }; let mut state = zone.state.lock().unwrap(); - trace!("Restored diffs: {:?}", state.persisted_loaded_diffs); + trace!("Restored diffs: {:?}", state.persisted_loaded_diff_paths); let mut handle = ZoneHandle { zone: &zone, state: &mut state, @@ -202,9 +202,9 @@ fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { // the corresponding files on disk. let zone_state_dir = center.config.zone_state_dir.canonicalize().unwrap(); for p in state - .persisted_loaded_diffs + .persisted_loaded_diff_paths .iter() - .chain(state.persisted_signed_diffs.iter()) + .chain(state.persisted_signed_diff_paths.iter()) { if p.exists() && let Ok(ref p) = p.canonicalize() @@ -222,8 +222,8 @@ fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { } } } - state.persisted_loaded_diffs.clear(); - state.persisted_signed_diffs.clear(); + state.persisted_loaded_diff_paths.clear(); + state.persisted_signed_diff_paths.clear(); } //----------- PersistenceState ------------------------------------------------- diff --git a/src/zone/mod.rs b/src/zone/mod.rs index 1abdf5922..17df70eb6 100644 --- a/src/zone/mod.rs +++ b/src/zone/mod.rs @@ -256,12 +256,12 @@ pub struct ZoneState { /// Locations of persisted unsigned zone diffs to enable IXFR from /// the upstream to resume on restart, and to enable a complete latest /// unsigned version of the zone to be reconstituted. - pub persisted_loaded_diffs: Vec, + pub persisted_loaded_diff_paths: Vec, /// Locations of persisted signed zone diffs to ensure IXFR out toward /// downstreams is still possible after restart, and to enable a complete /// latest signed version of the zone to be reconsituted. - pub persisted_signed_diffs: Vec, + pub persisted_signed_diff_paths: Vec, /// Loading new versions of the zone. pub loader: LoaderState, @@ -325,8 +325,8 @@ impl Default for ZoneState { signer: Default::default(), storage: Default::default(), persistence: Default::default(), - persisted_loaded_diffs: Default::default(), - persisted_signed_diffs: Default::default(), + persisted_loaded_diff_paths: Default::default(), + persisted_signed_diff_paths: Default::default(), } } } diff --git a/src/zone/state/mod.rs b/src/zone/state/mod.rs index e51a3faf2..c9cd83ef2 100644 --- a/src/zone/state/mod.rs +++ b/src/zone/state/mod.rs @@ -127,8 +127,8 @@ impl Spec { previous_serial, loader, history, - persisted_loaded_diffs, - persisted_signed_diffs, + persisted_loaded_diff_paths: persisted_loaded_diffs, + persisted_signed_diff_paths: persisted_signed_diffs, ..Default::default() }) } diff --git a/src/zone/state/v1.rs b/src/zone/state/v1.rs index 4e41c6325..ef4b68304 100644 --- a/src/zone/state/v1.rs +++ b/src/zone/state/v1.rs @@ -124,8 +124,8 @@ impl Spec { last_signature_refresh: zone.last_signature_refresh.clone(), previous_serial: zone.previous_serial, history: zone.history.clone(), - persisted_loaded_diffs: zone.persisted_loaded_diffs.clone(), - persisted_signed_diffs: zone.persisted_signed_diffs.clone(), + persisted_loaded_diffs: zone.persisted_loaded_diff_paths.clone(), + persisted_signed_diffs: zone.persisted_signed_diff_paths.clone(), } } } From db02b5f13bd0d91085e6333766e2e3b6ff091331 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Mon, 11 May 2026 10:27:13 +0200 Subject: [PATCH 40/81] Rename old_request -> request. --- src/server/service.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/server/service.rs b/src/server/service.rs index 51ad0d176..14f55d89f 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -163,17 +163,17 @@ mod compat { fn is_permitted( zone: &ServedZone, - old_request: &Request, Option>>, + request: &Request, Option>>, ) -> bool { let zone_state = zone.handle.state.lock().unwrap(); if tracing::enabled!(Level::TRACE) { - let tsig_key = old_request.metadata().as_ref().map(|key| key.name()); + let tsig_key = request.metadata().as_ref().map(|key| key.name()); trace!( "Received request {} from {} for {} in zone {} with TSIG key {tsig_key:?}", - old_request.message().header().id(), - old_request.client_addr().ip(), - old_request + request.message().header().id(), + request.client_addr().ip(), + request .message() .qtype() .map(|rtype| rtype.to_string()) @@ -189,11 +189,11 @@ mod compat { { // If at least one ACL was specified, enforce it. if !acls.is_empty() { - let wanted_tsig_key_name = old_request.metadata().as_ref().map(|key| key.name()); + let wanted_tsig_key_name = request.metadata().as_ref().map(|key| key.name()); for acl in acls { // Does the client address match the allowed address? - if acl.addr.ip() == old_request.client_addr().ip() { + if acl.addr.ip() == request.client_addr().ip() { // Is the request signed with the right TSIG key? if acl.tsig_key_name.as_ref() == wanted_tsig_key_name { // Allow the request. @@ -213,9 +213,9 @@ mod compat { }; debug!( "Rejecting request {} from {} for {} in zone {}: access denied{extra}", - old_request.message().header().id(), - old_request.client_addr().ip(), - old_request + request.message().header().id(), + request.client_addr().ip(), + request .message() .qtype() .map(|rtype| rtype.to_string()) From 765d26ff237ba9124c3c0d53ffac818925dfff48 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 12 May 2026 15:51:35 +0200 Subject: [PATCH 41/81] Remove early merged versions of the ixfr-out test, these have since been extracted to PR #650. --- .../keys/Kexample.test.+015+00580.key | 1 - .../keys/Kexample.test.+015+00580.private | 3 - .../Knotify-and-xfr-tsig.test.+015+10995.key | 1 - ...otify-and-xfr-tsig.test.+015+10995.private | 3 - .../ixfr-out/policies/with-tsig.toml | 32 -- .../ixfr-out/policies/without-tsig.toml | 23 -- .../reference-output/example.test.axfr | 17 - .../reference-output/example.test.ixfr-1-3 | 73 ---- .../reference-output/example.test.ixfr-2-3 | 10 - .../reference-output/example.test.ixfr-3-3 | 1 - .../notify-and-xfr-tsig.test.axfr | 17 - .../notify-and-xfr-tsig.test.ixfr-1-3 | 27 -- .../notify-and-xfr-tsig.test.ixfr-2-3 | 10 - .../notify-and-xfr-tsig.test.ixfr-3-3 | 1 - integration-tests/tests/ixfr-out/action.yml | 324 ------------------ 15 files changed, 543 deletions(-) delete mode 100644 integration-tests/ixfr-out/keys/Kexample.test.+015+00580.key delete mode 100644 integration-tests/ixfr-out/keys/Kexample.test.+015+00580.private delete mode 100644 integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.key delete mode 100644 integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.private delete mode 100644 integration-tests/ixfr-out/policies/with-tsig.toml delete mode 100644 integration-tests/ixfr-out/policies/without-tsig.toml delete mode 100644 integration-tests/ixfr-out/reference-output/example.test.axfr delete mode 100644 integration-tests/ixfr-out/reference-output/example.test.ixfr-1-3 delete mode 100644 integration-tests/ixfr-out/reference-output/example.test.ixfr-2-3 delete mode 100644 integration-tests/ixfr-out/reference-output/example.test.ixfr-3-3 delete mode 100644 integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.axfr delete mode 100644 integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-1-3 delete mode 100644 integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-2-3 delete mode 100644 integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-3-3 delete mode 100644 integration-tests/tests/ixfr-out/action.yml diff --git a/integration-tests/ixfr-out/keys/Kexample.test.+015+00580.key b/integration-tests/ixfr-out/keys/Kexample.test.+015+00580.key deleted file mode 100644 index 224ce50d5..000000000 --- a/integration-tests/ixfr-out/keys/Kexample.test.+015+00580.key +++ /dev/null @@ -1 +0,0 @@ -example.test. IN DNSKEY 256 3 15 u7Tg6xf6Xgt0y+yUT8CQi1Nyy+tRopVHnZOFQz0zQ5A= diff --git a/integration-tests/ixfr-out/keys/Kexample.test.+015+00580.private b/integration-tests/ixfr-out/keys/Kexample.test.+015+00580.private deleted file mode 100644 index c6ba81b21..000000000 --- a/integration-tests/ixfr-out/keys/Kexample.test.+015+00580.private +++ /dev/null @@ -1,3 +0,0 @@ -Private-key-format: v1.2 -Algorithm: 15 (ED25519) -PrivateKey: qCwt/EY9s1vGn0uj+4dlMQ5waOuFfIYpZZVrX43jdcc= diff --git a/integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.key b/integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.key deleted file mode 100644 index 9ef332abc..000000000 --- a/integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.key +++ /dev/null @@ -1 +0,0 @@ -notify-and-xfr-tsig.test. IN DNSKEY 256 3 15 0efFuDXcYUyhaTUWgir/RaWrzAJWAa58VuEF+391Taw= diff --git a/integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.private b/integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.private deleted file mode 100644 index b385fd508..000000000 --- a/integration-tests/ixfr-out/keys/Knotify-and-xfr-tsig.test.+015+10995.private +++ /dev/null @@ -1,3 +0,0 @@ -Private-key-format: v1.2 -Algorithm: 15 (ED25519) -PrivateKey: Nx9EHQ1RBhngQMCRQUxMsiD2ybaWRCiEdcDlW5isKoo= diff --git a/integration-tests/ixfr-out/policies/with-tsig.toml b/integration-tests/ixfr-out/policies/with-tsig.toml deleted file mode 100644 index cc32f479d..000000000 --- a/integration-tests/ixfr-out/policies/with-tsig.toml +++ /dev/null @@ -1,32 +0,0 @@ -# Setup incremental signing to produce predictable outputs so that we can -# easily compare the generated zone against expected output. -# -# Enable sending of NOTIFY messages using TSIG authentication to the NSD -# secondary. -version = "v1" - -[signer.denial] -type = "nsec" - -[signer] -serial-policy = "keep" -signature-inception-offset = 0 -signature-lifetime = 100000000 - -[key-manager.records] -dnskey.signature-inception-offset = 0 -cds.signature-inception-offset = 0 -dnskey.signature-lifetime = 100000000 -cds.signature-lifetime = 100000000 - -[server.outbound] -# NSD secondary listens on port 1054. -send-notify-to = ["127.0.0.1:1054^tsig-key"] - -# Require that the XFR request from the NSD secondary be signed with the -# specified TSIG key. Without this, a TSIG signed XFR request with a known -# key will still be accepted, but an XFR request that is NOT TSIG signed -# would also be accepted. In the test we verify that TSIG is required by -# Cascade by attempting a dig without the required TSIG key and showing -# that it fails. -accept-xfr-from = ["127.0.0.1^tsig-key"] diff --git a/integration-tests/ixfr-out/policies/without-tsig.toml b/integration-tests/ixfr-out/policies/without-tsig.toml deleted file mode 100644 index 07c1d9359..000000000 --- a/integration-tests/ixfr-out/policies/without-tsig.toml +++ /dev/null @@ -1,23 +0,0 @@ -# Setup incremental signing to produce predictable outputs so that we can -# easily compare the generated zone against expected output. -# -# Enable sending of NOTIFY messages to the NSD secondary. -version = "v1" - -[signer.denial] -type = "nsec" - -[signer] -serial-policy = "keep" -signature-inception-offset = 0 -signature-lifetime = 100000000 - -[key-manager.records] -dnskey.signature-inception-offset = 0 -cds.signature-inception-offset = 0 -dnskey.signature-lifetime = 100000000 -cds.signature-lifetime = 100000000 - -[server.outbound] -# NSD secondary listens on port 1054. -send-notify-to = ["127.0.0.1:1054"] diff --git a/integration-tests/ixfr-out/reference-output/example.test.axfr b/integration-tests/ixfr-out/reference-output/example.test.axfr deleted file mode 100644 index c9577dd58..000000000 --- a/integration-tests/ixfr-out/reference-output/example.test.axfr +++ /dev/null @@ -1,17 +0,0 @@ -example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 -example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. iVSTY7uTaal9mRd44phalz9+oHlQtNDGPrQb4rvx3SkdTMT21l9PDCVL BxomZOiC51WhuQX+8FxSiLQcN+sfAw== -example.test. 3600 IN NS NS.example.test. -example.test. 3600 IN RRSIG NS 15 2 3600 20231114221320 20200913122640 580 example.test. hUD4MZwiVtXmHs+VueFIIrTUKYzWfOiNWm2nTR1gtsHTYnGjRrVEH/ic 3XeWDtKK0EedUE5iOKIEfQyYpaTLAg== -example.test. 3600 IN DNSKEY 256 3 15 u7Tg6xf6Xgt0y+yUT8CQi1Nyy+tRopVHnZOFQz0zQ5A= -example.test. 3600 IN RRSIG DNSKEY 15 2 3600 20231114221320 20200913122640 580 example.test. xGC6Pj6iN7tOjmhnLxmw4yQt7CCxCVouXvdB1P3lsQwN0iFfxgamkU7j EqIQcPpl20erYZ4XoWwtOdoeaXwtAg== -example.test. 5 IN NSEC JAIN-BB.example.test. NS SOA RRSIG NSEC DNSKEY -example.test. 5 IN RRSIG NSEC 15 2 5 20231114221320 20200913122640 580 example.test. P/YK6hlW/FOz43gKHqT4zoxRBdKFinx8GktOFDKuM+CWNyfBkjUZEI04 e3pa5/GUNcLKwRYAyWlNQnONPuusCQ== -NS.example.test. 3600 IN A 133.69.136.1 -NS.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. EsKLrDOszkF8dHi6K8XYed5EbqU9fSlNO2+25pBO/qZk1nZm1RyDO15X KWoCf1jUUNsTR4sZkiu3OGV7oazcAA== -NS.example.test. 5 IN NSEC example.test. A RRSIG NSEC -NS.example.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 580 example.test. hO9nn4+rxV08CFGkyXYzqGrRYNVOptJXZOaQLp6OSCMtte2iSQCq7UxJ p2KK2Q7aegqYKm6nNFoABmEYjZNlDQ== -JAIN-BB.example.test. 3600 IN A 133.69.136.3 -JAIN-BB.example.test. 3600 IN A 192.41.197.2 -JAIN-BB.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. gPZnGC1IeAz1VtEvYEV9jMLw7kFhwPKX9kMtH32/xC7zyYNOfmcWqffZ xbobEY8c/oS9z4WIiK8k4uFi90rQCA== -JAIN-BB.example.test. 5 IN NSEC NS.example.test. A RRSIG NSEC -JAIN-BB.example.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 580 example.test. /S/FZGh/OBQjwaVRPXJChDNkraEeD7qtVvIAa8db4bYmylI4KWOsH991 XDjye2aOv1+qPZxXIfzuX/3ER8JUBA== diff --git a/integration-tests/ixfr-out/reference-output/example.test.ixfr-1-3 b/integration-tests/ixfr-out/reference-output/example.test.ixfr-1-3 deleted file mode 100644 index 56beb1143..000000000 --- a/integration-tests/ixfr-out/reference-output/example.test.ixfr-1-3 +++ /dev/null @@ -1,73 +0,0 @@ -; The input zone was based on RFC 1995 section 7. This diff should look -; similar to "the following incremental message" from that section, but with -; DNSSEC changes present as well. Our zone name differs to that of the example -; in RFC 1995 as we have to match the zone name configured in our secondary NSD -; instance. - -; === Start of IXFR -example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 - -; [DIFF 1]: "NEZU.JAIN.AD.JP. is removed and JAIN-BB.JAIN.AD.JP. is added." -; [DIFF 1]: Records from serial 1 to be removed. - -; Changing the SOA serial requires that we regenerate the RRSIG, so the old -; RRSIG has to be removed. -example.test. 3600 IN SOA NS.example.test. mail.example.test. 1 60 60 3600 5 -example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. TlLDWFfpydqCQjy1oDPBXugGWraoyvvk83KdTsiq441t0/hYKmL2mFEH 2nwJ6fcuZT2hLpvl9gwk3bJCXrNdAQ== - -; Remove the A record and its associated RRSIG. -NEZU.example.test. 3600 IN A 133.69.136.5 -NEZU.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. k232/sABO752SeaFp3jHkIJMUGlDA0NG+iQh1gpGVsi07XqDce+JzNX4 NiWmz06kVu+SKu1HFHNvGJ3enh5/DQ== - -; Remove the A record from the NSEC type bitmap too, and also remove NSEC -; RRSIG as a new one will be added to replace it. -NEZU.example.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 580 example.test. BCpvhGfe+GW4GnyJBBiqjMpiVZhCXv9ZMdh+RF7qf07V3jIePWJThAy2 yV5O4I7NhKgl6rqpOyv2+aSoW8tVDw== -NEZU.example.test. 5 IN NSEC NS.example.test. A RRSIG NSEC - -; As the entire NEZU.example.test. label was removed this also requires the -; next owner name in the NSEC chain to be changed so we have to remove the -; current NSEC on the previous owner and its associated RRSIG, new ones will -; be added. -example.test. 5 IN NSEC NEZU.example.test. NS SOA RRSIG NSEC DNSKEY -example.test. 5 IN RRSIG NSEC 15 2 5 20231114221320 20200913122640 580 example.test. Hyrs5CKpXfCCNC9OK9+07Ei5qhRjQ9EQZK3up84BCwlflhOCGQppddzM eHK0Klgl0ywKaD7dfV2w3SWvP7vTDA== - -; [DIFF 1]: Records to be added for serial 2. - -; The new SOA with the updated SERIAL and its associated RRSIG. -example.test. 3600 IN SOA ns.example.test. mail.example.test. 2 60 60 3600 5 -example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. fYRpxmxV5HQl7wIPmuN+lrYQJalFuxi9iI0DX2/mM4kdh7q9GGutFTOZ kbdY4D+AZf25s08CZR1CPpd9o2Q3Dg== - -; A changed NSEC and associated RRSIG due to changes in the NSEC chain. -example.test. 5 IN NSEC JAIN-BB.example.test. NS SOA RRSIG NSEC DNSKEY -example.test. 5 IN RRSIG NSEC 15 2 5 20231114221320 20200913122640 580 example.test. P/YK6hlW/FOz43gKHqT4zoxRBdKFinx8GktOFDKuM+CWNyfBkjUZEI04 e3pa5/GUNcLKwRYAyWlNQnONPuusCQ== - -; Add new label JAIN-BB.example.test with two A records, associated NSEC and RRSIGs. -JAIN-BB.example.test. 3600 IN A 133.69.136.4 -JAIN-BB.example.test. 3600 IN A 192.41.197.2 -JAIN-BB.example.test. 5 IN NSEC NS.example.test. A RRSIG NSEC -JAIN-BB.example.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 580 example.test. /S/FZGh/OBQjwaVRPXJChDNkraEeD7qtVvIAa8db4bYmylI4KWOsH991 XDjye2aOv1+qPZxXIfzuX/3ER8JUBA== -JAIN-BB.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. XLFJlxjJDzmdwqF2BgxWN7Swfty8HD3ILb6Mo7fHKrfYt6x3uxXIUIjO JFVwFBhavLTrOlOcXdBGoNTvceqTDg== - -; [DIFF 2]: "One of the IP addresses of JAIN-BB.JAIN.AD.JP. is changed." -; [DIFF 2]: Records from serial 2 to be removed. - -; The old SOA and associated RRSIG. -example.test. 3600 IN SOA ns.example.test. mail.example.test. 2 60 60 3600 5 -example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. fYRpxmxV5HQl7wIPmuN+lrYQJalFuxi9iI0DX2/mM4kdh7q9GGutFTOZ kbdY4D+AZf25s08CZR1CPpd9o2Q3Dg== - -; The old A record and associated RRSIG. -JAIN-BB.example.test. 3600 IN A 133.69.136.4 -JAIN-BB.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. XLFJlxjJDzmdwqF2BgxWN7Swfty8HD3ILb6Mo7fHKrfYt6x3uxXIUIjO JFVwFBhavLTrOlOcXdBGoNTvceqTDg== - -; [DIFF 2]: Records to be added for serial 3. - -; The new SOA and associated RRSIG. -example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 -example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. iVSTY7uTaal9mRd44phalz9+oHlQtNDGPrQb4rvx3SkdTMT21l9PDCVL BxomZOiC51WhuQX+8FxSiLQcN+sfAw== - -; The updated A record and associated RRSIG. -JAIN-BB.example.test. 3600 IN A 133.69.136.3 -JAIN-BB.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. gPZnGC1IeAz1VtEvYEV9jMLw7kFhwPKX9kMtH32/xC7zyYNOfmcWqffZ xbobEY8c/oS9z4WIiK8k4uFi90rQCA== - -; End of IXFR -example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 diff --git a/integration-tests/ixfr-out/reference-output/example.test.ixfr-2-3 b/integration-tests/ixfr-out/reference-output/example.test.ixfr-2-3 deleted file mode 100644 index 56f9a7289..000000000 --- a/integration-tests/ixfr-out/reference-output/example.test.ixfr-2-3 +++ /dev/null @@ -1,10 +0,0 @@ -example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 -example.test. 3600 IN SOA ns.example.test. mail.example.test. 2 60 60 3600 5 -example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. fYRpxmxV5HQl7wIPmuN+lrYQJalFuxi9iI0DX2/mM4kdh7q9GGutFTOZ kbdY4D+AZf25s08CZR1CPpd9o2Q3Dg== -JAIN-BB.example.test. 3600 IN A 133.69.136.4 -JAIN-BB.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. XLFJlxjJDzmdwqF2BgxWN7Swfty8HD3ILb6Mo7fHKrfYt6x3uxXIUIjO JFVwFBhavLTrOlOcXdBGoNTvceqTDg== -example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 -example.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 580 example.test. iVSTY7uTaal9mRd44phalz9+oHlQtNDGPrQb4rvx3SkdTMT21l9PDCVL BxomZOiC51WhuQX+8FxSiLQcN+sfAw== -JAIN-BB.example.test. 3600 IN A 133.69.136.3 -JAIN-BB.example.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 580 example.test. gPZnGC1IeAz1VtEvYEV9jMLw7kFhwPKX9kMtH32/xC7zyYNOfmcWqffZ xbobEY8c/oS9z4WIiK8k4uFi90rQCA== -example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 diff --git a/integration-tests/ixfr-out/reference-output/example.test.ixfr-3-3 b/integration-tests/ixfr-out/reference-output/example.test.ixfr-3-3 deleted file mode 100644 index ebe4c34bc..000000000 --- a/integration-tests/ixfr-out/reference-output/example.test.ixfr-3-3 +++ /dev/null @@ -1 +0,0 @@ -example.test. 3600 IN SOA ns.example.test. mail.example.test. 3 60 60 3600 5 diff --git a/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.axfr b/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.axfr deleted file mode 100644 index 08e54a056..000000000 --- a/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.axfr +++ /dev/null @@ -1,17 +0,0 @@ -notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 -notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. caF70ZxIDm7UIXcj4RmkY5G+rcNFJp+wKpg17pUEz+N4P8+uxp3tYjYj 145UTqd36AUv4jsJgqEVN9roaZAbAw== -notify-and-xfr-tsig.test. 3600 IN NS NS.notify-and-xfr-tsig.test. -notify-and-xfr-tsig.test. 3600 IN RRSIG NS 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. YDqsJSHx81jK/GSqDdnNDG3JN9v9SH6jgmsgbQNElcaSCXqMrR2Q3QFq wrYW06FgnadRgTySRZ2evzS3dYGaDw== -notify-and-xfr-tsig.test. 3600 IN DNSKEY 256 3 15 0efFuDXcYUyhaTUWgir/RaWrzAJWAa58VuEF+391Taw= -notify-and-xfr-tsig.test. 3600 IN RRSIG DNSKEY 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. CIiRWX2DYaZRidc0nDl4zE0vmUCK2XFX2ekrJc1DVljIestEaJBAR2V1 ZQYOtCtb8y8AA/RCH8UFN2DYSM6+CQ== -notify-and-xfr-tsig.test. 5 IN NSEC JAIN-BB.notify-and-xfr-tsig.test. NS SOA RRSIG NSEC DNSKEY -notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 2 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. z7aQTz7VOeeSwwftFsHr+f2BG1k1mVTGztiqZNUAYBkT5R+IlhWpzNdR KvpUSMcdgSNSsjKLQciFq/vYSqg7DA== -NS.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.1 -NS.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. ziPT0QEZ/gGcVltE17tqgf3jqo9SfesIffux+pIZwC3vO8eg1MnzPJOv Kmdl9vJQqS03FjwT+6r3xtjIVIfwAA== -NS.notify-and-xfr-tsig.test. 5 IN NSEC notify-and-xfr-tsig.test. A RRSIG NSEC -NS.notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. nLNfo5CuJQd8qYbDywdefe0pFQ881jn3W6WEfNnkMg1daJk2ENcZUkiJ aeR7Hv7FPzTyavE2SQbuHfHrYDKoAA== -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.3 -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 192.41.197.2 -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0X5PtAfOES8cyYc9kFoXfFB5w5AHBtTSlw8V2wd+UehtSgHMDX0ebUig C65dQahRoe6oELz7UNY7rv+BLOMrAA== -JAIN-BB.notify-and-xfr-tsig.test. 5 IN NSEC NS.notify-and-xfr-tsig.test. A RRSIG NSEC -JAIN-BB.notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. Kn1CEC2iYr/uSRAZvM9SV2HRR0B8l8ObkvqYufiGO0en+OSLRmfgzviT Mdhdai9onUYr7ndQFHI8bSav5MBoCQ== diff --git a/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-1-3 b/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-1-3 deleted file mode 100644 index 33df5ea3e..000000000 --- a/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-1-3 +++ /dev/null @@ -1,27 +0,0 @@ -notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 -notify-and-xfr-tsig.test. 3600 IN SOA NS.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 1 60 60 3600 5 -NEZU.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.5 -notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 2 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0BtPWx9VOtcE1BMD3cLk1FR1YaT/1v32VuuxXh/XQgS97Nj80H+Qm57V ORpk/V6wkw2exuvz3ko2s2BfkaQqAQ== -notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. xzwl8jSWvas1L2Uqzfq8kGkPZzvTQyvrmv8Q3q+jVdTj7FIYCOXN3moZ MvasxJgo6euVesldGA0WzQ0UaIxyBw== -notify-and-xfr-tsig.test. 5 IN NSEC NEZU.notify-and-xfr-tsig.test. NS SOA RRSIG NSEC DNSKEY -NEZU.notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. kNDs5mxnK6tKyDu1fBIAg1SnynqRQM7afK+hmPkLJD0SkvKEItomMbui gKlgDdtA7YWA8zTh4j7bwUq/tBlYAQ== -NEZU.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. Etgfp3BL11ReT8j0UHQ5epj6l+mAH2+1Fmv/p74EQ3EsbpPiGx3c+96u XeRbWRCVVY/yCqH7E5j0NPHvFYhBAg== -NEZU.notify-and-xfr-tsig.test. 5 IN NSEC NS.notify-and-xfr-tsig.test. A RRSIG NSEC -notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 2 60 60 3600 5 -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.4 -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 192.41.197.2 -notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 2 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. z7aQTz7VOeeSwwftFsHr+f2BG1k1mVTGztiqZNUAYBkT5R+IlhWpzNdR KvpUSMcdgSNSsjKLQciFq/vYSqg7DA== -notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0Qn9RoFrnZrhs8qy/GFWrSQ0ZUx11s60vVAzhBR0MhX9k8731t6Is5ic VwajxP8l3NM1PSvB0Eqamo0cyy6rCA== -notify-and-xfr-tsig.test. 5 IN NSEC JAIN-BB.notify-and-xfr-tsig.test. NS SOA RRSIG NSEC DNSKEY -JAIN-BB.notify-and-xfr-tsig.test. 5 IN RRSIG NSEC 15 3 5 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. Kn1CEC2iYr/uSRAZvM9SV2HRR0B8l8ObkvqYufiGO0en+OSLRmfgzviT Mdhdai9onUYr7ndQFHI8bSav5MBoCQ== -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. SRcvE4FT1NE9uVZrrFv1f+xinA37/CZWac6bJ62REhIE2e90rbDr1i/e n31YVDZQOHeVTKZCuueVKXgPYyztCw== -JAIN-BB.notify-and-xfr-tsig.test. 5 IN NSEC NS.notify-and-xfr-tsig.test. A RRSIG NSEC -notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 2 60 60 3600 5 -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.4 -notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0Qn9RoFrnZrhs8qy/GFWrSQ0ZUx11s60vVAzhBR0MhX9k8731t6Is5ic VwajxP8l3NM1PSvB0Eqamo0cyy6rCA== -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. SRcvE4FT1NE9uVZrrFv1f+xinA37/CZWac6bJ62REhIE2e90rbDr1i/e n31YVDZQOHeVTKZCuueVKXgPYyztCw== -notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.3 -notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. caF70ZxIDm7UIXcj4RmkY5G+rcNFJp+wKpg17pUEz+N4P8+uxp3tYjYj 145UTqd36AUv4jsJgqEVN9roaZAbAw== -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0X5PtAfOES8cyYc9kFoXfFB5w5AHBtTSlw8V2wd+UehtSgHMDX0ebUig C65dQahRoe6oELz7UNY7rv+BLOMrAA== -notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 diff --git a/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-2-3 b/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-2-3 deleted file mode 100644 index 9b3966fe2..000000000 --- a/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-2-3 +++ /dev/null @@ -1,10 +0,0 @@ -notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 -notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 2 60 60 3600 5 -notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0Qn9RoFrnZrhs8qy/GFWrSQ0ZUx11s60vVAzhBR0MhX9k8731t6Is5ic VwajxP8l3NM1PSvB0Eqamo0cyy6rCA== -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.4 -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. SRcvE4FT1NE9uVZrrFv1f+xinA37/CZWac6bJ62REhIE2e90rbDr1i/e n31YVDZQOHeVTKZCuueVKXgPYyztCw== -notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 -notify-and-xfr-tsig.test. 3600 IN RRSIG SOA 15 2 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. caF70ZxIDm7UIXcj4RmkY5G+rcNFJp+wKpg17pUEz+N4P8+uxp3tYjYj 145UTqd36AUv4jsJgqEVN9roaZAbAw== -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN A 133.69.136.3 -JAIN-BB.notify-and-xfr-tsig.test. 3600 IN RRSIG A 15 3 3600 20231114221320 20200913122640 10995 notify-and-xfr-tsig.test. 0X5PtAfOES8cyYc9kFoXfFB5w5AHBtTSlw8V2wd+UehtSgHMDX0ebUig C65dQahRoe6oELz7UNY7rv+BLOMrAA== -notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 diff --git a/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-3-3 b/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-3-3 deleted file mode 100644 index 1808ea302..000000000 --- a/integration-tests/ixfr-out/reference-output/notify-and-xfr-tsig.test.ixfr-3-3 +++ /dev/null @@ -1 +0,0 @@ -notify-and-xfr-tsig.test. 3600 IN SOA ns.notify-and-xfr-tsig.test. mail.notify-and-xfr-tsig.test. 3 60 60 3600 5 diff --git a/integration-tests/tests/ixfr-out/action.yml b/integration-tests/tests/ixfr-out/action.yml deleted file mode 100644 index a627272be..000000000 --- a/integration-tests/tests/ixfr-out/action.yml +++ /dev/null @@ -1,324 +0,0 @@ -# Making reusable composite actions documented at -# https://docs.github.com/en/actions/tutorials/create-actions/create-a-composite-action#creating-a-composite-action-within-the-same-repository -name: 'Serve a zone via IXFR to the NSD secondary.' -description: 'Serve a zone via IXFR to the NSD secondary.' -defaults: - # see: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defaultsrunshell - run: - shell: bash --noprofile --norc -eo pipefail -x {0} -inputs: - log-level: - description: The level of logging that Cascade should output. - required: false - default: debug - type: choice - options: - - error - - warning - - info - - debug - - trace - with-tsig: - description: Whether or not TSIG should be required for XFR out. - required: true - type: boolean -runs: - using: "composite" - steps: - - uses: ./.github/actions/prepare-systest-env - - uses: ./.github/actions/setup-and-start-cascade - with: - log-level: ${{ inputs.log-level }} - fake-time: 1600000000 - - - name: Determine the downstrem secondary zone config to use - id: set-vars - run: | - if [[ "${{ inputs.with-tsig }}" == "true" ]]; then - ZONE_NAME="notify-and-xfr-tsig.test." - POLICY_NAME="with-tsig" - KEY_NAME="Knotify-and-xfr-tsig.test.+015+10995.key" - else - ZONE_NAME="example.test." - POLICY_NAME="without-tsig" - KEY_NAME="Kexample.test.+015+00580.key" - fi - echo "ZONE_NAME=${ZONE_NAME}" >> "$GITHUB_OUTPUT" - echo "POLICY_NAME=${POLICY_NAME}" >> "$GITHUB_OUTPUT" - echo "KEY_NAME=${KEY_NAME}" >> "$GITHUB_OUTPUT" - echo "TSIG_SPEC=hmac-sha256:tsig-key:COzoVsYQmXeXiyq1Quhp0bbVnMyxjPxsaGSoIWR98i0=" >> "$GITHUB_OUTPUT" - - - name: Add the TSIG key - if: ${{ inputs.with-tsig }} - run: | - cascade tsig add ${{ steps.set-vars.outputs.TSIG_SPEC }} - - - name: Add policy ${{ steps.set-vars.outputs.POLICY_NAME }} and reload - run: | - POLICY_DIR=$(integration-tests/scripts/get-default-path.sh policy-dir) - TEST_DIR="${PWD}/integration-tests/ixfr-out" - cp "${TEST_DIR}/policies/${{ steps.set-vars.outputs.POLICY_NAME }}.toml" "${POLICY_DIR}/" - cascade policy reload - - # The zone name has to match a zone configured in the downstream NSD - # nameserver. Zone 'notify-and-xfr-tsig.test' is a zone in the downstream - # NSD nameserver that is configured to expect TSIG to be used for the - # NOTIFY message it is sent, to use IXFR (as opposed to only AXFR) and for - # it to need to use TSIG to request an IXFR transfer from Cascade. - - name: Make a ${{ steps.set-vars.outputs.ZONE_NAME }} zone based on RFC 1995 section 7 - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - cat >"${ZONE_NAME}zone" < (start + timeout))); then - cascade zone status ${ZONE_NAME} - echo "timeout: zone status did not report published zone available" >&2 - exit 1 - fi - sleep 1 - done - - - name: Expect serial 1 of zone ${{ steps.set-vars.outputs.ZONE_NAME }} at the NSD secondary - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - timeout=10 # seconds - start=$(date +%s) - until dig +short @127.0.0.1 -p 1054 ${ZONE_NAME} SOA | grep -q "ns.${ZONE_NAME} mail.${ZONE_NAME} 1 60 60 3600 5"; do - if (($(date +%s) > (start + timeout))); then - cascade zone status ${ZONE_NAME} - dig +short @127.0.0.1 -p 1054 ${ZONE_NAME} SOA - echo "::error:: timeout: NSD did not acquire the zone changes" - exit 1 - fi - sleep 1 - done - - - name: Edit and reload serial 2 of zone ${{ steps.set-vars.outputs.ZONE_NAME }} - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - cat >"${ZONE_NAME}zone" < (start + timeout))); then - cascade zone status ${ZONE_NAME} - echo "timeout: zone status did not report published zone available" >&2 - exit 1 - fi - sleep 1 - done - - - name: Expect serial 2 of zone ${{ steps.set-vars.outputs.ZONE_NAME }} at the NSD secondary - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - timeout=10 # seconds - start=$(date +%s) - until dig +short @127.0.0.1 -p 1054 ${ZONE_NAME} SOA | grep -q "ns.${ZONE_NAME} mail.${ZONE_NAME} 2 60 60 3600 5"; do - if (($(date +%s) > (start + timeout))); then - dig @127.0.0.1 -p 1054 ${ZONE_NAME} SOA - cascade zone status ${ZONE_NAME} - echo "::error:: timeout: NSD did not acquire the zone changes" - exit 1 - fi - sleep 1 - done - - - name: Edit and reload serial 3 of zone ${{ steps.set-vars.outputs.ZONE_NAME }} - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - cat >"${ZONE_NAME}zone" < (start + timeout))); then - cascade zone status ${ZONE_NAME} - echo "timeout: zone status did not report published zone available" >&2 - exit 1 - fi - sleep 1 - done - - - name: Expect serial 3 of zone ${{ steps.set-vars.outputs.ZONE_NAME }} at the NSD secondary - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - timeout=10 # seconds - start=$(date +%s) - until dig +short @127.0.0.1 -p 1054 ${ZONE_NAME} SOA | grep -q "ns.${ZONE_NAME} mail.${ZONE_NAME} 3 60 60 3600 5"; do - if (($(date +%s) > (start + timeout))); then - cascade zone status ${ZONE_NAME} - dig +short @127.0.0.1 -p 1054 ${ZONE_NAME} SOA - echo "::error:: timeout: NSD did not acquire the zone changes" - exit 1 - fi - sleep 1 - done - - # Note: There is no NSD metric that I am aware of that we can use to - # verify that it received IXFR from Cascade instead of AXFR, nor is there - # any Cascade Prometheus metric or CLI command that we can use to check - # this either. Increasing the NSD log level to 9 doesn't cause it to - # report whether it received IXFR or AXFR either. - - - name: Verify that TSIG is required by Cascade - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - if [[ "${{ inputs.with-tsig }}" == "false" ]]; then - exit 0 - fi - - # This should not succeed as it lacks the required TSIG key. - # Note that dig will exit with code 0 even if the request fails, - # because it considers an error response still to be successful - # receipt of a response, so we have to check the actual output. - dig @127.0.0.1 +yaml -p 4542 ${ZONE_NAME} AXFR &> dig-no-tsig.log - grep -Fq "status: REFUSED" dig-no-tsig.log - - - name: Verify Cascade AXFR out - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - EXTRA_ARGS= - if [[ "${{ inputs.with-tsig }}" == "true" ]]; then - EXTRA_ARGS="-y ${{ steps.set-vars.outputs.TSIG_SPEC }}" - fi - dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${EXTRA_ARGS} ${ZONE_NAME} AXFR &> cascade-axfr.log - TEST_DIR="${PWD}/integration-tests/ixfr-out" - LC_COLLATE=C sort "${TEST_DIR}/reference-output/${ZONE_NAME}axfr" | egrep -v '^;|^$' > reference.output.sorted - LC_COLLATE=C sort cascade-axfr.log > output.sorted - diff -u -w output.sorted reference.output.sorted - - # A UDP IXFR request should result in a single SOA response "to inform the - # client that a TCP query should be initiated". - - name: Verify that UDP is not supported - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - EXTRA_ARGS= - if [[ "${{ inputs.with-tsig }}" == "true" ]]; then - EXTRA_ARGS="-y ${{ steps.set-vars.outputs.TSIG_SPEC }}" - fi - SOA_RR=$(dig +short +notcp +ignore @127.0.0.1 -p 4542 ${ZONE_NAME} -t IXFR=1 ${EXTRA_ARGS} 2>&1 | tee dig-udp-ixfr.log) - if [[ "${SOA_RR}" != "ns.${ZONE_NAME} mail.${ZONE_NAME} 3 60 60 3600 5" ]]; then - exit 1 - fi - - - name: Verify Cascade IXFR serial 1..=3 out - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - EXTRA_ARGS= - if [[ "${{ inputs.with-tsig }}" == "true" ]]; then - EXTRA_ARGS="-y ${{ steps.set-vars.outputs.TSIG_SPEC }}" - fi - dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${ZONE_NAME} -t IXFR=1 ${EXTRA_ARGS} &> cascade-ixfr-1-3.log - TEST_DIR="${PWD}/integration-tests/ixfr-out" - LC_COLLATE=C sort "${TEST_DIR}/reference-output/${ZONE_NAME}ixfr-1-3" | egrep -v '^;|^$' > reference.output.sorted - LC_COLLATE=C sort cascade-ixfr-1-3.log > output.sorted - diff -u -w output.sorted reference.output.sorted - - - name: Verify Cascade IXFR serial 2..=3 out - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - EXTRA_ARGS= - if [[ "${{ inputs.with-tsig }}" == "true" ]]; then - EXTRA_ARGS="-y ${{ steps.set-vars.outputs.TSIG_SPEC }}" - fi - dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${ZONE_NAME} -t IXFR=2 ${EXTRA_ARGS} &> cascade-ixfr-2-3.log - TEST_DIR="${PWD}/integration-tests/ixfr-out" - LC_COLLATE=C sort "${TEST_DIR}/reference-output/${ZONE_NAME}ixfr-2-3" | egrep -v '^;|^$' > reference.output.sorted - LC_COLLATE=C sort cascade-ixfr-2-3.log > output.sorted - diff -u -w output.sorted reference.output.sorted - - - name: Verify Cascade IXFR serial 3..=3 out - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - EXTRA_ARGS= - if [[ "${{ inputs.with-tsig }}" == "true" ]]; then - EXTRA_ARGS="-y ${{ steps.set-vars.outputs.TSIG_SPEC }}" - fi - dig +onesoa +noall +answer @127.0.0.1 -p 4542 ${ZONE_NAME} -t IXFR=3 ${EXTRA_ARGS} &> cascade-ixfr-3-3.log - TEST_DIR="${PWD}/integration-tests/ixfr-out" - LC_COLLATE=C sort "${TEST_DIR}/reference-output/${ZONE_NAME}ixfr-3-3" | egrep -v '^;|^$' > reference.output.sorted - LC_COLLATE=C sort cascade-ixfr-3-3.log > output.sorted - diff -u -w output.sorted reference.output.sorted - - - name: Verify NSD AXFR out - env: - ZONE_NAME: ${{ steps.set-vars.outputs.ZONE_NAME }} - run: | - dig +onesoa +noall +answer @127.0.0.1 -p 1054 ${ZONE_NAME} AXFR &> nsd-axfr.log - TEST_DIR="${PWD}/integration-tests/ixfr-out" - - # Pass -f to sort and -i to diff to normalize case in the Cascade and - # NSD output being compared, as NSD outputs lower case labels while - # Cascade preserves label case. -f tells sort not to order differently - # based on case, and -i tells diff to consider differing case to be - # equal. - - LC_COLLATE=C sort -f "${TEST_DIR}/reference-output/${ZONE_NAME}axfr" | egrep -v '^;|^$' > reference.output.sorted - LC_COLLATE=C sort -f nsd-axfr.log > output.sorted - diff -u -w -i output.sorted reference.output.sorted - - - name: Print log files on any failure in this job - uses: ./.github/actions/print-logfiles - if: failure() From df3950059402e3e27a38a942a86a595b38ba7747 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 12 May 2026 16:21:03 +0200 Subject: [PATCH 42/81] Merge fix. Validated with the ixfr-out system test from PR #650. --- src/persistence/persist.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 2e35fab96..a298bc304 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -181,9 +181,10 @@ pub fn persist_signed( // MUST always have a new SOA SERIAL compared to the previous version // of the signed zone. + let partial_diff = state.storage.diffs.last_mut().unwrap(); let loaded_diff = loaded_diff.cloned().unwrap_or(DiffData::new().into()); let complete_diff = (loaded_diff, signed_diff.clone()); - state.storage.diffs.push(complete_diff); + *partial_diff = complete_diff; } persister.mark_complete() From da676289a8402f0c484b5b61176bfcc76c92c960 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 12 May 2026 16:31:31 +0200 Subject: [PATCH 43/81] Remove change to system-tests that isn't needed by this PR. --- integration-tests/scripts/manage-test-environment.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/integration-tests/scripts/manage-test-environment.sh b/integration-tests/scripts/manage-test-environment.sh index 32180fc8c..f33036dc0 100755 --- a/integration-tests/scripts/manage-test-environment.sh +++ b/integration-tests/scripts/manage-test-environment.sh @@ -298,7 +298,7 @@ pattern: name: secondary zonefile: "%s.secondary-zone" allow-notify: 127.0.0.1 NOKEY - request-xfr: 127.0.0.1@${_cascade_port} NOKEY + request-xfr: AXFR 127.0.0.1@${_cascade_port} NOKEY provide-xfr: 127.0.0.1 NOKEY zone: @@ -309,21 +309,21 @@ zone: name: notify-tsig.test zonefile: "notify-tsig.test.secondary-zone" allow-notify: 127.0.0.1 tsig-key - request-xfr: 127.0.0.1@${_cascade_port} NOKEY + request-xfr: AXFR 127.0.0.1@${_cascade_port} NOKEY provide-xfr: 127.0.0.1 NOKEY zone: name: xfr-tsig.test zonefile: "xfr-tsig.test.secondary-zone" allow-notify: 127.0.0.1 NOKEY - request-xfr: 127.0.0.1@${_cascade_port} tsig-key + request-xfr: AXFR 127.0.0.1@${_cascade_port} tsig-key provide-xfr: 127.0.0.1 NOKEY zone: name: notify-and-xfr-tsig.test zonefile: "notify-and-xfr-tsig.test.secondary-zone" allow-notify: 127.0.0.1 tsig-key - request-xfr: 127.0.0.1@${_cascade_port} tsig-key + request-xfr: AXFR 127.0.0.1@${_cascade_port} tsig-key provide-xfr: 127.0.0.1 NOKEY EOF From fabd541ddfd459e2a3812beccdfd8ce0247af127 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 12 May 2026 16:32:16 +0200 Subject: [PATCH 44/81] Add back accidentally removed comment. --- integration-tests/scripts/manage-test-environment.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/integration-tests/scripts/manage-test-environment.sh b/integration-tests/scripts/manage-test-environment.sh index f33036dc0..349ce5d69 100755 --- a/integration-tests/scripts/manage-test-environment.sh +++ b/integration-tests/scripts/manage-test-environment.sh @@ -298,6 +298,7 @@ pattern: name: secondary zonefile: "%s.secondary-zone" allow-notify: 127.0.0.1 NOKEY + # Until Cascade supports IXFR we always use AXFR request-xfr: AXFR 127.0.0.1@${_cascade_port} NOKEY provide-xfr: 127.0.0.1 NOKEY From 97ed92b0926ee77b040e703d43eed8792c760e43 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 12 May 2026 16:34:41 +0200 Subject: [PATCH 45/81] Remove unnnecessary temporary diagnostic Debug derive. --- crates/zonedata/src/diff.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/zonedata/src/diff.rs b/crates/zonedata/src/diff.rs index 8645db4d6..a66c99b8a 100644 --- a/crates/zonedata/src/diff.rs +++ b/crates/zonedata/src/diff.rs @@ -19,7 +19,7 @@ use crate::{RegularRecord, SoaRecord}; /// [`DiffData`] can be used to store the data for an old zone (where it is the /// base, and the next newer zone is the target). This is perfect for serving /// IXFR requests. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Default)] pub struct DiffData { /// The SOA record to remove. /// From 4f9d86d61f28a530b6cae696adbbc5a6d527a72e Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 12 May 2026 16:44:04 +0200 Subject: [PATCH 46/81] Remove duplicate code resulting from merge. --- src/persistence/persist.rs | 55 +++++++++++--------------------------- 1 file changed, 15 insertions(+), 40 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index a298bc304..e1a6c2ef7 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -125,16 +125,13 @@ pub fn persist_signed( .push(destination.into()); handle.zone.mark_dirty(handle.state, handle.center); - // Store the diffs in-memory for serving IXFR. + // Store the diffs in-memory for serving IXFR out. // // Only store a diff if the SOA from the previous version of the // signed zone was removed and a new one added, otherwise this is not // a diff to a previous version of the zone but actually a snapshot of // the zone after having been signed for the first time. - if let Some(loaded_diff) = loaded_diff - && signed_diff.removed_soa.is_some() - && signed_diff.removed_soa != signed_diff.added_soa - { + if signed_diff.removed_soa.is_some() && signed_diff.removed_soa != signed_diff.added_soa { // Store anything that changed when the zone was re-loaded, i.e. // unsigned zone content changes. Note that the SOA SERIAL is not // required to change unless using 'keep' policy and so we should @@ -147,46 +144,24 @@ pub fn persist_signed( // expiring. Signed zones MUST always have a new SOA SERIAL // compared to the previous version of the signed zone. - let complete_diff = (loaded_diff.clone(), signed_diff.clone()); - state.storage.diffs.push(complete_diff); + let partial_diff = state.storage.diffs.last_mut().unwrap(); + let loaded_diff = loaded_diff.cloned().unwrap_or(DiffData::new().into()); trace!( - "Stored IXFR diff for SOA serial {} -> {}", - loaded_diff.removed_soa.as_ref().unwrap().rdata.serial, - signed_diff.added_soa.as_ref().unwrap().rdata.serial, + "Storing IXFR diff for SOA serial {:?} -> {:?}", + loaded_diff + .removed_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), + signed_diff + .added_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), ); + let complete_diff = (loaded_diff, signed_diff.clone()); + *partial_diff = complete_diff; } } - // Store the signed diff in-memory for serving IXFR out. - // - // Only store a diff if the SOA from the previous version of the signed - // zone was removed and a new one added, otherwise this is not a diff to a - // previous version of the zone but actually a snapshot of the zone after - // having been signed for the first time. - let loaded_diff = persister.loaded_diff(); - let signed_diff = persister.signed_diff(); - - if signed_diff.removed_soa.is_some() && signed_diff.removed_soa != signed_diff.added_soa { - let mut state = zone.state.lock().unwrap(); - - // Store anything that changed when the zone was re-loaded, i.e. - // unsigned zone content changes. Note that the SOA SERIAL is not - // required to change unless using 'keep' policy and so we should not - // require the SOA to have been removed and a new one added. - - // Store anything that changed when the zone was re-signed, i.e. - // changes DNSSEC RRs that can be caused by unsigned content changes - // or changing from NSEC <-> NSEC3 or using a new key to sign with or - // just regenerating signatures to avoid them expiring. Signed zones - // MUST always have a new SOA SERIAL compared to the previous version - // of the signed zone. - - let partial_diff = state.storage.diffs.last_mut().unwrap(); - let loaded_diff = loaded_diff.cloned().unwrap_or(DiffData::new().into()); - let complete_diff = (loaded_diff, signed_diff.clone()); - *partial_diff = complete_diff; - } - persister.mark_complete() } From f9f4bfa3c7163ed8cdcd668cdbcd6c770dc70d93 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 12 May 2026 16:53:15 +0200 Subject: [PATCH 47/81] Remove reference to ixfr-out system test as that now has its own PR. --- integration-tests/system-tests.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/integration-tests/system-tests.yml b/integration-tests/system-tests.yml index 3e8d0ad5e..a2c43b201 100644 --- a/integration-tests/system-tests.yml +++ b/integration-tests/system-tests.yml @@ -166,22 +166,6 @@ jobs: with: log-level: ${{ inputs.log-level }} - ixfr-out: - name: Serve a zone via IXFR to the NSD secondary. - runs-on: ubuntu-latest - strategy: - matrix: - with-tsig: [true, false] - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/set-build-profile - with: - build-profile: ${{ inputs.build-profile }} - - uses: ./integration-tests/tests/ixfr-out - with: - log-level: ${{ inputs.log-level }} - with-tsig: ${{ matrix.with-tsig }} - incremental-signing: name: Test incremental signing. runs-on: ubuntu-latest From 51e37473a239806760d8543b66fa75f4212512a3 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 14 May 2026 08:35:58 +0200 Subject: [PATCH 48/81] Review feedback: restore instrumentation log level. --- src/persistence/restore.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index a1a239161..364add54e 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -28,7 +28,7 @@ use crate::{center::Center, zone::Zone}; /// Restore the loaded instance data of a zone. #[tracing::instrument( - level = "info", + level = "trace", skip_all, fields(zone = %zone.name), )] @@ -129,7 +129,7 @@ pub fn restore_loaded( /// Restore the loaded instance data of a zone. #[tracing::instrument( - level = "info", + level = "trace", skip_all, fields(zone = %zone.name), )] From 2952b93668a10244421ada98c1c0a73015d03c72 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 14 May 2026 08:45:40 +0200 Subject: [PATCH 49/81] Note a possible structural improvement to be made later. --- src/zone/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/zone/mod.rs b/src/zone/mod.rs index 3b757b0f4..d764ab168 100644 --- a/src/zone/mod.rs +++ b/src/zone/mod.rs @@ -262,11 +262,13 @@ pub struct ZoneState { /// Locations of persisted unsigned zone diffs to enable IXFR from /// the upstream to resume on restart, and to enable a complete latest /// unsigned version of the zone to be reconstituted. + // TODO: Move into `PersistenceState`. pub persisted_loaded_diff_paths: Vec, /// Locations of persisted signed zone diffs to ensure IXFR out toward /// downstreams is still possible after restart, and to enable a complete /// latest signed version of the zone to be reconsituted. + // TODO: Move into `PersistenceState`. pub persisted_signed_diff_paths: Vec, /// Loading new versions of the zone. From 90d1618ceca3042cdec5389ee3a35eb2296eef52 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 22 May 2026 10:23:04 +0200 Subject: [PATCH 50/81] Review feedback: Don't reload disk zones on startup. As the operator may be in the process of editing them and we thus require an explicit `cascade zone reload` command to be used to reload on-disk zones. --- src/loader/mod.rs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/loader/mod.rs b/src/loader/mod.rs index 39c8c7e44..1ca5e6494 100644 --- a/src/loader/mod.rs +++ b/src/loader/mod.rs @@ -51,16 +51,27 @@ impl Loader { /// Initialize the loader, synchronously. pub fn init(center: &Arc
, state: &mut State) { - // Enqueue refreshes for all known zones. + // Enqueue refreshes for all known upstream zones. for zone in &state.zones { let mut state = zone.0.state.lock().unwrap(); - ZoneHandle { - zone: &zone.0, - state: &mut state, - center, + match state.loader.source { + Source::None => { /* Nothing to do */ } + Source::Zonefile { .. } => { + // Don't enqueue a refresh for zones sourced from disk as + // the operator may in the middle of editing the zonefile + // and thus we require zonefiles to be reloaded explicitly + // via `cascade zone reload`. + } + Source::Server { .. } => { + ZoneHandle { + zone: &zone.0, + state: &mut state, + center, + } + .loader() + .enqueue_refresh(false); + } } - .loader() - .enqueue_refresh(false); } } From d60fc0610621968d86edaa01611ff408d7571e6b Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 22 May 2026 11:11:31 +0200 Subject: [PATCH 51/81] Review feedback: Persist state now, and don't fail if prior persisted zone data is found. --- src/persistence/persist.rs | 121 +++++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 46 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index e1a6c2ef7..8d9e491e2 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -2,7 +2,7 @@ use std::{ fs::File, - io::{BufWriter, Write}, + io::{BufWriter, ErrorKind, Write}, path::Path, sync::Arc, }; @@ -12,11 +12,11 @@ use cascade_zonedata::{ }; use domain::new::base::wire::{BuildBytes, TruncationError}; -use tracing::trace; +use tracing::{trace, warn}; use crate::{ center::Center, - zone::{Zone, ZoneHandle}, + zone::{Zone, ZoneHandle, save_state_now}, }; /// Persist the data for a loaded instance of a zone. @@ -38,23 +38,25 @@ pub fn persist_loaded( // suffixed by an index which rises by one when persisted. // TODO: Don't keep an unlimited number of diffs. // TODO: Compact diffs when idle? - let mut state = zone.state.lock().unwrap(); - let handle = ZoneHandle { - zone, - state: &mut state, - center, - }; - let next_idx = handle.state.persisted_loaded_diff_paths.len(); - let destination = center - .config - .zone_state_dir - .join(format!("{}.loaded.{next_idx}", zone.name)); - persist_to_file(destination.as_std_path(), persister.loaded_diff().clone()); - handle - .state - .persisted_loaded_diff_paths - .push(destination.into()); - handle.zone.mark_dirty(handle.state, handle.center); + { + let mut state = zone.state.lock().unwrap(); + let handle = ZoneHandle { + zone, + state: &mut state, + center, + }; + let next_idx = handle.state.persisted_loaded_diff_paths.len(); + let destination = center + .config + .zone_state_dir + .join(format!("{}.loaded.{next_idx}", zone.name)); + persist_to_file(destination.as_std_path(), persister.loaded_diff().clone()); + handle + .state + .persisted_loaded_diff_paths + .push(destination.into()); + } + save_state_now(center, zone); } // Store the loaded diff in-memory for serving IXFR out. @@ -104,26 +106,28 @@ pub fn persist_signed( // index which rises by one when persisted. // TODO: Don't keep an unlimited number of diffs. // TODO: Compact diffs when idle? - let mut state = zone.state.lock().unwrap(); - let handle = ZoneHandle { - zone, - state: &mut state, - center, - }; - let next_idx = handle.state.persisted_signed_diff_paths.len(); - let destination = center - .config - .zone_state_dir - .join(format!("{}.signed.{next_idx}", zone.name)); - - // Write the diff to disk as a binary AXFR snapshot or binary IXFR - // diff. - persist_to_file(destination.as_std_path(), signed_diff.clone()); - handle - .state - .persisted_signed_diff_paths - .push(destination.into()); - handle.zone.mark_dirty(handle.state, handle.center); + { + let mut state = zone.state.lock().unwrap(); + let handle = ZoneHandle { + zone, + state: &mut state, + center, + }; + let next_idx = handle.state.persisted_signed_diff_paths.len(); + let destination = center + .config + .zone_state_dir + .join(format!("{}.signed.{next_idx}", zone.name)); + + // Write the diff to disk as a binary AXFR snapshot or binary IXFR + // diff. + persist_to_file(destination.as_std_path(), signed_diff.clone()); + handle + .state + .persisted_signed_diff_paths + .push(destination.into()); + } + save_state_now(center, zone); // Store the diffs in-memory for serving IXFR out. // @@ -144,6 +148,7 @@ pub fn persist_signed( // expiring. Signed zones MUST always have a new SOA SERIAL // compared to the previous version of the signed zone. + let mut state = zone.state.lock().unwrap(); let partial_diff = state.storage.diffs.last_mut().unwrap(); let loaded_diff = loaded_diff.cloned().unwrap_or(DiffData::new().into()); trace!( @@ -169,12 +174,36 @@ pub fn persist_signed( fn persist_to_file(destination: &Path, diff: Arc) { // Write the diff in AXFR / IXFR wire format to disk. - let f = File::create_new(destination).unwrap_or_else(|err| { - panic!( - "Failed to persist unsigned zone data to '{}': {err}", - destination.display() - ); - }); + let f = match File::create_new(destination) { + Ok(f) => f, + Err(err) if err.kind() == ErrorKind::AlreadyExists => { + // This is not expected. When persisting the zone data to a file + // we save Cascade zone state "now" so that the persisted paths in + // use are known on next restart, so we should know this path was + // in use and be attempting to write to a different non-existing + // path. If for some reason zone state was not persisted after the + // persisted zone data file was created, e.g. a power outage in + // combination with a change to persistence logic compared to how + // it is at the time of writing so that zone state was not ensured + // to be persisted before proceeding, that could cause this. + warn!( + "Overwriting existing persisted zone data file at '{}'.", + destination.display() + ); + File::create(destination).unwrap_or_else(|err| { + panic!( + "Failed to persist zone data to '{}': {err}", + destination.display() + ); + }) + } + Err(err) => { + panic!( + "Failed to persist zone data to '{}': {err}", + destination.display() + ); + } + }; let mut f = BufWriter::new(f); let mut buf = vec![0u8; 1024]; From aea5dd461dfd6679107f49d84abfd69adc663fe3 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 22 May 2026 12:12:30 +0200 Subject: [PATCH 52/81] Correct English typo. --- src/loader/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/loader/mod.rs b/src/loader/mod.rs index 1ca5e6494..89584164e 100644 --- a/src/loader/mod.rs +++ b/src/loader/mod.rs @@ -57,10 +57,10 @@ impl Loader { match state.loader.source { Source::None => { /* Nothing to do */ } Source::Zonefile { .. } => { - // Don't enqueue a refresh for zones sourced from disk as - // the operator may in the middle of editing the zonefile - // and thus we require zonefiles to be reloaded explicitly - // via `cascade zone reload`. + // Don't enqueue a refresh for zones sourced from disk + // as the operator be may in the middle of editing the + // zonefile and thus we require zonefiles to be reloaded + // explicitly via `cascade zone reload`. } Source::Server { .. } => { ZoneHandle { From 4f73c852619bc445700347dc6611f08d8b920ec9 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 22 May 2026 13:02:34 +0200 Subject: [PATCH 53/81] Add some developer documentation about persistence and restore. --- src/persistence/persist.rs | 84 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 8d9e491e2..a7e4e0293 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -1,4 +1,88 @@ //! Persisting zone data. +//! +//! When re-starting Cascade in-memory zone and IXFR diff data will be lost +//! unless persisted and restored. This module provides implements +//! persistence and restoration using files on disk stored in the zone-state +//! directory alongside the JSON '.db' zone state files. +//! +//! # Data format +//! +//! Data is persisted as AXFR and IXFR message ANSWER sections in wire format. +//! +//! # Persistence +//! +//! Persistence is invoked immediately after zone approval, either because of +//! a successful review hook, or no review hook at all, or because the +//! operator overrode a failed review hook. +//! +//! Persisted data is stored separately for records received loading the +//! zone vs changes that occur to the zone as a result of (re)signing it. +//! +//! For both loaded and signed changes, persistence stores an initial snapshot +//! and a sequence of zero or more diffs: +//! - A loaded snapshot file is written immediately after approval of the +//! initial version of a zone is received, whether from disk or via +//! XFR-in. This file has the name .loaded.0. +//! - A loaded diff file is written each time the input zone is reloaded, +//! whether due to XFR-in or due to reloading of the input file from disk. +//! Loaded diff files are named .loaded.N where N > 0 and +//! increases by one each time a new diff is persisted. +//! - A signed snapshot file is written immediately after approval of the +//! first signed version of the zone resulting from full zone signing. +//! This file has the name .signed.0. +//! - A signed diff file is written each time the zone is re-signed, whether +//! due to changes in the input zone or changes in the signing keys or the +//! replacing of signatures for records whose signatures need refreshing. +//! Signed diff files are named .signed.N where N > 0 and +//! increases by one each time a new diff is persisted. +//! +//! After a diff is persisted successfully: +//! - The diff is stored in memory alongside the zone so that it can be +//! served in response to an IXFR request from a downstream nameserver. +//! - The path that the diff file was written to is appended to a collection +//! of paths held in zone state and the zone state is immediately +//! saved to disk. +//! +//! If persistence fails due to an I/O error this will cause Cascade to panic. +//! If the underlying storage that Cascade depends on is not reliable we have +//! no way of knowing what else may be failing and abort as it is not safe to +//! continue under such circumstances. +//! +//! # Restoration +//! +//! Zones are created in memory at Cascade startup in storage state +//! RestoringLoaded. If the zone loader attempts to start loading the zone the +//! load will fail because the zone storage is not yet in the Passive state. +//! Instead a refresh will be enqueud and acted upon once the zone storage +//! enters the passive state. +//! +//! On startup Cascade starts a zone restorer which will attempt to restore +//! all known zones. If restoration fails the zone will move to the passive +//! state and any subsequent load of data will be handled as usual. As long +//! as the last used serial number was successfully persisted to state and +//! restored from state the newly signed zone will receive a higher serial +//! number than the last published zone that we failed to restore. Failure +//! to restore also results in deletion of all persisted data for the zone +//! and updating of the state to clear the paths to the no longer existing +//! persisted data files. Failure to remove a persisted data file will be +//! logged as a WARNing and Cascade will continue. +//! +//! Restoration of a zone is achieved by replacing the current (empty) zone +//! content with the loaded snapshot, then applying each loaded diff file +//! to the snapshot one at a time. The diffs are also kept in-memory for +//! responding to IXFR requests from downstream nameservers. The signed +//! snapshot and diffs are also restored like this. +//! +//! Any diff that was available at a review server will have been lost. +//! However as only approved data gets persisted, there should be no need +//! to still be able to query the review server for an IXFR diff after +//! Cascsade restarts. +//! +//! TODO: What happens if loaded data is approved and persisted, but +//! Cascade is terminated before signing occurs. In such a case if restore +//! is done as described above signing can occur as usual, but will a +//! signed review hook be able to query the loaded review server for the +//! loaded diff? use std::{ fs::File, From dad097d7aa00203b48e894056bc012a83f428562 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 22 May 2026 13:04:10 +0200 Subject: [PATCH 54/81] Move persist/restore docs to the right place. --- src/persistence/mod.rs | 84 ++++++++++++++++++++++++++++++++++++++ src/persistence/persist.rs | 84 -------------------------------------- 2 files changed, 84 insertions(+), 84 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 85311f245..0883d85ee 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -3,6 +3,90 @@ //! The zone persister saves the data for loaded and signed zones to disk, so //! that Cascade can seamlessly resume operation after a crash / restart. At //! startup, it tries to restore data for all known zones. +//! +//! When re-starting Cascade in-memory zone and IXFR diff data will be lost +//! unless persisted and restored. This module provides implements +//! persistence and restoration using files on disk stored in the zone-state +//! directory alongside the JSON '.db' zone state files. +//! +//! # Data format +//! +//! Data is persisted as AXFR and IXFR message ANSWER sections in wire format. +//! +//! # Persistence +//! +//! Persistence is invoked immediately after zone approval, either because of +//! a successful review hook, or no review hook at all, or because the +//! operator overrode a failed review hook. +//! +//! Persisted data is stored separately for records received loading the +//! zone vs changes that occur to the zone as a result of (re)signing it. +//! +//! For both loaded and signed changes, persistence stores an initial snapshot +//! and a sequence of zero or more diffs: +//! - A loaded snapshot file is written immediately after approval of the +//! initial version of a zone is received, whether from disk or via +//! XFR-in. This file has the name .loaded.0. +//! - A loaded diff file is written each time the input zone is reloaded, +//! whether due to XFR-in or due to reloading of the input file from disk. +//! Loaded diff files are named .loaded.N where N > 0 and +//! increases by one each time a new diff is persisted. +//! - A signed snapshot file is written immediately after approval of the +//! first signed version of the zone resulting from full zone signing. +//! This file has the name .signed.0. +//! - A signed diff file is written each time the zone is re-signed, whether +//! due to changes in the input zone or changes in the signing keys or the +//! replacing of signatures for records whose signatures need refreshing. +//! Signed diff files are named .signed.N where N > 0 and +//! increases by one each time a new diff is persisted. +//! +//! After a diff is persisted successfully: +//! - The diff is stored in memory alongside the zone so that it can be +//! served in response to an IXFR request from a downstream nameserver. +//! - The path that the diff file was written to is appended to a collection +//! of paths held in zone state and the zone state is immediately +//! saved to disk. +//! +//! If persistence fails due to an I/O error this will cause Cascade to panic. +//! If the underlying storage that Cascade depends on is not reliable we have +//! no way of knowing what else may be failing and abort as it is not safe to +//! continue under such circumstances. +//! +//! # Restoration +//! +//! Zones are created in memory at Cascade startup in storage state +//! RestoringLoaded. If the zone loader attempts to start loading the zone the +//! load will fail because the zone storage is not yet in the Passive state. +//! Instead a refresh will be enqueud and acted upon once the zone storage +//! enters the passive state. +//! +//! On startup Cascade starts a zone restorer which will attempt to restore +//! all known zones. If restoration fails the zone will move to the passive +//! state and any subsequent load of data will be handled as usual. As long +//! as the last used serial number was successfully persisted to state and +//! restored from state the newly signed zone will receive a higher serial +//! number than the last published zone that we failed to restore. Failure +//! to restore also results in deletion of all persisted data for the zone +//! and updating of the state to clear the paths to the no longer existing +//! persisted data files. Failure to remove a persisted data file will be +//! logged as a WARNing and Cascade will continue. +//! +//! Restoration of a zone is achieved by replacing the current (empty) zone +//! content with the loaded snapshot, then applying each loaded diff file +//! to the snapshot one at a time. The diffs are also kept in-memory for +//! responding to IXFR requests from downstream nameservers. The signed +//! snapshot and diffs are also restored like this. +//! +//! Any diff that was available at a review server will have been lost. +//! However as only approved data gets persisted, there should be no need +//! to still be able to query the review server for an IXFR diff after +//! Cascsade restarts. +//! +//! TODO: What happens if loaded data is approved and persisted, but +//! Cascade is terminated before signing occurs. In such a case if restore +//! is done as described above signing can occur as usual, but will a +//! signed review hook be able to query the loaded review server for the +//! loaded diff? use std::sync::Arc; diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index a7e4e0293..8d9e491e2 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -1,88 +1,4 @@ //! Persisting zone data. -//! -//! When re-starting Cascade in-memory zone and IXFR diff data will be lost -//! unless persisted and restored. This module provides implements -//! persistence and restoration using files on disk stored in the zone-state -//! directory alongside the JSON '.db' zone state files. -//! -//! # Data format -//! -//! Data is persisted as AXFR and IXFR message ANSWER sections in wire format. -//! -//! # Persistence -//! -//! Persistence is invoked immediately after zone approval, either because of -//! a successful review hook, or no review hook at all, or because the -//! operator overrode a failed review hook. -//! -//! Persisted data is stored separately for records received loading the -//! zone vs changes that occur to the zone as a result of (re)signing it. -//! -//! For both loaded and signed changes, persistence stores an initial snapshot -//! and a sequence of zero or more diffs: -//! - A loaded snapshot file is written immediately after approval of the -//! initial version of a zone is received, whether from disk or via -//! XFR-in. This file has the name .loaded.0. -//! - A loaded diff file is written each time the input zone is reloaded, -//! whether due to XFR-in or due to reloading of the input file from disk. -//! Loaded diff files are named .loaded.N where N > 0 and -//! increases by one each time a new diff is persisted. -//! - A signed snapshot file is written immediately after approval of the -//! first signed version of the zone resulting from full zone signing. -//! This file has the name .signed.0. -//! - A signed diff file is written each time the zone is re-signed, whether -//! due to changes in the input zone or changes in the signing keys or the -//! replacing of signatures for records whose signatures need refreshing. -//! Signed diff files are named .signed.N where N > 0 and -//! increases by one each time a new diff is persisted. -//! -//! After a diff is persisted successfully: -//! - The diff is stored in memory alongside the zone so that it can be -//! served in response to an IXFR request from a downstream nameserver. -//! - The path that the diff file was written to is appended to a collection -//! of paths held in zone state and the zone state is immediately -//! saved to disk. -//! -//! If persistence fails due to an I/O error this will cause Cascade to panic. -//! If the underlying storage that Cascade depends on is not reliable we have -//! no way of knowing what else may be failing and abort as it is not safe to -//! continue under such circumstances. -//! -//! # Restoration -//! -//! Zones are created in memory at Cascade startup in storage state -//! RestoringLoaded. If the zone loader attempts to start loading the zone the -//! load will fail because the zone storage is not yet in the Passive state. -//! Instead a refresh will be enqueud and acted upon once the zone storage -//! enters the passive state. -//! -//! On startup Cascade starts a zone restorer which will attempt to restore -//! all known zones. If restoration fails the zone will move to the passive -//! state and any subsequent load of data will be handled as usual. As long -//! as the last used serial number was successfully persisted to state and -//! restored from state the newly signed zone will receive a higher serial -//! number than the last published zone that we failed to restore. Failure -//! to restore also results in deletion of all persisted data for the zone -//! and updating of the state to clear the paths to the no longer existing -//! persisted data files. Failure to remove a persisted data file will be -//! logged as a WARNing and Cascade will continue. -//! -//! Restoration of a zone is achieved by replacing the current (empty) zone -//! content with the loaded snapshot, then applying each loaded diff file -//! to the snapshot one at a time. The diffs are also kept in-memory for -//! responding to IXFR requests from downstream nameservers. The signed -//! snapshot and diffs are also restored like this. -//! -//! Any diff that was available at a review server will have been lost. -//! However as only approved data gets persisted, there should be no need -//! to still be able to query the review server for an IXFR diff after -//! Cascsade restarts. -//! -//! TODO: What happens if loaded data is approved and persisted, but -//! Cascade is terminated before signing occurs. In such a case if restore -//! is done as described above signing can occur as usual, but will a -//! signed review hook be able to query the loaded review server for the -//! loaded diff? use std::{ fs::File, From 6edf77347612f9c41ebdb76aa94b62adaf6cd9ec Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 22 May 2026 13:06:34 +0200 Subject: [PATCH 55/81] Fix cargo-doc errors. --- src/persistence/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 0883d85ee..505b77603 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -26,18 +26,18 @@ //! and a sequence of zero or more diffs: //! - A loaded snapshot file is written immediately after approval of the //! initial version of a zone is received, whether from disk or via -//! XFR-in. This file has the name .loaded.0. +//! XFR-in. This file has the name `.loaded.0`. //! - A loaded diff file is written each time the input zone is reloaded, //! whether due to XFR-in or due to reloading of the input file from disk. -//! Loaded diff files are named .loaded.N where N > 0 and +//! Loaded diff files are named `.loaded.N` where N > 0 and //! increases by one each time a new diff is persisted. //! - A signed snapshot file is written immediately after approval of the //! first signed version of the zone resulting from full zone signing. -//! This file has the name .signed.0. +//! This file has the name `.signed.0`. //! - A signed diff file is written each time the zone is re-signed, whether //! due to changes in the input zone or changes in the signing keys or the //! replacing of signatures for records whose signatures need refreshing. -//! Signed diff files are named .signed.N where N > 0 and +//! Signed diff files are named `.signed.N` where N > 0 and //! increases by one each time a new diff is persisted. //! //! After a diff is persisted successfully: From 8924fd3fc2ae85afc45c067ec425cc0aedcf45f6 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 26 May 2026 11:47:56 +0200 Subject: [PATCH 56/81] Extend the persist-zone system test to restore from nothing. Adds a zone that can be added (because loading is deferred until the upstream nameserver can be contacted) but cannot actually be loaded (by virtue of not being known to the upstream name server), then exiting Cascade and restarting it so that it knows the zone but never had any lodaed data to persist or to restore from. --- .../tests/persist-zone/action.yml | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/integration-tests/tests/persist-zone/action.yml b/integration-tests/tests/persist-zone/action.yml index 6a5095cac..8084cbfb8 100644 --- a/integration-tests/tests/persist-zone/action.yml +++ b/integration-tests/tests/persist-zone/action.yml @@ -27,6 +27,68 @@ runs: with: log-level: ${{ inputs.log-level }} + # ------------------------------------------------------------------------ + # 0. Add a zone that cannot be loaded but does not fail `zone add` + # immediately so that Cascade has the zone but no content for it, + # then stop Cascade and restart it, to verify that Cascade can handle + # restoration without any persisted state to restore from. + # ------------------------------------------------------------------------ + + - name: Add a non-existing upstream zone + run: | + cascade zone add --policy default --source 127.0.0.1:1055 i.dont.exist + + - name: Stop Cascade + run: | + pkill cascaded + + - name: Wait for Cascade to exit again + run: | + timeout=10 # seconds + start=$(date +%s) + until ! cascade health; do + if (($(date +%s) > (start + timeout))); then + cascade health + echo "::error:: timeout: health check did not indicate Cascade had stopped" + exit 1 + fi + sleep 1 + done + + - name: Restart Cascade + run: | + CASCADE_DIR=${{ github.workspace }}/cascade-dir + cascaded --config "${CASCADE_DIR}/config.toml" --state "${CASCADE_DIR}/state.db" --daemonize &>"${CASCADE_DIR}/cascade-startup.log" + + - name: Wait for Cascade to become healthy + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade health; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: health check did not indicate Cascade had started" + exit 1 + fi + sleep 1 + done + + - name: Check that Cascade still knows the zone + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone list | grep -q i.dont.exist; do + if (($(date +%s) > (start + timeout))); then + cascade zone list + echo "::error:: timeout: Cascade no longer knows the zone" + exit 1 + fi + sleep 1 + done + + - name: Delete the zone + run: | + cascade zone remove i.dont.exist + # ------------------------------------------------------------------------ # 1. Add an upstream NSD source zone and sign it. # ------------------------------------------------------------------------ @@ -70,11 +132,11 @@ runs: # zone that we saved to disk in the step above. # ------------------------------------------------------------------------ - - name: Stop Cascade + - name: Stop Cascade again run: | pkill cascaded - - name: Wait for Cascade to exit + - name: Wait for Cascade to exit again run: | timeout=10 # seconds start=$(date +%s) From 79356968ec9094d59ebc6147ef67bb8c0b4aed93 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 26 May 2026 11:50:46 +0200 Subject: [PATCH 57/81] Remove errant whitespace. --- integration-tests/tests/persist-zone/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration-tests/tests/persist-zone/action.yml b/integration-tests/tests/persist-zone/action.yml index 8084cbfb8..3c22591fe 100644 --- a/integration-tests/tests/persist-zone/action.yml +++ b/integration-tests/tests/persist-zone/action.yml @@ -58,7 +58,7 @@ runs: - name: Restart Cascade run: | CASCADE_DIR=${{ github.workspace }}/cascade-dir - cascaded --config "${CASCADE_DIR}/config.toml" --state "${CASCADE_DIR}/state.db" --daemonize &>"${CASCADE_DIR}/cascade-startup.log" + cascaded --config "${CASCADE_DIR}/config.toml" --state "${CASCADE_DIR}/state.db" --daemonize &>"${CASCADE_DIR}/cascade-startup.log" - name: Wait for Cascade to become healthy run: | @@ -87,7 +87,7 @@ runs: - name: Delete the zone run: | - cascade zone remove i.dont.exist + cascade zone remove i.dont.exist # ------------------------------------------------------------------------ # 1. Add an upstream NSD source zone and sign it. From 8142b353ff236c9baffe76a6931b1d64cf356b12 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 26 May 2026 11:55:27 +0200 Subject: [PATCH 58/81] On restore from nothing, abandon rather than finish restoration. As `finish()` requires the zone to have a SOA record which it does not if is empty. --- src/persistence/restore.rs | 19 +++++++++++----- src/persistence/zone.rs | 46 +++++++++++++++++++++++++++++--------- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 364add54e..86bc62006 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -27,6 +27,9 @@ use tracing::{info, trace}; use crate::{center::Center, zone::Zone}; /// Restore the loaded instance data of a zone. +/// +/// Returns Ok(true) if data was stored, Ok(false) if there was nothing to +/// restore, or Err(..) on error. #[tracing::instrument( level = "trace", skip_all, @@ -36,7 +39,7 @@ pub fn restore_loaded( zone: &Arc, center: &Arc
, restorer: &mut LoadedZoneRestorer, -) -> io::Result<()> { +) -> io::Result { let mut state = zone.state.lock().unwrap(); if !state.persisted_loaded_diff_paths.is_empty() { info!("Restoring loaded zone from persisted data"); @@ -122,12 +125,16 @@ pub fn restore_loaded( } info!("Restored loaded zone snapshot and {} diffs", count - 1); + io::Result::Ok(true) + } else { + io::Result::Ok(false) } - - io::Result::Ok(()) } /// Restore the loaded instance data of a zone. +/// +/// Returns Ok(true) if data was stored, Ok(false) if there was nothing to +/// restore, or Err(..) on error. #[tracing::instrument( level = "trace", skip_all, @@ -137,7 +144,7 @@ pub fn restore_signed( zone: &Arc, center: &Arc
, restorer: &mut SignedZoneRestorer, -) -> io::Result<()> { +) -> io::Result { let state = zone.state.lock().unwrap(); if !state.persisted_loaded_diff_paths.is_empty() { info!("Restoring signed zone from persisted data"); @@ -240,8 +247,10 @@ pub fn restore_signed( ); } info!("Restored signed zone snapshot and {} diffs", count - 1); + io::Result::Ok(true) + } else { + io::Result::Ok(false) } - io::Result::Ok(()) } fn parse_rr( diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 32f776638..5fbe3accf 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -57,11 +57,24 @@ impl ZonePersistenceHandle<'_> { // Try to restore the loaded instance. let mut restorer = restorer; let restored = match super::restore_loaded(&zone, ¢er, &mut restorer) { - Ok(()) => restorer.finish().unwrap_or_else(|_| { - unreachable!( - "'restore_loaded()' always completes restoration on successful return" - ) - }), + Ok(true) => { + // Data was restored. Use it. + restorer.finish().unwrap_or_else(|_| { + unreachable!("Loaded zone restoration should have built new zone data") + }) + } + Ok(false) => { + // There was nothing to restore. + let mut state = zone.state.lock().unwrap(); + let mut handle = ZoneHandle { + zone: &zone, + state: &mut state, + center: ¢er, + }; + handle.storage().abandon_loaded_restoration(restorer); + handle.state.persistence.ongoing.finish(); + return; + } Err(err) => { warn!("Abandoning loaded restoration: {err}"); let mut state = zone.state.lock().unwrap(); @@ -90,11 +103,24 @@ impl ZonePersistenceHandle<'_> { // Try to restore the signed instance. let restored = match super::restore_signed(&zone, ¢er, &mut restorer) { - Ok(()) => restorer.finish().unwrap_or_else(|_| { - unreachable!( - "'restore_signed()' always completes restoration on successful return" - ) - }), + Ok(true) => { + // Data was restored. Use it. + restorer.finish().unwrap_or_else(|_| { + unreachable!("Signed zone restoration should have built new zone data") + }) + } + Ok(false) => { + // There was nothing to restore. + let mut state = zone.state.lock().unwrap(); + let mut handle = ZoneHandle { + zone: &zone, + state: &mut state, + center: ¢er, + }; + handle.storage().abandon_signed_restoration(restorer); + handle.state.persistence.ongoing.finish(); + return; + } Err(err) => { warn!("Abandoning signed restoration: {err}"); let mut state = zone.state.lock().unwrap(); From 5fb0578f36f344b641fa30aebe657fb80691a760 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Tue, 26 May 2026 12:32:10 +0200 Subject: [PATCH 59/81] Also test partial restoration of a previously signed zone. As this causes a panic currently. --- .../tests/persist-zone/action.yml | 96 +++++++++++++++++-- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/integration-tests/tests/persist-zone/action.yml b/integration-tests/tests/persist-zone/action.yml index 3c22591fe..4c0c2ecf2 100644 --- a/integration-tests/tests/persist-zone/action.yml +++ b/integration-tests/tests/persist-zone/action.yml @@ -31,7 +31,7 @@ runs: # 0. Add a zone that cannot be loaded but does not fail `zone add` # immediately so that Cascade has the zone but no content for it, # then stop Cascade and restart it, to verify that Cascade can handle - # restoration without any persisted state to restore from. + # restoration without any persisted state paths to restore from. # ------------------------------------------------------------------------ - name: Add a non-existing upstream zone @@ -203,11 +203,9 @@ runs: diff -u example.test.signed.log example.test.signed2.log # ------------------------------------------------------------------------ - # 3. Stop and restart Cascade again so we can test that it: - # - If persisted state files are deleted, does Cascade handle this - # correctly? It should attempt to load the persisted data files (as - # has paths to them in its main state file), find that they are - # missing and then ignore them and fully re-sign the zone. + # 3. Delete all persisted zone data files to verify that Cascade can + # handle restoration with persisted state paths that refer to missing + # files. # ------------------------------------------------------------------------ - name: Stop Cascade again @@ -598,6 +596,92 @@ runs: sed -e 's/\s\+/ /g' "${OUT_FILE}" | grep -F '5 IN SOA' > diff.in diff -u diff.in "${EXPECTED_OUT_FILE}" + # ------------------------------------------------------------------------ + # 7. Delete all persisted signed zone data files to verify that Cascade + # can handle partial restoration for a zone that was previously signed + # at least once. + # ------------------------------------------------------------------------ + + - name: Stop Cascade again + run: | + pkill cascaded + + - name: Wait for Cascade to exit again + run: | + timeout=10 # seconds + start=$(date +%s) + until ! cascade health; do + if (($(date +%s) > (start + timeout))); then + cascade health + echo "::error:: timeout: health check did not indicate Cascade had stopped" + exit 1 + fi + sleep 1 + done + + - name: Delete the persisted signed zone data that Cascade wrote to disk + run: | + CASCADE_DIR=${{ github.workspace }}/cascade-dir + rm "${CASCADE_DIR}"/zone-state/example.test.signed.* + + - name: Start Cascade again + run: | + CASCADE_DIR=${{ github.workspace }}/cascade-dir + cascaded --config "${CASCADE_DIR}/config.toml" --state "${CASCADE_DIR}/state.db" --daemonize &>"${CASCADE_DIR}/cascade-startup.log" + + - name: Wait for Cascade to become healthy again + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade health; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: health check did not indicate Cascade had started" + exit 1 + fi + sleep 1 + done + + - name: Check that Cascade still knows the zone + run: | + timeout=10 # seconds + start=$(date +%s) + until cascade zone list | grep -q example.test; do + if (($(date +%s) > (start + timeout))); then + cascade zone list + echo "::error:: timeout: Cascade no longer knows the zone" + exit 1 + fi + sleep 1 + done + + - name: Wait for Cascade to serve the re-signed zone with a newer serial number + env: + EXPECTED_SERIAL: ${{ steps.expected-serial.outputs.serial }} + run: | + timeout=10 # seconds + start=$(date +%s) + let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 3 )) + + until dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR | grep -q "${EXPECTED_SERIAL}" ; do + if (($(date +%s) > (start + timeout))); then + echo "::error:: timeout: signed zone did not become available for AXFR with expected SOA '${EXPECTED_SERIAL}'" + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR + exit 1 + fi + sleep 1 + done + + - name: Save the signed zone for later comparison + run: | + dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR > example.test.signed7.log + + - name: Check that Cascade DID resign the zone, not just bump the serial number + run: | + if diff -u example.test.signed2.log example.test.signed7.log > unexpected-diff.log; then + echo "::error:: Cascade should have re-signed the zone" + exit 1 + fi + - name: Print log files on any failure in this job uses: ./.github/actions/print-logfiles if: failure() From 9b0656794366c60597e505a9560aef2156f93b6e Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 27 May 2026 01:13:48 +0200 Subject: [PATCH 60/81] On restore signed failure, switch to Passive state rather than the Signing state. As: a) I am unable to get the storage state machine internals into the right state such that the Signing state and the states thereafter work correctly, b) If there was a problem with loading the signed persisted state maybe it is better to start from a known good source zone rather than trust the loaded persisted state to be correct in this unexpected situation, c) It is much simpler to code and to reason about. --- crates/zonedata/src/restorer.rs | 15 ++ crates/zonedata/src/storage/transitions.rs | 15 +- .../tests/persist-zone/action.yml | 2 +- src/persistence/restore.rs | 154 +++++++++--------- src/persistence/zone.rs | 24 +++ src/zone/machine.rs | 48 +++--- src/zone/storage.rs | 16 +- 7 files changed, 154 insertions(+), 120 deletions(-) diff --git a/crates/zonedata/src/restorer.rs b/crates/zonedata/src/restorer.rs index fbdca5ab2..a8fe86cf1 100644 --- a/crates/zonedata/src/restorer.rs +++ b/crates/zonedata/src/restorer.rs @@ -428,6 +428,21 @@ impl SignedZoneRestorer { /// it will be overwritten. pub fn clear(&mut self) { // Initialize the absolute data. + // Initialize the absolute data. + + // SAFETY: As per the caller, 'loaded[index]' will not be + // accessed elsewhere for the lifetime of 'self', and so is sound to + // access mutably. + let next = unsafe { &mut *self.data.loaded[self.index as usize].get() }; + next.soa = None; + next.records.clear(); + + // SAFETY: As per the caller, 'loaded[!index]' will not be + // accessed elsewhere for the lifetime of 'self', and so is sound to + // access mutably. + let curr = unsafe { &mut *self.data.loaded[!self.index as usize].get() }; + curr.soa = None; + curr.records.clear(); // SAFETY: As per the caller, 'signed[index]' will not be // accessed elsewhere for the lifetime of 'self', and so is sound to diff --git a/crates/zonedata/src/storage/transitions.rs b/crates/zonedata/src/storage/transitions.rs index 02831a430..92dcdd6be 100644 --- a/crates/zonedata/src/storage/transitions.rs +++ b/crates/zonedata/src/storage/transitions.rs @@ -138,8 +138,7 @@ impl RestoringSignedStorage { LoadedZoneReviewer, SignedZoneReviewer, ZoneViewer, - SignedZoneBuilder, - SigningStorage, + PassiveStorage, ) { assert!( Arc::ptr_eq(restorer.data(), &self.data), @@ -147,11 +146,11 @@ impl RestoringSignedStorage { ); restorer.clear(); - let Self { data, - curr_loaded_index, + curr_loaded_index: _, } = self; + let curr_loaded_index = false; let curr_signed_index = false; let loaded_reviewer = @@ -166,18 +165,14 @@ impl RestoringSignedStorage { ) }; let viewer = unsafe { ZoneViewer::new(data.clone(), curr_loaded_index, curr_signed_index) }; - let builder = unsafe { - SignedZoneBuilder::new(data.clone(), curr_loaded_index, !curr_signed_index, None) - }; - let storage = SigningStorage { + let storage = PassiveStorage { data, curr_loaded_index, curr_signed_index, - loaded_diff: None, }; - (loaded_reviewer, signed_reviewer, viewer, builder, storage) + (loaded_reviewer, signed_reviewer, viewer, storage) } } diff --git a/integration-tests/tests/persist-zone/action.yml b/integration-tests/tests/persist-zone/action.yml index 4c0c2ecf2..36cc80614 100644 --- a/integration-tests/tests/persist-zone/action.yml +++ b/integration-tests/tests/persist-zone/action.yml @@ -660,7 +660,7 @@ runs: run: | timeout=10 # seconds start=$(date +%s) - let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 3 )) + let EXPECTED_SERIAL=$(( EXPECTED_SERIAL + 4 )) until dig +noall +answer @127.0.0.1 -p 4542 example.test AXFR | grep -q "${EXPECTED_SERIAL}" ; do if (($(date +%s) > (start + timeout))); then diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 86bc62006..c303db604 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -41,94 +41,96 @@ pub fn restore_loaded( restorer: &mut LoadedZoneRestorer, ) -> io::Result { let mut state = zone.state.lock().unwrap(); - if !state.persisted_loaded_diff_paths.is_empty() { - info!("Restoring loaded zone from persisted data"); - state.storage.diffs.clear(); + if state.persisted_loaded_diff_paths.is_empty() { + return io::Result::Ok(false); + } - // Determine the paths to read from. Each zone is persisted as an AXFR - // plus zero or more IXFRs. The restorer takes a base path ending in - // an unsigned integer number and loads that file plus N more, where - // the final number in the path is replaced by the previous number - // plus one each time. - let loaded_source = center - .config - .zone_state_dir - .join(format!("{}.loaded.0", zone.name)); - let count = state.persisted_loaded_diff_paths.len(); - let mut buf = Vec::::new(); - drop(state); + info!("Restoring loaded zone from persisted data"); + state.storage.diffs.clear(); + + // Determine the paths to read from. Each zone is persisted as an AXFR + // plus zero or more IXFRs. The restorer takes a base path ending in + // an unsigned integer number and loads that file plus N more, where + // the final number in the path is replaced by the previous number + // plus one each time. + let loaded_source = center + .config + .zone_state_dir + .join(format!("{}.loaded.0", zone.name)); + let count = state.persisted_loaded_diff_paths.len(); + let mut buf = Vec::::new(); + drop(state); + + // Process the initial "loaded" AXFR wire format dump. + let (soa, records) = + load_axfr_wire_dump(loaded_source.as_std_path(), &mut buf).map_err(|err| { + io::Error::other(format!("Loading snapshot '{loaded_source}' failed: {err}")) + })?; + let mut loaded_replacer = restorer.fill().ok_or(io::Error::other( + "Internal error: Could not acquire replacer".to_string(), + ))?; + loaded_replacer.set_soa(soa.clone()).unwrap(); + loaded_replacer.set_records(records).unwrap(); + loaded_replacer.apply().unwrap(); + trace!( + "Restored loaded snapshot for SOA serial {} for zone '{}' from file '{loaded_source}'", + soa.rdata.serial, zone.name + ); + + if count == 1 { + return io::Result::Ok(true); + } - // Process the initial "loaded" AXFR wire format dump. - let (soa, records) = - load_axfr_wire_dump(loaded_source.as_std_path(), &mut buf).map_err(|err| { - io::Error::other(format!("Loading snapshot '{loaded_source}' failed: {err}")) + let mut source = loaded_source.to_path_buf(); + let mut all_serials = vec![]; + + for i in 1..count { + let mut loaded_patcher = restorer + .patch() + .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; + source.set_extension(i.to_string()); + + let (start_serial, end_serial) = + load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { + apply_ixfr_event_to_loaded_data(&mut loaded_patcher, event); + }) + .map_err(|err| { + io::Error::other(format!("Loading diff '{loaded_source}' failed: {err}",)) })?; - let mut loaded_replacer = restorer.fill().ok_or(io::Error::other( - "Internal error: Could not acquire replacer".to_string(), - ))?; - loaded_replacer.set_soa(soa.clone()).unwrap(); - loaded_replacer.set_records(records).unwrap(); - loaded_replacer.apply().unwrap(); - trace!( - "Restored loaded snapshot for SOA serial {} for zone '{}' from file '{loaded_source}'", - soa.rdata.serial, zone.name - ); - - if count > 1 { - let mut source = loaded_source.to_path_buf(); - let mut all_serials = vec![]; - - for i in 1..count { - let mut loaded_patcher = restorer - .patch() - .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; - source.set_extension(i.to_string()); - - let (start_serial, end_serial) = - load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { - apply_ixfr_event_to_loaded_data(&mut loaded_patcher, event); - }) - .map_err(|err| { - io::Error::other(format!("Loading diff '{loaded_source}' failed: {err}",)) - })?; - - loaded_patcher.next_patchset().map_err(|err| { - io::Error::other(format!("Internal error: Next patchset failed: {err}")) - })?; - loaded_patcher.apply().map_err(|err| { - io::Error::other(format!("Internal error: Apply failed: {err}")) - })?; + loaded_patcher.next_patchset().map_err(|err| { + io::Error::other(format!("Internal error: Next patchset failed: {err}")) + })?; - if let Some(diff) = restorer.take_diff() { - // Store the loaded diff to be used as part of serving an IXFR. - let mut state = zone.state.lock().unwrap(); - state - .storage - .diffs - .push((diff.into(), DiffData::new().into())); + loaded_patcher + .apply() + .map_err(|err| io::Error::other(format!("Internal error: Apply failed: {err}")))?; - trace!( - "Stored IXFR loaded diff for SOA serial {} from file '{loaded_source}': serial {start_serial} -> {end_serial}", - soa.rdata.serial, - ); - } + if let Some(diff) = restorer.take_diff() { + // Store the loaded diff to be used as part of serving an IXFR. + let mut state = zone.state.lock().unwrap(); + state + .storage + .diffs + .push((diff.into(), DiffData::new().into())); - let start_serial: u32 = start_serial.into(); - let end_serial: u32 = end_serial.into(); - all_serials.push((start_serial, end_serial)); - } trace!( - "Restored loaded diff for SOA serial {} for zone '{}' from file '{loaded_source}' with diff serials: {all_serials:?}", - soa.rdata.serial, zone.name + "Stored IXFR loaded diff for SOA serial {} from file '{loaded_source}': serial {start_serial} -> {end_serial}", + soa.rdata.serial, ); } - info!("Restored loaded zone snapshot and {} diffs", count - 1); - io::Result::Ok(true) - } else { - io::Result::Ok(false) + let start_serial: u32 = start_serial.into(); + let end_serial: u32 = end_serial.into(); + all_serials.push((start_serial, end_serial)); } + trace!( + "Restored loaded diff for SOA serial {} for zone '{}' from file '{loaded_source}' with diff serials: {all_serials:?}", + soa.rdata.serial, zone.name + ); + + info!("Restored loaded zone snapshot and {} diffs", count - 1); + io::Result::Ok(true) } /// Restore the loaded instance data of a zone. diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 5fbe3accf..2b358f04d 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -73,6 +73,18 @@ impl ZonePersistenceHandle<'_> { }; handle.storage().abandon_loaded_restoration(restorer); handle.state.persistence.ongoing.finish(); + + // In case this zone was signed in the past we have + // to make sure that we don't attempt to sign it now, + // as doing so will fail due to the lack of loaded + // zone content. + // TODO: Find a better way to prevent this issue + // as changing the min_expiration timestamps is a + // very indirect and non-obvious way of preventing + // re-signing. + state.min_expiration = None; + state.next_min_expiration = None; + return; } Err(err) => { @@ -119,6 +131,18 @@ impl ZonePersistenceHandle<'_> { }; handle.storage().abandon_signed_restoration(restorer); handle.state.persistence.ongoing.finish(); + + // In case this zone was signed in the past we have + // to make sure that we don't attempt to sign it now, + // as doing so will fail due to the lack of loaded + // zone content. + // TODO: Find a better way to prevent this issue + // as changing the min_expiration timestamps is a + // very indirect and non-obvious way of preventing + // re-signing. + state.min_expiration = None; + state.next_min_expiration = None; + return; } Err(err) => { diff --git a/src/zone/machine.rs b/src/zone/machine.rs index 9e2d7892b..309ccc9a2 100644 --- a/src/zone/machine.rs +++ b/src/zone/machine.rs @@ -265,27 +265,27 @@ impl<'a> ZoneHandle<'a> { self.signer().enqueue_new_sign(builder); } - /// Begin signing a restored loaded instance. - /// - /// This is called when the zone is restored from persisted state, the data - /// for its last known loaded instance is restored successfully, but the - /// data for its last known signed instance could not be restored (or none - /// existed). It directly initiates signing. - pub(crate) fn start_sign_after_restore( - &mut self, - builder: cascade_zonedata::SignedZoneBuilder, - ) { - // Force the state machine to the signing state. - let (transition, state) = self.state.machine.transition(); - let ZoneStateMachine::Waiting(waiting) = state else { - panic!("'start_sign_after_restore()' called when the zone was not passive"); - }; - transition.move_to(ZoneStateMachine::Signing( - waiting.start_sign_after_restore(), - )); - - self.signer().enqueue_new_sign(builder); - } + // /// Begin signing a restored loaded instance. + // /// + // /// This is called when the zone is restored from persisted state, the data + // /// for its last known loaded instance is restored successfully, but the + // /// data for its last known signed instance could not be restored (or none + // /// existed). It directly initiates signing. + // pub(crate) fn start_sign_after_restore( + // &mut self, + // builder: cascade_zonedata::SignedZoneBuilder, + // ) { + // // Force the state machine to the signing state. + // let (transition, state) = self.state.machine.transition(); + // let ZoneStateMachine::Waiting(waiting) = state else { + // panic!("'start_sign_after_restore()' called when the zone was not passive"); + // }; + // transition.move_to(ZoneStateMachine::Signing( + // waiting.start_sign_after_restore(), + // )); + + // self.signer().enqueue_new_sign(builder); + // } pub(crate) fn finish_signing(&mut self, built: cascade_zonedata::SignedZoneBuilt) { self.state.record_event( @@ -551,9 +551,9 @@ impl Waiting { Loading {} } - fn start_sign_after_restore(self) -> Signing { - Signing {} - } + // fn start_sign_after_restore(self) -> Signing { + // Signing {} + // } fn start_resign(self) -> Signing { Signing {} diff --git a/src/zone/storage.rs b/src/zone/storage.rs index e7addebd7..98803a346 100644 --- a/src/zone/storage.rs +++ b/src/zone/storage.rs @@ -633,18 +633,16 @@ impl StorageZoneHandle<'_> { /// Abandon the ongoing signed-instance restore. /// - /// Any intermediate zone data for the signed instance is wiped. The - /// restored loaded instance is preserved. The zone is moved to the signing - /// state. It is registered against Cascade's zone servers and a signing - /// operation is enqueued. + /// Any intermediate zone data is cleared and the zone is moved to the + /// passive state. It is registered against Cascade's zone servers. pub fn abandon_signed_restoration(&mut self, restorer: SignedZoneRestorer) { // Examine the current state. - let (loaded_reviewer, signed_reviewer, viewer, builder); + let (loaded_reviewer, signed_reviewer, viewer); match transition(&mut self.state.storage.machine) { (transition, ZoneDataStorage::RestoringSigned(s)) => { let new_s; - (loaded_reviewer, signed_reviewer, viewer, builder, new_s) = s.abandon(restorer); - transition.move_to(ZoneDataStorage::Signing(new_s)); + (loaded_reviewer, signed_reviewer, viewer, new_s) = s.abandon(restorer); + transition.move_to(ZoneDataStorage::Passive(new_s)); } _ => unreachable!( @@ -657,8 +655,8 @@ impl StorageZoneHandle<'_> { SignedReviewServer::add_zone(self.center, self.zone.clone(), signed_reviewer); PublicationServer::add_zone(self.center, self.zone.clone(), viewer); - // Initiate a new signing operation. - self.zone().start_sign_after_restore(builder); + // Send a notification that the state machine is now passive. + self.on_passive(); } } From e5adc5e0e683ef9a6d999903cf691f41bb27f5a6 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 27 May 2026 01:43:22 +0200 Subject: [PATCH 61/81] Fixes for restoring incremental signed diffs. --- src/persistence/persist.rs | 9 ++++++--- src/persistence/restore.rs | 25 ++++++++++++------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 8d9e491e2..96853b75d 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -149,7 +149,11 @@ pub fn persist_signed( // compared to the previous version of the signed zone. let mut state = zone.state.lock().unwrap(); - let partial_diff = state.storage.diffs.last_mut().unwrap(); + + // Discard any partial (loaded only without signed) diff, we will + // push a complete (loaded + signed) diff to the collection. + let _partial_diff = state.storage.diffs.pop(); + let loaded_diff = loaded_diff.cloned().unwrap_or(DiffData::new().into()); trace!( "Storing IXFR diff for SOA serial {:?} -> {:?}", @@ -162,8 +166,7 @@ pub fn persist_signed( .as_ref() .map(|soa_rr| soa_rr.rdata.serial), ); - let complete_diff = (loaded_diff, signed_diff.clone()); - *partial_diff = complete_diff; + state.storage.diffs.push((loaded_diff, signed_diff.clone())); } } diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index c303db604..cff817cd1 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -215,22 +215,21 @@ pub fn restore_signed( io::Error::other(format!("Internal error: Apply failed: {err}")) })?; - if let Some(diff) = restorer.take_diff() { + if let Some(signed_diff) = restorer.take_diff() { let mut state = zone.state.lock().unwrap(); // Get the diff pair (loaded diff and missing signed diff) - // that this signed diff needs to be inserted into. - let diffs = state - .storage - .diffs - .get_mut(i - 1) - .ok_or(io::Error::other(format!("Missing loaded diff {i}")))?; - - // Insert the signed diff alongside the loaded diff, unless the - // signed diff already unexpectedly exists. - if !diffs.1.is_empty() { - return Err(io::Error::other(format!("Signed diff {i} already exists"))); + // that this signed diff needs to be inserted into. If + // the signed diff was caused by incremental signing then + // a loaded diff won't have been available to restore, we + // need to use an empty loaded diff in that case. + if let Some(partial_diff) = state.storage.diffs.get_mut(i - 1) { + // Insert the signed diff alongside the loaded diff, unless the + // signed diff already unexpectedly exists. + assert!(partial_diff.1.is_empty()); + partial_diff.1 = signed_diff.into(); } else { - diffs.1 = diff.into(); + let loaded_diff = Arc::new(DiffData::new()); + state.storage.diffs.push((loaded_diff, signed_diff.into())); } trace!( From e0ecbbb346e4985f5bbd08d9008846cacfb5c46c Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 27 May 2026 10:45:19 +0200 Subject: [PATCH 62/81] Add an ASCII art state machine diagram for ZoneStorageData. --- crates/zonedata/src/storage/mod.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/zonedata/src/storage/mod.rs b/crates/zonedata/src/storage/mod.rs index d979c46de..8cf62f863 100644 --- a/crates/zonedata/src/storage/mod.rs +++ b/crates/zonedata/src/storage/mod.rs @@ -31,6 +31,32 @@ mod transitions; /// reviewed, and switched to. While it requires `&mut` access to be modified, /// it is designed to live in a (synchronous) mutex -- expensive operations on /// the zone are always achievable without `&mut` access. +/// +/// ```text +/// ╔═══════════════════╗ finish ╔═══════════════════╗ finish +/// ║ RestoringLoaded ╠═════════▶ RestoringSigned ╠════════╗ +/// ╚══╤════════════════╝ ╚══╤════════════════╝ ║ +/// │ abandon │ abandon ║ +/// ┌──▼─────────────────────────────▼─────────────────────────▼─────────┐ +/// │ Passive │ +/// └─────────╥────────────────────────────────────────────────────────▲─┘ +/// ║ │ mark_complete +/// ┌─────────║──────────────────────────────────────────────────────────┐ ╔═════════════╗ start ╔════════════════════╗ approve +/// │ ║ Cleaning <═══════════════║ Switching <═══════════║ PersistingSigned <═══════════════════════════════════════════════════════════════════▲ +/// └─────────║──────▲─────────────────────────────────────────────────▲─┘ ╚═════════════╝ ╚════════════════════╝ ║ +/// ║ │ │ stop_review ║ +/// ║ │ ┌─────────────────────┐ stop_review ┌────────────────────┐ give_up ║ +/// load ║ │ give_up │ CleanLoadedPending <──────────────────────────│ CleanWholePending <─────────────────────────────────────────────────────┐ ║ +/// ║ │ └────▲────────────────┘ └────────────────────┘ │ ║ +/// ▼ │ │ give_up │ ║ +/// ╔═════════╧═╗ finish ╔══════════════════════════╗ start ╔═══════════════════╗ approve ╔════════════════════╗ complete ╔══▼═════════════╗ finish ╔═══════════════════════╗ start ╔══════════════════╗ +/// ║ Loading ╠════════▶ ReviewingLoadedPending ╠═══════▶ ReviewingLoaded ╠═════════▶ PersistingLoaded ╠══════════▶ Signing ╠════════▶ ReviewSignedPending ╠═══════▶ ReviewingSigned ║ +/// ╚═══════════╝ ╚══════════════════════════╝ ╚═══════════════════╝ ╚════════════════════╝ ╚══╤═════════▲═══╝ ╚═══════════════════════╝ ╚════════╤═════════╝ +/// retry │ │ complete retry │ +/// ┌──▼─────────┴────┐ stop_review ┌──────────▼──────────┐ +/// │ CleaningSigned <─────────────────────────────────────│ CleanSignedPending │ +/// └─────────────────┘ └─────────────────────┘ +/// ``` pub enum ZoneDataStorage { /// The loaded instance of the zone is being restored. RestoringLoaded(RestoringLoadedStorage), From 3d0329963e35c620c6773fd90715244d945b1c37 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 27 May 2026 10:45:30 +0200 Subject: [PATCH 63/81] Remove a dupliate comment line. --- crates/zonedata/src/restorer.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/zonedata/src/restorer.rs b/crates/zonedata/src/restorer.rs index a8fe86cf1..2203cfec1 100644 --- a/crates/zonedata/src/restorer.rs +++ b/crates/zonedata/src/restorer.rs @@ -428,7 +428,6 @@ impl SignedZoneRestorer { /// it will be overwritten. pub fn clear(&mut self) { // Initialize the absolute data. - // Initialize the absolute data. // SAFETY: As per the caller, 'loaded[index]' will not be // accessed elsewhere for the lifetime of 'self', and so is sound to From 8714072a58ef64d65ad107428528416afb3bdded Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 27 May 2026 10:46:54 +0200 Subject: [PATCH 64/81] Minor typo correction. --- src/loader/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/loader/mod.rs b/src/loader/mod.rs index 89584164e..3b7416b1e 100644 --- a/src/loader/mod.rs +++ b/src/loader/mod.rs @@ -58,7 +58,7 @@ impl Loader { Source::None => { /* Nothing to do */ } Source::Zonefile { .. } => { // Don't enqueue a refresh for zones sourced from disk - // as the operator be may in the middle of editing the + // as the operator may be in the middle of editing the // zonefile and thus we require zonefiles to be reloaded // explicitly via `cascade zone reload`. } From 7d146a574504ab3bc50e5808c15ff90addd4f230 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 27 May 2026 10:58:16 +0200 Subject: [PATCH 65/81] RustDoc updates. --- src/persistence/mod.rs | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 505b77603..0203121d3 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -2,12 +2,12 @@ //! //! The zone persister saves the data for loaded and signed zones to disk, so //! that Cascade can seamlessly resume operation after a crash / restart. At -//! startup, it tries to restore data for all known zones. +//! startup it tries to restore data for all known zones. //! //! When re-starting Cascade in-memory zone and IXFR diff data will be lost -//! unless persisted and restored. This module provides implements -//! persistence and restoration using files on disk stored in the zone-state -//! directory alongside the JSON '.db' zone state files. +//! unless persisted and restored. This module implements persistence +//! and restoration using files on disk stored in the zone-state directory +//! alongside the JSON '.db' zone state files. //! //! # Data format //! @@ -19,7 +19,7 @@ //! a successful review hook, or no review hook at all, or because the //! operator overrode a failed review hook. //! -//! Persisted data is stored separately for records received loading the +//! Persisted data is stored separately for records received while loading the //! zone vs changes that occur to the zone as a result of (re)signing it. //! //! For both loaded and signed changes, persistence stores an initial snapshot @@ -41,11 +41,15 @@ //! increases by one each time a new diff is persisted. //! //! After a diff is persisted successfully: -//! - The diff is stored in memory alongside the zone so that it can be -//! served in response to an IXFR request from a downstream nameserver. -//! - The path that the diff file was written to is appended to a collection -//! of paths held in zone state and the zone state is immediately -//! saved to disk. +//! - The diff is stored in memory alongside the zone in +//! [`StorageState::diffs`]so that it can be served in response to an IXFR +//! request from a downstream nameserver. +//! - The path that the diff file was written to is appended to +//! [`ZoneState::persisted_loaded_diff_paths`] or +//! [`ZoneState::persisted_signed_diff_paths`] and the zone state is +//! immediately saved to disk. +//! +//! # Panics //! //! If persistence fails due to an I/O error this will cause Cascade to panic. //! If the underlying storage that Cascade depends on is not reliable we have @@ -55,13 +59,13 @@ //! # Restoration //! //! Zones are created in memory at Cascade startup in storage state -//! RestoringLoaded. If the zone loader attempts to start loading the zone the -//! load will fail because the zone storage is not yet in the Passive state. -//! Instead a refresh will be enqueud and acted upon once the zone storage -//! enters the passive state. +//! `RestoringLoaded`. If the zone loader attempts to start loading the zone +//! the load will fail because the zone storage is not yet in the `Passive` +//! state. Instead a refresh will be enqueud and acted upon once the zone +//! storage enters the `Passive` state. //! //! On startup Cascade starts a zone restorer which will attempt to restore -//! all known zones. If restoration fails the zone will move to the passive +//! all known zones. If restoration fails the zone will move to the `Passive` //! state and any subsequent load of data will be handled as usual. As long //! as the last used serial number was successfully persisted to state and //! restored from state the newly signed zone will receive a higher serial From 1186a5fbd2254ffa201f3bda9d1736b466c86bd5 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 27 May 2026 11:00:41 +0200 Subject: [PATCH 66/81] Minor comment reflowing and removal. --- src/persistence/persist.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 96853b75d..bcb8dfca2 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -32,10 +32,10 @@ pub fn persist_loaded( ) -> LoadedZonePersisted { if !persister.loaded_diff().is_empty() { // Determine the path to write to and update the record of written - // paths here as we don't want to give responsibility for working - // with ZoneState to the persistence crate. Accumulate a set of - // diffs per unsigned and signed zone, each stored at a path one - // suffixed by an index which rises by one when persisted. + // paths here as we don't want to give responsibility for working with + // ZoneState to the persistence crate. Accumulate a set of diffs per + // unsigned and signed zone, each stored at a path one suffixed by an + // index which rises by one when persisted. // TODO: Don't keep an unlimited number of diffs. // TODO: Compact diffs when idle? { @@ -66,10 +66,10 @@ pub fn persist_loaded( // Only store a diff if something has changed compared to the previous // version of the loaded zone, otherwise this is not a diff to a previous // version of the zone but actually a snapshot of the zone after having - // been loaded for the first time. If the SOA serial didn't change (which - // is legal for a loaded zone) don't store a diff because the IXFR protocol - // requires a SOA serial number change so we won't be able to serve the diff - // anyway. + // been loaded for the first time. If the SOA serial didn't change + // (which is legal for a loaded zone) don't store a diff because the IXFR + // protocol requires a SOA serial number change so we won't be able to + // serve the diff anyway. if !loaded_diff.is_empty() && loaded_diff.removed_soa.is_some() { // Store anything that changed when the zone was re-loaded, i.e. // unsigned zone content changes. Note that the SOA SERIAL is not @@ -216,10 +216,6 @@ fn persist_to_file(destination: &Path, diff: Arc) { RR: std::ops::Deref, W: Write, { - // Earlier attempt using presentation format instead of wire format. - // let r: OldRecord = loaded_diff.added_soa.clone().unwrap().into(); - // writeln!(f, "{r}").unwrap(); - let buf_len = buf.len(); let num_bytes_to_write = match rr.build_bytes(buf) { From 7399096192830c4c9290ad06b4224c8d2c14ca43 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 27 May 2026 11:04:03 +0200 Subject: [PATCH 67/81] Reflow comments, use consistent early abort style, and fix a bug. Test if the signed paths collection is empty on restoring from signed data, not if the loaded paths collection is empty. --- src/persistence/restore.rs | 203 +++++++++++++++++++------------------ 1 file changed, 102 insertions(+), 101 deletions(-) diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index cff817cd1..a7eb77490 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -49,10 +49,10 @@ pub fn restore_loaded( state.storage.diffs.clear(); // Determine the paths to read from. Each zone is persisted as an AXFR - // plus zero or more IXFRs. The restorer takes a base path ending in - // an unsigned integer number and loads that file plus N more, where - // the final number in the path is replaced by the previous number - // plus one each time. + // plus zero or more IXFRs. The restorer takes a base path ending in an + // unsigned integer number and loads that file plus N more, where the + // final number in the path is replaced by the previous number plus one + // each time. let loaded_source = center .config .zone_state_dir @@ -148,110 +148,111 @@ pub fn restore_signed( restorer: &mut SignedZoneRestorer, ) -> io::Result { let state = zone.state.lock().unwrap(); - if !state.persisted_loaded_diff_paths.is_empty() { - info!("Restoring signed zone from persisted data"); - - // Determine the paths to read from. Each zone is persisted as an AXFR - // plus zero or more IXFRs. The restorer takes a base path ending in - // an unsigned integer number and loads that file plus N more, where - // the final number in the path is replaced by the previous number - // plus one each time. - let signed_source = center - .config - .zone_state_dir - .join(format!("{}.signed.0", zone.name)); - let count = state.persisted_signed_diff_paths.len(); - let mut buf = Vec::::new(); - drop(state); - - // Process the initial "signed" AXFR wire format dump. - let (soa, records) = - load_axfr_wire_dump(signed_source.as_std_path(), &mut buf).map_err(|err| { - io::Error::other(format!( - "Loading snapshot from '{signed_source}' failed: {err}" - )) + if state.persisted_signed_diff_paths.is_empty() { + return io::Result::Ok(false); + } + + // Determine the paths to read from. Each zone is persisted as an AXFR + // plus zero or more IXFRs. The restorer takes a base path ending in an + // unsigned integer number and loads that file plus N more, where the + // final number in the path is replaced by the previous number plus one + // each time. + let signed_source = center + .config + .zone_state_dir + .join(format!("{}.signed.0", zone.name)); + let count = state.persisted_signed_diff_paths.len(); + let mut buf = Vec::::new(); + drop(state); + + // Process the initial "signed" AXFR wire format dump. + let (soa, records) = + load_axfr_wire_dump(signed_source.as_std_path(), &mut buf).map_err(|err| { + io::Error::other(format!( + "Loading snapshot from '{signed_source}' failed: {err}" + )) + })?; + let mut signed_replacer = restorer.fill().ok_or(io::Error::other( + "Internal error: Could not acquire replacer".to_string(), + ))?; + signed_replacer.set_soa(soa.clone()).unwrap(); + signed_replacer.set_records(records).unwrap(); + signed_replacer.apply().unwrap(); + trace!( + "Restored signed snapshot for SOA serial {} for zone '{}' from file '{signed_source}'", + soa.rdata.serial, zone.name + ); + + if count == 1 { + return io::Result::Ok(true); + } + + // Process zero or more "signed" IXFR wire format dumps. + let mut source = signed_source.to_path_buf(); + let mut all_serials = vec![]; + + // Load each diff and apply it to the zone, retrieving a single DiffData + // per signed diff. Store each signed DiffData alongside the corresponding + // loaded DiffData that was restored earlier in restore_loaded(). These + // DiffData's will be used to respond to IXFR requests, while at the same + // time also building up the entire signed zone that should be served for + // AXFR requests. + for i in 1..count { + let mut signed_patcher = restorer + .patch() + .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; + source.set_extension(i.to_string()); + + let (start_serial, end_serial) = + load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { + apply_ixfr_event_to_signed_data(&mut signed_patcher, event); + }) + .map_err(|err| { + io::Error::other(format!("Loading diff '{signed_source}' failed: {err}")) })?; - let mut signed_replacer = restorer.fill().ok_or(io::Error::other( - "Internal error: Could not acquire replacer".to_string(), - ))?; - signed_replacer.set_soa(soa.clone()).unwrap(); - signed_replacer.set_records(records).unwrap(); - signed_replacer.apply().unwrap(); - trace!( - "Restored signed snapshot for SOA serial {} for zone '{}' from file '{signed_source}'", - soa.rdata.serial, zone.name - ); - - // Process zero or more "signed" IXFR wire format dumps. - if count > 1 { - let mut source = signed_source.to_path_buf(); - let mut all_serials = vec![]; - - // Load each diff and apply it to the zone, retrieving a single - // DiffData per signed diff. Store each signed DiffData alongside - // the corresponding loaded DiffData that was restored earlier - // in restore_loaded(). These DiffData's will be used to respond - // to IXFR requests, while at the same time also building up the - // entire signed zone that should be served for AXFR requests. - for i in 1..count { - let mut signed_patcher = restorer - .patch() - .ok_or(io::Error::other("Internal error: Patch failed".to_string()))?; - source.set_extension(i.to_string()); - - let (start_serial, end_serial) = - load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { - apply_ixfr_event_to_signed_data(&mut signed_patcher, event); - }) - .map_err(|err| { - io::Error::other(format!("Loading diff '{signed_source}' failed: {err}")) - })?; - - signed_patcher.next_patchset().map_err(|err| { - io::Error::other(format!("Internal error: Next patchset failed: {err}")) - })?; - - signed_patcher.apply().map_err(|err| { - io::Error::other(format!("Internal error: Apply failed: {err}")) - })?; - - if let Some(signed_diff) = restorer.take_diff() { - let mut state = zone.state.lock().unwrap(); - // Get the diff pair (loaded diff and missing signed diff) - // that this signed diff needs to be inserted into. If - // the signed diff was caused by incremental signing then - // a loaded diff won't have been available to restore, we - // need to use an empty loaded diff in that case. - if let Some(partial_diff) = state.storage.diffs.get_mut(i - 1) { - // Insert the signed diff alongside the loaded diff, unless the - // signed diff already unexpectedly exists. - assert!(partial_diff.1.is_empty()); - partial_diff.1 = signed_diff.into(); - } else { - let loaded_diff = Arc::new(DiffData::new()); - state.storage.diffs.push((loaded_diff, signed_diff.into())); - } - - trace!( - "Stored signed diff for SOA serial {} from file '{signed_source}': serial {start_serial} -> {end_serial}", - soa.rdata.serial, - ); - } - let start_serial: u32 = start_serial.into(); - let end_serial: u32 = end_serial.into(); - all_serials.push((start_serial, end_serial)); + signed_patcher.next_patchset().map_err(|err| { + io::Error::other(format!("Internal error: Next patchset failed: {err}")) + })?; + + signed_patcher + .apply() + .map_err(|err| io::Error::other(format!("Internal error: Apply failed: {err}")))?; + + if let Some(signed_diff) = restorer.take_diff() { + let mut state = zone.state.lock().unwrap(); + // Get the diff pair (loaded diff and missing signed diff) that + // this signed diff needs to be inserted into. If the signed diff + // was caused by incremental signing then a loaded diff won't have + // been available to restore, we need to use an empty loaded diff + // in that case. + if let Some(partial_diff) = state.storage.diffs.get_mut(i - 1) { + // Insert the signed diff alongside the loaded diff, unless + // the signed diff already unexpectedly exists. + assert!(partial_diff.1.is_empty()); + partial_diff.1 = signed_diff.into(); + } else { + let loaded_diff = Arc::new(DiffData::new()); + state.storage.diffs.push((loaded_diff, signed_diff.into())); } + trace!( - "Restored signed diff for SOA serial {} for zone '{}' from file '{signed_source}' with diff serials: {all_serials:?}", - soa.rdata.serial, zone.name + "Stored signed diff for SOA serial {} from file '{signed_source}': serial {start_serial} -> {end_serial}", + soa.rdata.serial, ); } - info!("Restored signed zone snapshot and {} diffs", count - 1); - io::Result::Ok(true) - } else { - io::Result::Ok(false) + + let start_serial: u32 = start_serial.into(); + let end_serial: u32 = end_serial.into(); + all_serials.push((start_serial, end_serial)); } + trace!( + "Restored signed diff for SOA serial {} for zone '{}' from file '{signed_source}' with diff serials: {all_serials:?}", + soa.rdata.serial, zone.name + ); + + info!("Restored signed zone snapshot and {} diffs", count - 1); + io::Result::Ok(true) } fn parse_rr( From 836781f74c0132713895b1ad79c0ef9d1da36f68 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Wed, 27 May 2026 11:13:04 +0200 Subject: [PATCH 68/81] Fix broken RustDoc links. --- src/persistence/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 0203121d3..62fd0807f 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -42,11 +42,11 @@ //! //! After a diff is persisted successfully: //! - The diff is stored in memory alongside the zone in -//! [`StorageState::diffs`]so that it can be served in response to an IXFR +//! `StorageState::diffs` so that it can be served in response to an IXFR //! request from a downstream nameserver. //! - The path that the diff file was written to is appended to -//! [`ZoneState::persisted_loaded_diff_paths`] or -//! [`ZoneState::persisted_signed_diff_paths`] and the zone state is +//! `ZoneState::persisted_loaded_diff_paths` or +//! `ZoneState::persisted_signed_diff_paths` and the zone state is //! immediately saved to disk. //! //! # Panics From 8a7bca08188fc545e94c608bd80f8c0e91a45406 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 28 May 2026 12:32:24 +0200 Subject: [PATCH 69/81] FIX: Don't overwrite the last in-memory IXFR diff with a new one created by incremental signing. Also improve and make IXFR in-memory diff manipulation log messages more consistent. --- src/persistence/persist.rs | 53 +++++++++++++++++++++++++++++++++++--- src/persistence/restore.rs | 47 +++++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index bcb8dfca2..dc310336b 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -78,6 +78,19 @@ pub fn persist_loaded( let mut state = zone.state.lock().unwrap(); let loaded_only_diff = (persister.loaded_diff().clone(), DiffData::new().into()); + trace!( + "Storing IXFR in-memory diff for SOA loaded serial -{:?}:+{:?}", + loaded_only_diff + .0 + .removed_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), + loaded_only_diff + .0 + .added_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), + ); state.storage.diffs.push(loaded_only_diff); } @@ -150,14 +163,46 @@ pub fn persist_signed( let mut state = zone.state.lock().unwrap(); - // Discard any partial (loaded only without signed) diff, we will - // push a complete (loaded + signed) diff to the collection. - let _partial_diff = state.storage.diffs.pop(); + // If we have a new signed diff to store because records in the + // loaded part of the zone changed, e.g. due to changes in the + // zone content or receipt of a changed DNSKEY set from the key + // manager, then the loaded diff will have been stored in-memory + // by loaded zone persistence, but the corresponding signed diff + // will not yet have been stored in-memory, we have to do that + // now. In this case we have to update the last stored in-memory + // diff. We drop the partial diff and push a replacement full + // diff instead. + // + // Alternatively if we have a new signed diff to store because + // records in the signed part of the zone changed, e.g. due to + // signature re-generation to ensure that existing signatures + // don't expire, then there will be no corresponding loaded diff + // yet in-memory. In this case we have to push an entirely new + // diff to the in-memory collection without dropping an existing + // diff first. + + let mut action = "Storing new"; + if let Some(potentially_partial_diff) = state.storage.diffs.last() { + if potentially_partial_diff.1.is_empty() { + // Remove the partial diff, it will be replaced by a + // complete diff. + let _partial_diff = state.storage.diffs.pop(); + action = "Updating existing"; + } + } let loaded_diff = loaded_diff.cloned().unwrap_or(DiffData::new().into()); trace!( - "Storing IXFR diff for SOA serial {:?} -> {:?}", + "{action} IXFR in-memory diff for SOA loaded serial -{:?}:+{:?} -> signed serial -{:?}:+{:?}", + loaded_diff + .removed_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), loaded_diff + .added_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), + signed_diff .removed_soa .as_ref() .map(|soa_rr| soa_rr.rdata.serial), diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index a7eb77490..90968cc5e 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -231,15 +231,52 @@ pub fn restore_signed( // the signed diff already unexpectedly exists. assert!(partial_diff.1.is_empty()); partial_diff.1 = signed_diff.into(); + trace!( + "Updated signed part of IXFR in-memory diff for SOA loaded serial -{:?}:+{:?} -> signed serial -{:?}:+{:?} from file '{signed_source}'", + partial_diff + .0 + .removed_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), + partial_diff + .0 + .added_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), + partial_diff + .1 + .removed_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), + partial_diff + .1 + .added_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), + ); } else { let loaded_diff = Arc::new(DiffData::new()); + trace!( + "Storing IXFR in-memory diff for SOA loaded serial -{:?}:+{:?} -> signed serial -{:?}:+{:?} from file '{signed_source}'", + loaded_diff + .removed_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), + loaded_diff + .added_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), + signed_diff + .removed_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), + signed_diff + .added_soa + .as_ref() + .map(|soa_rr| soa_rr.rdata.serial), + ); state.storage.diffs.push((loaded_diff, signed_diff.into())); } - - trace!( - "Stored signed diff for SOA serial {} from file '{signed_source}': serial {start_serial} -> {end_serial}", - soa.rdata.serial, - ); } let start_serial: u32 = start_serial.into(); From d595c3a95a9c891298862e62aa0fd8e3e343e061 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 28 May 2026 12:53:46 +0200 Subject: [PATCH 70/81] Clippy. --- src/persistence/persist.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index dc310336b..c653497c9 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -182,13 +182,13 @@ pub fn persist_signed( // diff first. let mut action = "Storing new"; - if let Some(potentially_partial_diff) = state.storage.diffs.last() { - if potentially_partial_diff.1.is_empty() { - // Remove the partial diff, it will be replaced by a - // complete diff. - let _partial_diff = state.storage.diffs.pop(); - action = "Updating existing"; - } + if let Some(potentially_partial_diff) = state.storage.diffs.last() + && potentially_partial_diff.1.is_empty() + { + // Remove the partial diff, it will be replaced by a + // complete diff. + let _partial_diff = state.storage.diffs.pop(); + action = "Updating existing"; } let loaded_diff = loaded_diff.cloned().unwrap_or(DiffData::new().into()); From 15533745ec00795aa71516db1e5fbf230c86d885 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 28 May 2026 13:37:23 +0200 Subject: [PATCH 71/81] Persist updated paths in state before writing to the new path, not the other way around. This will prevent leaving orphaned files behind that are not used and thus potentially confusing. It causes another edge case for which a TODO has been added for now. --- src/persistence/persist.rs | 75 +++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index c653497c9..7e3d84d69 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -38,7 +38,7 @@ pub fn persist_loaded( // index which rises by one when persisted. // TODO: Don't keep an unlimited number of diffs. // TODO: Compact diffs when idle? - { + let destination = { let mut state = zone.state.lock().unwrap(); let handle = ZoneHandle { zone, @@ -50,13 +50,41 @@ pub fn persist_loaded( .config .zone_state_dir .join(format!("{}.loaded.{next_idx}", zone.name)); - persist_to_file(destination.as_std_path(), persister.loaded_diff().clone()); + handle .state .persisted_loaded_diff_paths - .push(destination.into()); - } + .push(destination.clone().into()); + + destination + }; + + // Update the set of persisted zone data file paths BEFORE writing + // the new file because if we crash/lose power between writing the new + // file and storing the updated set of paths: + // - We would not know when restoring that a diff is missing. + // This isn't ultimately a problem as we also would not ever + // have served the updated zone from the publication server as + // persistence has to complete before publication occurs. + // - The new file would be unused but left behind on disk. It may + // later be overwritten by a new diff but until then it would + // be unused and also not removed by cleaning of diff files that + // occurs if restoration fails, as the path would not be known + // to us. + + // TODO: Saving state first then writing the file could lead to a + // situation where a zone that was published consisting of a snapshot + // and N diffs would be discarded if the path to the N+1th diff was + // recorded in state and persisted but actually writing the N+1th + // diff file failed. On restore the file would be looked for and not + // found and all loaded and signed content including diffs would be + // discarded at that point. One way around this could be to track + // (e.g. using an Option field) that this path is in the process of + // being written but has not yet finished yet, and then on restore if + // the Option is Some the referred to path can just be deleted. save_state_now(center, zone); + + persist_to_file(destination.as_std_path(), persister.loaded_diff().clone()); } // Store the loaded diff in-memory for serving IXFR out. @@ -119,7 +147,7 @@ pub fn persist_signed( // index which rises by one when persisted. // TODO: Don't keep an unlimited number of diffs. // TODO: Compact diffs when idle? - { + let destination = { let mut state = zone.state.lock().unwrap(); let handle = ZoneHandle { zone, @@ -132,16 +160,43 @@ pub fn persist_signed( .zone_state_dir .join(format!("{}.signed.{next_idx}", zone.name)); - // Write the diff to disk as a binary AXFR snapshot or binary IXFR - // diff. - persist_to_file(destination.as_std_path(), signed_diff.clone()); handle .state .persisted_signed_diff_paths - .push(destination.into()); - } + .push(destination.clone().into()); + + destination + }; + + // Update the set of persisted zone data file paths BEFORE writing + // the new file because if we crash/lose power between writing the new + // file and storing the updated set of paths: + // - We would not know when restoring that a diff is missing. + // This isn't ultimately a problem as we also would not ever + // have served the updated zone from the publication server as + // persistence has to complete before publication occurs. + // - The new file would be unused but left behind on disk. It may + // later be overwritten by a new diff but until then it would + // be unused and also not removed by cleaning of diff files that + // occurs if restoration fails, as the path would not be known + // to us. + + // TODO: Saving state first then writing the file could lead to a + // situation where a zone that was published consisting of a snapshot + // and N diffs would be discarded if the path to the N+1th diff was + // recorded in state and persisted but actually writing the N+1th + // diff file failed. On restore the file would be looked for and not + // found and all loaded and signed content including diffs would be + // discarded at that point. One way around this could be to track + // (e.g. using an Option field) that this path is in the process of + // being written but has not yet finished yet, and then on restore if + // the Option is Some the referred to path can just be deleted. save_state_now(center, zone); + // Write the diff to disk as a binary AXFR snapshot or binary IXFR + // diff. + persist_to_file(destination.as_std_path(), signed_diff.clone()); + // Store the diffs in-memory for serving IXFR out. // // Only store a diff if the SOA from the previous version of the From 9be63e1668303a2a64e77ac5275f6decda378c99 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 28 May 2026 13:37:37 +0200 Subject: [PATCH 72/81] Also log persistence complete. --- src/persistence/zone.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 2b358f04d..ebcd8593b 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -190,8 +190,8 @@ impl ZonePersistenceHandle<'_> { .ongoing .spawn_blocking(span, move || { debug!("Persisting the loaded instance"); - let persisted = super::persist_loaded(&zone, ¢er, persister); + debug!("Persisting the loaded instance completed"); // NOTE: The outer function, which is spawning the background // task, has a lock of the zone state. Thus, the following lock @@ -227,8 +227,8 @@ impl ZonePersistenceHandle<'_> { .ongoing .spawn_blocking(span, move || { debug!("Persisting the signed instance"); - let persisted = super::persist_signed(&zone, ¢er, persister); + debug!("Persisting the signed instance completed"); // NOTE: The outer function, which is spawning the background // task, has a lock of the zone state. Thus, the following lock From a017078b58e788d76268cf30e63da73a0441704a Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 28 May 2026 13:41:53 +0200 Subject: [PATCH 73/81] Review feedback: Remove unnecessary path canonicalization. Defending against out of state dir paths makes little sense as the attacker already has the ability to do a lot of they can affect the content of the Cascade state files. --- src/persistence/zone.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index ebcd8593b..3c1c776f5 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -250,16 +250,12 @@ impl ZonePersistenceHandle<'_> { fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { // We can't use the persisted data so remove the paths from state and also // the corresponding files on disk. - let zone_state_dir = center.config.zone_state_dir.canonicalize().unwrap(); for p in state .persisted_loaded_diff_paths .iter() .chain(state.persisted_signed_diff_paths.iter()) { - if p.exists() - && let Ok(ref p) = p.canonicalize() - && p.starts_with(&zone_state_dir) - { + if p.exists() && p.starts_with(center.config.zone_state_dir.as_std_path()) { info!( "Removing unusable persisted zone data file '{}'", p.display() From d192b11f9a9c9783664d561e9bd802214be856c1 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 28 May 2026 13:42:57 +0200 Subject: [PATCH 74/81] Review feedback: Remove commented out code. --- src/zone/machine.rs | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/zone/machine.rs b/src/zone/machine.rs index fae173758..4074dedf5 100644 --- a/src/zone/machine.rs +++ b/src/zone/machine.rs @@ -265,28 +265,6 @@ impl<'a> ZoneHandle<'a> { self.signer().enqueue_new_sign(builder); } - // /// Begin signing a restored loaded instance. - // /// - // /// This is called when the zone is restored from persisted state, the data - // /// for its last known loaded instance is restored successfully, but the - // /// data for its last known signed instance could not be restored (or none - // /// existed). It directly initiates signing. - // pub(crate) fn start_sign_after_restore( - // &mut self, - // builder: cascade_zonedata::SignedZoneBuilder, - // ) { - // // Force the state machine to the signing state. - // let (transition, state) = self.state.machine.transition(); - // let ZoneStateMachine::Waiting(waiting) = state else { - // panic!("'start_sign_after_restore()' called when the zone was not passive"); - // }; - // transition.move_to(ZoneStateMachine::Signing( - // waiting.start_sign_after_restore(), - // )); - - // self.signer().enqueue_new_sign(builder); - // } - pub(crate) fn finish_signing(&mut self, built: cascade_zonedata::SignedZoneBuilt) { let (transition, state) = self.state.machine.transition(); From 04ec7c2d71796955e0c3c08aee00b0af5dc736c7 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 28 May 2026 15:59:15 +0200 Subject: [PATCH 75/81] Review feedback: Log error about correct path. --- src/persistence/restore.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/persistence/restore.rs b/src/persistence/restore.rs index 90968cc5e..690bc5fcd 100644 --- a/src/persistence/restore.rs +++ b/src/persistence/restore.rs @@ -94,9 +94,7 @@ pub fn restore_loaded( load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { apply_ixfr_event_to_loaded_data(&mut loaded_patcher, event); }) - .map_err(|err| { - io::Error::other(format!("Loading diff '{loaded_source}' failed: {err}",)) - })?; + .map_err(|err| io::Error::other(format!("Loading diff '{source}' failed: {err}",)))?; loaded_patcher.next_patchset().map_err(|err| { io::Error::other(format!("Internal error: Next patchset failed: {err}")) @@ -207,9 +205,7 @@ pub fn restore_signed( load_ixfr_wire_dump(source.as_std_path(), &mut buf, |event| { apply_ixfr_event_to_signed_data(&mut signed_patcher, event); }) - .map_err(|err| { - io::Error::other(format!("Loading diff '{signed_source}' failed: {err}")) - })?; + .map_err(|err| io::Error::other(format!("Loading diff '{source}' failed: {err}")))?; signed_patcher.next_patchset().map_err(|err| { io::Error::other(format!("Internal error: Next patchset failed: {err}")) From b4b446d9c9b3b1f6b54f66fcc9de0bad5c8ce8ea Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 28 May 2026 16:35:34 +0200 Subject: [PATCH 76/81] Extract common code and fix unblocked re-signing on restore error. On restore error re-signing should be blocked, but only the nothing-to-restore match branch was doing that. Factor out the common code and re-use it so that the error branch also correctly resets state. --- src/persistence/zone.rs | 119 ++++++++++++++++++++-------------------- 1 file changed, 59 insertions(+), 60 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 3c1c776f5..b55e3132e 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -2,13 +2,15 @@ use std::sync::Arc; -use cascade_zonedata::{LoadedZonePersister, LoadedZoneRestorer, SignedZonePersister}; +use cascade_zonedata::{ + LoadedZonePersister, LoadedZoneRestorer, SignedZonePersister, SignedZoneRestorer, +}; use tracing::{debug, info, trace, trace_span, warn}; use crate::{ center::Center, util::BackgroundTasks, - zone::{Zone, ZoneHandle, ZoneState}, + zone::{Zone, ZoneHandle, ZoneState, save_state_now}, }; //----------- ZonePersistenceHandle -------------------------------------------- @@ -65,39 +67,12 @@ impl ZonePersistenceHandle<'_> { } Ok(false) => { // There was nothing to restore. - let mut state = zone.state.lock().unwrap(); - let mut handle = ZoneHandle { - zone: &zone, - state: &mut state, - center: ¢er, - }; - handle.storage().abandon_loaded_restoration(restorer); - handle.state.persistence.ongoing.finish(); - - // In case this zone was signed in the past we have - // to make sure that we don't attempt to sign it now, - // as doing so will fail due to the lack of loaded - // zone content. - // TODO: Find a better way to prevent this issue - // as changing the min_expiration timestamps is a - // very indirect and non-obvious way of preventing - // re-signing. - state.min_expiration = None; - state.next_min_expiration = None; - + abandon_loaded_restoration(¢er, &zone, restorer); return; } Err(err) => { warn!("Abandoning loaded restoration: {err}"); - let mut state = zone.state.lock().unwrap(); - clear_persisted_zone_data(¢er, &mut state); - let mut handle = ZoneHandle { - zone: &zone, - state: &mut state, - center: ¢er, - }; - handle.storage().abandon_loaded_restoration(restorer); - handle.state.persistence.ongoing.finish(); + abandon_loaded_restoration(¢er, &zone, restorer); return; } }; @@ -123,39 +98,12 @@ impl ZonePersistenceHandle<'_> { } Ok(false) => { // There was nothing to restore. - let mut state = zone.state.lock().unwrap(); - let mut handle = ZoneHandle { - zone: &zone, - state: &mut state, - center: ¢er, - }; - handle.storage().abandon_signed_restoration(restorer); - handle.state.persistence.ongoing.finish(); - - // In case this zone was signed in the past we have - // to make sure that we don't attempt to sign it now, - // as doing so will fail due to the lack of loaded - // zone content. - // TODO: Find a better way to prevent this issue - // as changing the min_expiration timestamps is a - // very indirect and non-obvious way of preventing - // re-signing. - state.min_expiration = None; - state.next_min_expiration = None; - + abandon_signed_restoration(¢er, &zone, restorer); return; } Err(err) => { warn!("Abandoning signed restoration: {err}"); - let mut state = zone.state.lock().unwrap(); - clear_persisted_zone_data(¢er, &mut state); - let mut handle = ZoneHandle { - zone: &zone, - state: &mut state, - center: ¢er, - }; - handle.storage().abandon_signed_restoration(restorer); - handle.state.persistence.ongoing.finish(); + abandon_signed_restoration(¢er, &zone, restorer); return; } }; @@ -247,6 +195,57 @@ impl ZonePersistenceHandle<'_> { } } +fn abandon_loaded_restoration( + center: &Arc
, + zone: &Arc, + restorer: LoadedZoneRestorer, +) { + reset_state_due_to_abandoned_restore(center, zone); + let mut state = zone.state.lock().unwrap(); + let mut handle = ZoneHandle { + zone: zone, + state: &mut state, + center: center, + }; + handle.storage().abandon_loaded_restoration(restorer); + handle.state.persistence.ongoing.finish(); +} + +fn abandon_signed_restoration( + center: &Arc
, + zone: &Arc, + restorer: SignedZoneRestorer, +) { + reset_state_due_to_abandoned_restore(center, zone); + let mut state = zone.state.lock().unwrap(); + let mut handle = ZoneHandle { + zone: &zone, + state: &mut state, + center: ¢er, + }; + handle.storage().abandon_signed_restoration(restorer); + handle.state.persistence.ongoing.finish(); +} + +fn reset_state_due_to_abandoned_restore(center: &Arc
, zone: &Arc) { + { + let mut state = zone.state.lock().unwrap(); + clear_persisted_zone_data(center, &mut state); + + // In case this zone was signed in the past we have + // to make sure that we don't attempt to sign it now, + // as doing so will fail due to the lack of loaded + // zone content. + // TODO: Find a better way to prevent this issue + // as changing the min_expiration timestamps is a + // very indirect and non-obvious way of preventing + // re-signing. + state.min_expiration = None; + state.next_min_expiration = None; + } + save_state_now(center, zone); +} + fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { // We can't use the persisted data so remove the paths from state and also // the corresponding files on disk. From e48bd10cfb57895c785cb05b07aa74c142764d98 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 28 May 2026 17:56:52 +0200 Subject: [PATCH 77/81] Clippy. --- src/persistence/zone.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index b55e3132e..41f06c816 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -203,9 +203,9 @@ fn abandon_loaded_restoration( reset_state_due_to_abandoned_restore(center, zone); let mut state = zone.state.lock().unwrap(); let mut handle = ZoneHandle { - zone: zone, + zone, state: &mut state, - center: center, + center, }; handle.storage().abandon_loaded_restoration(restorer); handle.state.persistence.ongoing.finish(); @@ -219,9 +219,9 @@ fn abandon_signed_restoration( reset_state_due_to_abandoned_restore(center, zone); let mut state = zone.state.lock().unwrap(); let mut handle = ZoneHandle { - zone: &zone, + zone, state: &mut state, - center: ¢er, + center, }; handle.storage().abandon_signed_restoration(restorer); handle.state.persistence.ongoing.finish(); From af9d1536c678e2a6b5da05ec61e15f145dd4a535 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 28 May 2026 21:25:48 +0200 Subject: [PATCH 78/81] FIX: Abort any enqueud signing operations when restore failure discards the zone contents. --- src/persistence/zone.rs | 12 ++++++++---- src/signer/zone.rs | 7 +++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index 41f06c816..f91bdd600 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -232,16 +232,20 @@ fn reset_state_due_to_abandoned_restore(center: &Arc
, zone: &Arc) let mut state = zone.state.lock().unwrap(); clear_persisted_zone_data(center, &mut state); - // In case this zone was signed in the past we have - // to make sure that we don't attempt to sign it now, - // as doing so will fail due to the lack of loaded - // zone content. + // In case this zone was signed in the past we have to make sure that + // any attempt to enqueue a signing operation will be skipped as + // doing so will fail due to the lack of loaded zone content. // TODO: Find a better way to prevent this issue // as changing the min_expiration timestamps is a // very indirect and non-obvious way of preventing // re-signing. state.min_expiration = None; state.next_min_expiration = None; + + // Also remove any already enqueued signing operation that is blocked + // by the ongoing restore as it will otherwise immediately start once + // the restore completes. + state.signer.cancel_enqueued_signing_operations(); } save_state_now(center, zone); } diff --git a/src/signer/zone.rs b/src/signer/zone.rs index 46a6f3dd7..d64cbd6a8 100644 --- a/src/signer/zone.rs +++ b/src/signer/zone.rs @@ -254,6 +254,13 @@ pub struct SignerState { pub enqueued_resign: Option, } +impl SignerState { + pub fn cancel_enqueued_signing_operations(&mut self) { + self.enqueued_new_sign = None; + self.enqueued_resign = None; + } +} + //----------- EnqueuedSign ----------------------------------------------------- /// An enqueued sign of a zone. From f680da07bf118acda8a43897971a744b6a7b78b5 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 28 May 2026 21:27:56 +0200 Subject: [PATCH 79/81] Reflow comment. --- src/persistence/zone.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index f91bdd600..bbcdceb6f 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -233,12 +233,11 @@ fn reset_state_due_to_abandoned_restore(center: &Arc
, zone: &Arc) clear_persisted_zone_data(center, &mut state); // In case this zone was signed in the past we have to make sure that - // any attempt to enqueue a signing operation will be skipped as - // doing so will fail due to the lack of loaded zone content. - // TODO: Find a better way to prevent this issue - // as changing the min_expiration timestamps is a - // very indirect and non-obvious way of preventing - // re-signing. + // any attempt to enqueue a signing operation will be skipped as doing + // so will fail due to the lack of loaded zone content. + // TODO: Find a better way to prevent this issue as changing the + // min_expiration timestamps is a very indirect and non-obvious way of + // preventing re-signing. state.min_expiration = None; state.next_min_expiration = None; From 216e1f133ab70c2f3429bb02de91446b451d3bdc Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Thu, 28 May 2026 23:01:32 +0200 Subject: [PATCH 80/81] Minor comment improvement. --- src/persistence/zone.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index bbcdceb6f..b2feee04f 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -233,8 +233,8 @@ fn reset_state_due_to_abandoned_restore(center: &Arc
, zone: &Arc) clear_persisted_zone_data(center, &mut state); // In case this zone was signed in the past we have to make sure that - // any attempt to enqueue a signing operation will be skipped as doing - // so will fail due to the lack of loaded zone content. + // any attempt to enqueue a re-signing operation will be skipped as + // doing so will fail due to the lack of loaded zone content. // TODO: Find a better way to prevent this issue as changing the // min_expiration timestamps is a very indirect and non-obvious way of // preventing re-signing. From c66b630e8ce909d67db9403e01bbe8f2bce89036 Mon Sep 17 00:00:00 2001 From: Ximon Eighteen <3304436+ximon18@users.noreply.github.com> Date: Fri, 29 May 2026 09:51:01 +0200 Subject: [PATCH 81/81] FIX: Also forget loaded diffs when aborting a restore. --- src/persistence/zone.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/persistence/zone.rs b/src/persistence/zone.rs index b2feee04f..812d7bdde 100644 --- a/src/persistence/zone.rs +++ b/src/persistence/zone.rs @@ -250,8 +250,9 @@ fn reset_state_due_to_abandoned_restore(center: &Arc
, zone: &Arc) } fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { - // We can't use the persisted data so remove the paths from state and also - // the corresponding files on disk. + // We can't use the persisted data so remove the paths from state, remove + // the corresponding files on disk and remove any diffs that we loaded + // into memory. for p in state .persisted_loaded_diff_paths .iter() @@ -272,6 +273,7 @@ fn clear_persisted_zone_data(center: &Center, state: &mut ZoneState) { } state.persisted_loaded_diff_paths.clear(); state.persisted_signed_diff_paths.clear(); + state.storage.diffs.clear(); } //----------- PersistenceState -------------------------------------------------