From ecff335399c2ee2ed31fe9d94dee3a5b1819ef34 Mon Sep 17 00:00:00 2001 From: Terts Diepraam Date: Wed, 15 Jul 2026 11:44:06 +0200 Subject: [PATCH 1/2] zone edit command to change zone source and policy --- crates/api/src/lib.rs | 37 +++++++++ crates/cli/src/commands/zone.rs | 54 +++++++++++++ src/center.rs | 14 +++- src/units/http_server.rs | 130 ++++++++++++++++++++++++++++++++ 4 files changed, 234 insertions(+), 1 deletion(-) diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 428050799..3b0d1f6ae 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -345,6 +345,43 @@ impl fmt::Display for ZoneAddError { } } +//----------- ZoneEdit ------------------------------------------------------- + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ZoneEdit { + pub source: Option, + pub policy: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ZoneEditResult { + pub name: ZoneName, + pub status: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub enum ZoneEditError { + NoSuchZone, + NoSuchPolicy, + NoSuchTsigKey, + PolicyMidDeletion, + Other(String), +} + +impl fmt::Display for ZoneEditError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::NoSuchZone => "no zone with that name exists", + Self::NoSuchPolicy => "no policy with that name exists", + Self::NoSuchTsigKey => "no TSIG key with that name exists", + Self::PolicyMidDeletion => "the specified policy is being deleted", + Self::Other(reason) => reason, + }) + } +} + +//----------- ZoneRemove ----------------------------------------------------- + #[derive(Deserialize, Serialize, Debug, Clone)] pub struct ZoneRemoveResult { pub name: ZoneName, diff --git a/crates/cli/src/commands/zone.rs b/crates/cli/src/commands/zone.rs index ed17e6a90..a004e8491 100644 --- a/crates/cli/src/commands/zone.rs +++ b/crates/cli/src/commands/zone.rs @@ -56,6 +56,23 @@ pub enum ZoneCommand { import_csk_kmip: Vec, }, + /// Edit the configuration for a zone + #[command(name = "edit")] + Edit { + name: ZoneName, + + /// The source to obtain the zone content from: + /// `IP:[PORT][^TSIG_KEY_NAME]` (port defaults to 53) or the path to + /// a zone file locally available to the `cascaded` daemon. + // TODO: allow supplying different tcp and/or udp port? + #[arg(long = "source")] + source: Option, + + /// Policy to use for this zone + #[arg(long = "policy")] + policy: Option, + }, + /// Remove a zone #[command(name = "remove")] Remove { name: ZoneName }, @@ -241,6 +258,43 @@ impl Zone { Err(e) => Err(format!("Failed to add zone: {e}")), } } + ZoneCommand::Edit { + name, + mut source, + policy, + } => { + if policy.is_none() && source.is_none() { + return Err("nothing to do".into()); + } + + if let Some(ZoneSource::Zonefile { path }) = &mut source { + let canonicalized_path = path.canonicalize().map_err(|err| { + format!("Failed to canonicalize zonefile path '{}': {err}", path) + })?; + let path_str = canonicalized_path.to_str().ok_or_else(|| { + format!("Failed to convert path '{}'", canonicalized_path.display()) + })?; + *path = Utf8PathBuf::from(path_str).into_boxed_path(); + } + + let res: Result = client + .post_json_with( + &format!("zone/{name}/edit"), + &ZoneEdit { + source: source.map(|s| s.try_into().unwrap()), + policy, + }, + ) + .await?; + + match res { + Ok(res) => { + println!("Edited zone {}: {}", res.name, res.status); + Ok(()) + } + Err(e) => Err(format!("Failed to edit zone: {e}")), + } + } ZoneCommand::Remove { name } => { let res: Result = client.post_json(&format!("zone/{name}/remove")).await?; diff --git a/src/center.rs b/src/center.rs index 89338100f..97c3da3ce 100644 --- a/src/center.rs +++ b/src/center.rs @@ -14,8 +14,8 @@ use tracing::{debug, error, info, trace}; use crate::api::{self, KeyImport, TsigAddError, TsigAddResult}; use crate::config::RuntimeConfig; -use crate::loader::Loader; use crate::loader::zone::LoaderZoneHandle; +use crate::loader::{Loader, Source}; use crate::metrics::Metrics; use crate::persistence::{Persister, Restorer}; use crate::server::{LoadedReviewServer, PublicationServer, SignedReviewServer}; @@ -241,6 +241,18 @@ pub async fn add_zone( Ok(()) } +pub fn change_zone_source(center: &Arc
, zone: &Arc, source: Source) { + let mut state = zone.write(center); + + // Set the source of the zone, and begin loading it. + LoaderZoneHandle { + zone, + state: &mut state, + center, + } + .set_source(source); +} + async fn register_zone( center: &Arc
, name: Name, diff --git a/src/units/http_server.rs b/src/units/http_server.rs index 0c95d4d91..a5807d057 100644 --- a/src/units/http_server.rs +++ b/src/units/http_server.rs @@ -50,6 +50,7 @@ use crate::units::key_manager::KmipServerCredentialsFileMode; use crate::units::key_manager::mk_dnst_keyset_cfg_file_path; use crate::units::key_manager::mk_dnst_keyset_state_file_path; use crate::units::zone_signer::KeySetState; +use crate::zone::ZoneByPtr; use crate::zone::machine::ZoneStateMachine; use crate::zone::{HistoricalEvent, HistoricalEventType, ZoneByName}; @@ -82,6 +83,7 @@ impl HttpServer { .route("/tsig/{name}/remove", post(Self::tsig_key_remove)) .route("/zone/", get(Self::zones_list)) .route("/zone/add", post(Self::zone_add)) + .route("/zone/{name}/edit", post(Self::zone_edit)) // TODO: .route("/zone/{name}/", get(Self::zone_get)) .route("/zone/{name}/remove", post(Self::zone_remove)) .route("/zone/{name}/reset", post(Self::zone_reset)) @@ -300,6 +302,134 @@ impl HttpServer { } } + async fn zone_edit( + State(state): State>, + Path(name): Path>, + Json(zone_edit): Json, + ) -> Json> { + // Poor man's try block + let do_zone_edit = || { + let zone = center::get_zone(&state.center, &name).ok_or(ZoneEditError::NoSuchZone)?; + + let center = &state.center; + let mut state = state.center.state.lock().unwrap(); + let state = &mut *state; + + // To ensure that all changes are made atomically, we do all the operations such as getting + // the zone, policy, tsig key etc. first before we make any changes to the actual zone. + // + // We can then make the required changes without worrying about rolling back anything. + + let policy = zone_edit + .policy + .map(|policy| { + let pol = state + .policies + .get(&policy.into_boxed_str()) + .ok_or(ZoneEditError::NoSuchPolicy)?; + + if pol.mid_deletion { + return Err(ZoneEditError::PolicyMidDeletion); + } + + Ok(pol) + }) + .transpose()?; + + let source = zone_edit + .source + .map(|source| { + Ok(match source { + api::ZoneSource::None => crate::loader::Source::None, + api::ZoneSource::Zonefile { path } => { + crate::loader::Source::Zonefile { path } + } + api::ZoneSource::Server { addr, tsig_key } => { + let tsig_key = if let Some(key_name) = tsig_key { + // Lookup the key in the TSIG key store. + let key = state + .tsig_store + .get_mut(&key_name) + .ok_or(ZoneEditError::NoSuchTsigKey)?; + + let key = key.inner.clone(); + + // Remember the found key. + Some(key) + } else { + None + }; + + crate::loader::Source::Server { addr, tsig_key } + } + }) + }) + .transpose()?; + + // Now we start making actual changes. + // Anything below this point should be infallible. + + let mut changes = Vec::new(); + + if let Some(policy) = policy { + let mut handle = zone.write_handle(center); + let old = handle.state.policy.replace(policy.latest.clone()); + handle.signer().after_policy_change(); + + center.key_manager.on_zone_policy_changed( + center, + &zone, + old, + policy.latest.clone(), + ); + + changes.push("policy"); + } + + if let Some(source) = source { + if let loader::Source::Server { + addr: _, + tsig_key: Some(tsig_key), + } = &source + { + // This seems to be the best way to do this currently. + let key = state + .tsig_store + .get_mut(tsig_key.name()) + .expect("we did a lookup above"); + + // Record that this zone uses this key. + key.zones.insert(ZoneByPtr(zone.clone())); + + state.tsig_store.mark_dirty(center); + } + + center::change_zone_source(center, &zone, source); + + changes.push("source") + } + + let last_change = changes.pop(); + + if let Some(last_change) = last_change { + let changes = changes.join(", "); + let status = if !changes.is_empty() { + format!("updated {changes} and {last_change}") + } else { + format!("updated {last_change}") + }; + Ok(ZoneEditResult { name, status }) + } else { + Ok(ZoneEditResult { + name, + status: "nothing changed".into(), + }) + } + }; + + Json(do_zone_edit()) + } + async fn zone_remove( State(state): State>, Path(name): Path>, From 7894308ad16b1ff9712a22ba6bfa0ecaa1ef7810 Mon Sep 17 00:00:00 2001 From: Terts Diepraam Date: Thu, 16 Jul 2026 14:52:30 +0200 Subject: [PATCH 2/2] Properly link and unlink policies from zones --- src/units/http_server.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/units/http_server.rs b/src/units/http_server.rs index a5807d057..934693948 100644 --- a/src/units/http_server.rs +++ b/src/units/http_server.rs @@ -325,7 +325,7 @@ impl HttpServer { .map(|policy| { let pol = state .policies - .get(&policy.into_boxed_str()) + .get_mut(&policy.into_boxed_str()) .ok_or(ZoneEditError::NoSuchPolicy)?; if pol.mid_deletion { @@ -373,16 +373,33 @@ impl HttpServer { if let Some(policy) = policy { let mut handle = zone.write_handle(center); - let old = handle.state.policy.replace(policy.latest.clone()); + + let old = handle + .state + .policy + .replace(policy.latest.clone()) + .expect("a policy was in use"); + + let old_name = old.name.clone(); + + policy.zones.insert(zone.name.clone()); + handle.signer().after_policy_change(); center.key_manager.on_zone_policy_changed( center, &zone, - old, + Some(old), policy.latest.clone(), ); + state + .policies + .get_mut(&old_name) + .expect("policy should exist") + .zones + .remove(&zone.name); + changes.push("policy"); }