diff --git a/crates/zonedata/src/storage/mod.rs b/crates/zonedata/src/storage/mod.rs index ea92d84d4..d979c46de 100644 --- a/crates/zonedata/src/storage/mod.rs +++ b/crates/zonedata/src/storage/mod.rs @@ -7,7 +7,9 @@ use std::sync::Arc; -use crate::{LoadedZoneRestorer, LoadedZoneReviewer, SignedZoneReviewer, ZoneViewer, data::Data}; +use crate::{ + DiffData, LoadedZoneRestorer, LoadedZoneReviewer, SignedZoneReviewer, ZoneViewer, data::Data, +}; mod states; pub use states::{ @@ -135,4 +137,27 @@ impl ZoneDataStorage { ZoneDataStorage::Poisoned => "Poisoned", } } + + /// Get the current loaded diff, if any. + pub fn loaded_diff(&self) -> Option> { + match self { + ZoneDataStorage::ReviewLoadedPending(s) => Some(s.loaded_diff.clone()), + ZoneDataStorage::ReviewingLoaded(s) => Some(s.loaded_diff.clone()), + ZoneDataStorage::PersistingLoaded(s) => Some(s.loaded_diff.clone()), + _ => None, + } + } + + /// Get the current signed diff, if any. + pub fn signed_diff(&self) -> Option> { + match self { + ZoneDataStorage::ReviewSignedPending(s) => Some(s.signed_diff.clone()), + ZoneDataStorage::ReviewingSigned(s) => Some(s.signed_diff.clone()), + ZoneDataStorage::PersistingSigned(_) => { + // PersistingSigned has no diff unlike PersistingLoaded + None + } + _ => None, + } + } } diff --git a/src/persistence/persist.rs b/src/persistence/persist.rs index 3981e81df..983ae2c2b 100644 --- a/src/persistence/persist.rs +++ b/src/persistence/persist.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use cascade_zonedata::{ - LoadedZonePersisted, LoadedZonePersister, SignedZonePersisted, SignedZonePersister, + DiffData, LoadedZonePersisted, LoadedZonePersister, SignedZonePersisted, SignedZonePersister, }; use crate::{center::Center, zone::Zone}; @@ -19,8 +19,30 @@ pub fn persist_loaded( center: &Arc
, persister: LoadedZonePersister, ) -> LoadedZonePersisted { - // TODO - let _ = (zone, center); + let _ = center; + + // Store the loaded diff in-memory for serving IXFR out. + + let loaded_diff = persister.loaded_diff(); + + // 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. + 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 + // required to change unless using 'keep' policy and so we should not + // require the SOA to have been removed and a new one added. + let mut state = zone.state.lock().unwrap(); + + let loaded_only_diff = (persister.loaded_diff().clone(), DiffData::new().into()); + state.storage.diffs.push(loaded_only_diff); + } + persister.mark_complete() } @@ -35,7 +57,36 @@ pub fn persist_signed( center: &Arc
, persister: SignedZonePersister, ) -> SignedZonePersisted { - // TODO - let _ = (zone, center); + let _ = center; + + // 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 loaded_diff = loaded_diff.cloned().unwrap_or(DiffData::new().into()); + let complete_diff = (loaded_diff, signed_diff.clone()); + state.storage.diffs.push(complete_diff); + } + persister.mark_complete() } diff --git a/src/server/mod.rs b/src/server/mod.rs index 0bc99fc64..94c1e41d1 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -34,7 +34,7 @@ pub struct LoadedReviewServer { impl LoadedReviewServer { /// Construct a new [`LoadedReviewServer`]. pub fn new() -> Self { - let (service, handle) = ZoneService::new(); + let (service, handle) = ZoneService::new(service::ServiceMode::LoadedReview); Self { service, handle } } @@ -123,7 +123,7 @@ pub struct SignedReviewServer { impl SignedReviewServer { /// Construct a new [`SignedReviewServer`]. pub fn new() -> Self { - let (service, handle) = ZoneService::new(); + let (service, handle) = ZoneService::new(service::ServiceMode::SignedReview); Self { service, handle } } @@ -212,7 +212,7 @@ pub struct PublicationServer { impl PublicationServer { /// Construct a new [`PublicationServer`]. pub fn new() -> Self { - let (service, handle) = ZoneService::new(); + let (service, handle) = ZoneService::new(service::ServiceMode::Publication); Self { service, handle } } 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..8bef675f7 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -39,6 +39,16 @@ pub struct ZoneService { // of the limitations of 'domain::net::server'; that architecture should be // gradually replaced locally for better flexibility and efficiency. state: Arc>>, + + /// What mode of operation is intended? + mode: ServiceMode, +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum ServiceMode { + LoadedReview, + SignedReview, + Publication, } impl ZoneService { @@ -46,10 +56,11 @@ impl ZoneService { /// /// In addition to the service, a [`ZoneServiceHandle`] is returned through /// which the service can be interacted with. - pub fn new() -> (ZoneService, ZoneServiceHandle) { + pub fn new(server_mode: ServiceMode) -> (ZoneService, ZoneServiceHandle) { let state = Arc::new(std::sync::RwLock::default()); let service = ZoneService { state: state.clone(), + mode: server_mode, }; let handle = ZoneServiceHandle { state }; (service, handle) @@ -60,6 +71,7 @@ impl Clone for ZoneService { fn clone(&self) -> Self { Self { state: self.state.clone(), + mode: self.mode, } } } @@ -72,19 +84,26 @@ impl Clone for ZoneService { mod compat { use std::{pin::Pin, sync::Arc}; - use cascade_zonedata::OldRecord; + use cascade_zonedata::{DiffData, OldRecord}; use domain::{ base::{Message, MessageBuilder, iana::Rcode}, net::server::{ message::Request, service::{CallResult, Service, ServiceResult}, }, - new::base::wire::ParseBytesZC, + new::{ + base::{name::Name, wire::ParseBytesZC}, + rdata::Soa, + }, tsig, }; use futures::Stream; + use tracing::{Level, debug, trace, warn}; - use crate::server::request::{RequestKind, ZoneRequestKind}; + use crate::server::{ + request::{RequestKind, ZoneRequestKind}, + service::ServiceMode, + }; use super::{ServedZone, Viewer, ZoneService}; @@ -128,7 +147,7 @@ mod compat { return Box::pin(std::future::ready(error(old_request.message(), rcode))); }; - if !is_permitted(zone, &old_request) { + if self.mode == ServiceMode::Publication && !is_permitted(zone, &old_request) { return Box::pin(std::future::ready(error( old_request.message(), Rcode::REFUSED, @@ -148,11 +167,10 @@ mod compat { Box::pin(axfr(old_request, zone.clone())) as Response } - // 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(), self.mode)) + as Response + } } } } @@ -165,6 +183,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() @@ -186,6 +219,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; } } @@ -194,9 +248,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(); @@ -211,6 +270,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, @@ -225,7 +285,12 @@ mod compat { 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); + warn!( + "Returning SERVFAIL for AXFR request from client '{}' for zone '{}': zone is empty", + request.client_addr().ip(), + zone.handle.name, + ); + return error(request.message(), Rcode::SERVFAIL); } // NOTE: The following code is a bit tricky. Ideally, we would elide @@ -236,7 +301,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. @@ -294,6 +359,349 @@ mod compat { Box::new(stream) as _ } + /// Generate an IXFR DNS message response stream for the given zone. + /// + /// For the loaded review server match the client provided SOA against + /// loaded diff SOAs. For the signed review and publication servers match + /// the client provided SOA against signed diff SOAs. + async fn ixfr( + request: Request, Option>>, + client_soa: Soa>, + zone: ServedZone, + mode: ServiceMode, + ) -> ResponseStream { + // 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. + warn!( + "Returning SERVFAIL for AXFR request from client '{}' for zone '{}': zone is empty", + request.client_addr().ip(), + zone.handle.name, + ); + return error(request.message(), Rcode::SERVFAIL); + } + + // 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(); + + // 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." + 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 = { + let zone_state = zone.handle.state.lock().unwrap(); + + match mode { + ServiceMode::LoadedReview => { + let mut diffs = Vec::with_capacity(1); + if let Some(loaded_diff) = zone_state.storage.current_loaded_diff() { + let empty_signed_diff = Arc::new(DiffData::new()); + diffs.push((loaded_diff, empty_signed_diff)); + } + diffs + } + ServiceMode::SignedReview => { + let mut diffs = Vec::with_capacity(1); + if let Some(signed_diff) = zone_state.storage.current_signed_diff() { + let empty_loaded_diff = Arc::new(DiffData::new()); + diffs.push((empty_loaded_diff, signed_diff)); + } + diffs + } + ServiceMode::Publication => zone_state.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. + + if tracing::enabled!(Level::DEBUG) { + debug!("IXFR out: client serial: {}", client_soa.serial); + debug!( + "IXFR out: {} diffs available for zone {}:", + diffs.len(), + zone.handle.name + ); + for (i, (loaded_diff, signed_diff)) in diffs.iter().enumerate() { + debug!( + "IXFR out: Diff #{i}: loaded serial -{:?}+{:?} => signed serial -{:?}+{:?}, loaded -{}+{}, signed -{}+{}", + loaded_diff + .removed_soa + .as_ref() + .map(|soa_rr| soa_rr.0.rdata.serial), + loaded_diff + .added_soa + .as_ref() + .map(|soa_rr| soa_rr.0.rdata.serial), + signed_diff + .removed_soa + .as_ref() + .map(|soa_rr| soa_rr.0.rdata.serial), + signed_diff + .added_soa + .as_ref() + .map(|soa_rr| soa_rr.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. The SOA serial has to match the one seen + // by the client, i.e. we need to know if the client requested the + // IXFR from a loaded review server and thus the client SOA serial + // should be matched against a loaded SOA serial, or if the client + // requested the IXFR from a signed review or publication server in + // which case the client SOA serial should be matched against a signed + // SOA serial. + let start_idx = { + diffs.iter().position(|(loaded_diff, signed_diff)| { + let d = if mode == ServiceMode::LoadedReview { + loaded_diff + } else { + signed_diff + }; + d.removed_soa.as_ref().map(|rr| rr.0.rdata.serial) == Some(client_soa.serial) + }) + }; + + let Some(start_idx) = start_idx else { + debug!( + "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); + + // 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. + let mut rrs = vec![new_soa.clone().into()]; + + // Serve the correct records: + // - to a client of the loaded review server, who shouldn't + // receive any signed records in the IXFR response, OR + // - to a client of the signed review or publication server, + // who should be served differences in both loaded and signed + // records in the IXFR response. + // + // There is a lot of overlap between these two branches but I + // find it much harder to understand if the code is merged so I + // kept it separate like this. + let mut last_removed_soa = None; + let mut last_added_soa = None; + + for (i, (loaded_diff, signed_diff)) in diffs[start_idx..].iter().enumerate() { + let abs_idx = start_idx + i; + + // Select the appropriate diff as the SOA source to use. + let soa_source_diff = if mode == ServiceMode::LoadedReview { + loaded_diff + } else { + signed_diff + }; + + // Skip diffs that didn't change the key part of the zone, + // loaded or signed, being served, i.e. + // - For a loaded review server, skip a change that only + // occured in the signed zone e.g. due to incremental + // re-signing. + // - For a signed review / publication server, skip a change + // that only occured in the loaded zone and not (yet) in + // the signed zone. + if soa_source_diff.is_empty() { + continue; + } + + // To serve an IXFR we must have a change in serial number. + assert!(soa_source_diff.removed_soa.is_some()); + assert!(soa_source_diff.added_soa.is_some()); + let removed_soa = soa_source_diff.removed_soa.as_ref().unwrap(); + let added_soa = soa_source_diff.added_soa.as_ref().unwrap(); + + // Skip a diff if we encounter the same SOA serial + // again which can happen if the loaded zone remained + // unchanged but incremental signing caused changes in the + // signed part only to occur. + if mode == ServiceMode::LoadedReview + && let Some(last_removed_soa) = last_removed_soa + && removed_soa == last_removed_soa + { + trace!("Skipping unchanged loaded diff #{abs_idx}."); + continue; + } + + // Ensure that the sequence of diffs has no SOA serial + // gaps, each last added SOA should be the SOA that the + // next diff sequence removes. + if let Some(last_added_soa) = last_added_soa { + assert_eq!(removed_soa, last_added_soa); + } + + if tracing::enabled!(Level::TRACE) { + trace_diff_pair(abs_idx, loaded_diff, signed_diff); + } + + trace!("Serving diff #{abs_idx} for loaded review server IXFR out",); + + if mode == ServiceMode::LoadedReview { + // Remove old records. + rrs.push(removed_soa.clone().into()); + rrs.extend(soa_source_diff.removed_records.clone()); + + // Add new records. + rrs.push(added_soa.clone().into()); + rrs.extend(soa_source_diff.added_records.clone()); + } else { + // Remove old records. + rrs.push(removed_soa.clone().into()); + rrs.extend(loaded_diff.removed_records.clone()); + rrs.extend(soa_source_diff.removed_records.clone()); + + // Add new records. + rrs.push(added_soa.clone().into()); + rrs.extend(loaded_diff.added_records.clone()); + rrs.extend(soa_source_diff.added_records.clone()); + } + + last_removed_soa = Some(removed_soa); + last_added_soa = Some(added_soa); + } + 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 trace_diff_pair(diff_idx: usize, loaded_diff: &Arc, signed_diff: &Arc) { + fn trace_diff(prefix: &str, diff_idx: usize, d: &Arc) { + trace!( + "{prefix} diff #{}: {:?}->{:?}: {:?}->{:?}", + diff_idx, + d.removed_soa.as_ref().map(|s| s.rdata.serial), + d.added_soa.as_ref().map(|s| s.rdata.serial), + d.removed_records + .iter() + .map(|r| format!("{:?}", r.rtype)) + .collect::>() + .join(","), + d.added_records + .iter() + .map(|r| format!("{:?}", r.rtype)) + .collect::>() + .join(","), + ); + } + + trace_diff("Loaded", diff_idx, loaded_diff); + trace_diff("Signed", diff_idx, signed_diff); + } + fn error(request: &Message>, rcode: Rcode) -> ResponseStream { let response = MessageBuilder::new_stream_vec() .start_error(request, rcode) diff --git a/src/zone/storage.rs b/src/zone/storage.rs index aa8013d51..418c3f2b0 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,16 @@ pub struct StorageState { // current i.e. published zone instance. pub published_loaded_soa: Option, + /// Diffs from one serial to another. Each diff consists of changes in the + /// loaded part and changes in the signed part. + /// + /// A loaded diff is not available if the zone was re-signed due to + /// changes in signing key or to refresh expiring signatures. + /// + /// A signed diff is not available if the zone has been re-loaded and has + /// not yet been signed, e.g. is held at review or signing is in-progress. + pub diffs: Vec<(Arc, Arc)>, + /// Ongoing background tasks. /// /// When the zone data needs to be cleaned or persisted, a background task @@ -944,9 +954,20 @@ impl StorageState { signed_review_soa: None, published_soa: None, published_loaded_soa: None, + diffs: Default::default(), background_tasks: Default::default(), } } + + /// Get the current loaded diff, if any. + pub fn current_loaded_diff(&self) -> Option> { + self.machine.loaded_diff() + } + + /// Get the current signed diff, if any. + pub fn current_signed_diff(&self) -> Option> { + self.machine.signed_diff() + } } impl Default for StorageState {