diff options
| author | tv <tv@krebsco.de> | 2026-11-08 17:07:03 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-08 19:28:22 +0100 |
| commit | f91604095e6f0978c61f66d0b937e36271637e6b (patch) | |
| tree | e05cd2a21dd4122c3a8a3a450b3605b5a094416b | |
| parent | 5f69ecf6284a8061a2b8fae5b8f98d67a67dad5f (diff) | |
remake: no --follow-strict-remakes; a strict remake failing says who
A strict remake takes a majority of members whose --strict-keys take
its manifest's signer: no node votes for one without, so
--follow-strict-remakes (1173) goes. A node that doesn't vote still
switches once the others commit, as with any remake.
So that an operator can tell why one fails: a strict remake aborted
for want of a majority (the voting period over, or a majority out of
reach) says in its reason (GET /admin/remakes/{id}, and the
coordinator's log) which members didn't vote, and that a member votes
for one only with its manifest signed by a key of its --strict-keys.
Each of those logs why itself (`Not voting for a strict remake`).
The sim a_strict_remake_too_few_take: only a takes the manifest; the
remake aborts, naming b and c.
| -rw-r--r-- | docs/access.md | 11 | ||||
| -rw-r--r-- | docs/example.toml | 8 | ||||
| -rw-r--r-- | src/config/mod.rs | 12 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/node.rs | 4 | ||||
| -rw-r--r-- | src/remake/coordinator.rs | 43 | ||||
| -rw-r--r-- | src/remake/mod.rs | 4 | ||||
| -rw-r--r-- | src/remake/vote.rs | 9 | ||||
| -rw-r--r-- | src/simtest/mod.rs | 1 | ||||
| -rw-r--r-- | src/simtest/policy.rs | 66 | ||||
| -rw-r--r-- | src/state/host.rs | 4 | ||||
| -rw-r--r-- | src/state/mod.rs | 4 |
12 files changed, 122 insertions, 45 deletions
diff --git a/docs/access.md b/docs/access.md index 9bd99cc..458c2dd 100644 --- a/docs/access.md +++ b/docs/access.md @@ -411,11 +411,12 @@ before, plain or signed by a key its planter put there itself, is gone unless th operator kept it. Every node is given the keys whose signature it takes on such a remake, out of the -document (`--strict-keys`, `authorized_keys` lines): one an operator holds. A node -votes for a strict remake only with its manifest signed by one of them; without any, -it votes for none (logged, `Not voting for a strict remake`), unless -`--follow-strict-remakes`. Once a majority commits one, every node switches, as with -any remake. +document (`--strict-keys`, `authorized_keys` lines): one an operator holds, the same +on a majority of the members at least. A node votes for a strict remake only with its +manifest signed by one of them; without any, for none (logged, `Not voting for a +strict remake`). Once a majority commits one, every node switches, as with any +remake. One that gets no majority aborts, its reason naming the members that didn't +vote: those whose `--strict-keys` don't take the manifest's signer, or that were away. ```sh continuum-signed strict diff --git a/docs/example.toml b/docs/example.toml index 2c3a2ab..d5af848 100644 --- a/docs/example.toml +++ b/docs/example.toml @@ -107,14 +107,10 @@ htpasswd = "/etc/continuum/htpasswd" # SSH keys, as authorized_keys lines (ssh-ed25519 <base64> [comment]), whose signature # on a strict remake's manifest this node takes: the values kept when the swarm turns -# strict (docs/access.md). Without, it votes for no strict remake, unless -# follow-strict-remakes. +# strict (docs/access.md). Without, it votes for no strict remake; a majority of the +# members must take the manifest's signer for one to commit. # strict-keys = ["ssh-ed25519 AAAA… alice"] -# Without strict-keys: vote for strict remakes all the same, taking the manifests as -# the members proposing them sign them. -# follow-strict-remakes = false - # Where the reverse proxies whose user-header is believed connect from: IP addresses (or # ranges, as ADDRESS/BITS), and unix for the API's Unix sockets (bind-addr). On a host # with other users, leave out the addresses and have the proxy use a Unix socket: others diff --git a/src/config/mod.rs b/src/config/mod.rs index fb707dd..0352832 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -184,14 +184,7 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ Kind::List, "KEY,[KEY...]", None, - "SSH keys, as authorized_keys lines (ssh-ed25519 <base64> [comment]), whose signature on a strict remake's manifest this node takes: the values kept when the swarm turns strict (docs/access.md). Without, it votes for no strict remake, unless follow-strict-remakes.", - ), - option( - "follow-strict-remakes", - Kind::Flag, - "BOOL", - Some("false"), - "Without strict-keys: vote for strict remakes all the same, taking the manifests as the members proposing them sign them.", + "SSH keys, as authorized_keys lines (ssh-ed25519 <base64> [comment]), whose signature on a strict remake's manifest this node takes: the values kept when the swarm turns strict (docs/access.md). Without, it votes for no strict remake; a majority of the members must take the manifest's signer for one to commit.", ), option( "log-format", @@ -610,8 +603,7 @@ pub(crate) struct Settings { pub(crate) bootstrap_keys: Vec<String>, #[serde(default)] pub(crate) strict_keys: Vec<String>, - #[serde(default)] - pub(crate) follow_strict_remakes: bool, + #[serde(default)] pub(crate) cors_origins: Vec<String>, #[serde(default)] diff --git a/src/main.rs b/src/main.rs index d0209c0..f632fe9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -265,7 +265,6 @@ async fn main() -> Result<()> { .iter() .filter_map(|line| access::sshsig::PublicKey::parse(line).ok()) .collect(), - follow_strict_remakes: args.follow_strict_remakes, key_logins_per_user: args.key_logins_per_user, cors_origins: args.cors_origins.clone(), files, diff --git a/src/node.rs b/src/node.rs index a097a3a..ddb9b7e 100644 --- a/src/node.rs +++ b/src/node.rs @@ -78,9 +78,8 @@ pub(crate) struct NodeConfig { /// `--challenge-lifetime`, `--key-logins-per-user`. pub(crate) challenge_lifetime: Duration, pub(crate) key_logins_per_user: usize, - /// `--strict-keys`, `--follow-strict-remakes`. + /// `--strict-keys`. pub(crate) strict_keys: Vec<crate::access::sshsig::PublicKey>, - pub(crate) follow_strict_remakes: bool, /// `--cors-origins`. pub(crate) cors_origins: Vec<String>, pub(crate) files: Arc<Files>, @@ -175,7 +174,6 @@ impl Node { challenge_lifetime: config.challenge_lifetime, key_logins_per_user: config.key_logins_per_user, strict_keys: Arc::new(config.strict_keys), - follow_strict_remakes: config.follow_strict_remakes, cors_origins: Arc::new(config.cors_origins), state_file: config.state_file, document_file: config.document_file, diff --git a/src/remake/coordinator.rs b/src/remake/coordinator.rs index 314cd87..0d7a434 100644 --- a/src/remake/coordinator.rs +++ b/src/remake/coordinator.rs @@ -138,7 +138,10 @@ async fn step<H: Host>(host: &H, id: &str) -> Result<bool, StoreError> { decide_abort( host, id, - format!("no majority in the voting period: {tally}"), + format!( + "no majority in the voting period: {tally}{}", + strict_hint(id, &record, ®ister) + ), ) .await?; return Ok(true); @@ -149,12 +152,48 @@ async fn step<H: Host>(host: &H, id: &str) -> Result<bool, StoreError> { return Ok(true); } if tally.out_of_reach() { - decide_abort(host, id, format!("a majority is out of reach: {tally}")).await?; + decide_abort( + host, + id, + format!( + "a majority is out of reach: {tally}{}", + strict_hint(id, &record, ®ister) + ), + ) + .await?; return Ok(true); } Ok(false) } +/// For a strict remake `id` without a majority: who didn't vote, and why they may not +/// have (`vote::strict_taken`), for its operator to tell from the reason. Nothing for +/// another kind, or if all voted. +fn strict_hint(id: &str, record: &Record, register: &records::Register) -> String { + if record.proposal.kind != "strict" { + return String::new(); + } + let silent: Vec<&str> = record + .proposal + .electorate + .iter() + .filter(|member| { + register + .get(member.as_str()) + .is_none_or(|votes| !votes.iter().any(|vote| vote.remake == id)) + }) + .map(String::as_str) + .collect(); + if silent.is_empty() { + return String::new(); + } + format!( + "; a strict remake, not voted for by {}: a member votes for one only with its \ + manifest signed by a key of its --strict-keys (or it was away)", + silent.join(", ") + ) +} + /// Why remake `id` must be aborted whatever its votes, if it must. fn obstacle(id: &str, record: &Record, all: &[(String, Record)]) -> Option<String> { if let Some(request) = &record.abort_requested { diff --git a/src/remake/mod.rs b/src/remake/mod.rs index a075c69..33ac748 100644 --- a/src/remake/mod.rs +++ b/src/remake/mod.rs @@ -330,10 +330,8 @@ pub(crate) trait Host: Clone + Send + Sync + 'static { /// What a replay does with writes a newer one superseded (`--superseded-writes`). fn superseded(&self) -> Superseded; - /// Whose signature on a strict remake's manifest this node takes (`--strict-keys`), - /// and whether it votes for one without any (`--follow-strict-remakes`). + /// Whose signature on a strict remake's manifest this node takes (`--strict-keys`). fn strict_keys(&self) -> &[crate::access::sshsig::PublicKey]; - fn follows_strict_remakes(&self) -> bool; /// Applies a change downloaded from peers (`fetch_change`), then publishes it like /// a change received by sync. diff --git a/src/remake/vote.rs b/src/remake/vote.rs index c231afa..073fcda 100644 --- a/src/remake/vote.rs +++ b/src/remake/vote.rs @@ -375,8 +375,7 @@ pub(super) async fn cast<H: Host>(host: &H) { } /// Whether this node takes a strict remake's manifest (`content`, for `swarm`): signed -/// by one of its `--strict-keys`; without any, if it follows strict remakes all the same -/// (`--follow-strict-remakes`). Why not, if not. +/// by one of its `--strict-keys`. Why not, if not. pub(super) fn strict_taken<H: Host>( host: &H, content: Option<&[u8]>, @@ -386,11 +385,7 @@ pub(super) fn strict_taken<H: Host>( .and_then(|bytes| std::str::from_utf8(bytes).ok()) .ok_or("no manifest")?; if host.strict_keys().is_empty() { - return if host.follows_strict_remakes() { - crate::access::strict::read(text).map(|_| ()) - } else { - Err("this node has no --strict-keys (nor --follow-strict-remakes)".to_owned()) - }; + return Err("this node has no --strict-keys".to_owned()); } crate::access::strict::verify(text, host.strict_keys(), swarm).map(|_| ()) } diff --git a/src/simtest/mod.rs b/src/simtest/mod.rs index 67ddaff..6921ed6 100644 --- a/src/simtest/mod.rs +++ b/src/simtest/mod.rs @@ -223,7 +223,6 @@ impl Sim { challenge_lifetime: Duration::from_secs(300), key_logins_per_user: 100, strict_keys: Vec::new(), - follow_strict_remakes: false, cors_origins: Vec::new(), files: Arc::new(Files::default()), state_file: None, diff --git a/src/simtest/policy.rs b/src/simtest/policy.rs index 0d590d7..7e881e7 100644 --- a/src/simtest/policy.rs +++ b/src/simtest/policy.rs @@ -1012,3 +1012,69 @@ async fn a_swarm_turning_strict_through_a_remake() { assert!(crate::access::policy::of(&replica.doc).strict()); } } + +/// A strict remake only one member's `--strict-keys` take: no majority, aborted, its +/// reason naming the members that didn't vote, for the operator to tell why. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_strict_remake_too_few_take() { + use crate::access::{sshsig, strict}; + let key = ed25519_dalek::SigningKey::from_bytes(&[13; 32]); + let public = sshsig::PublicKey(key.verifying_key().to_bytes()); + let sim = Sim::with_identity(93); + let a = sim.start_with("a", &[], Start::Genesis, move |config| { + config.first_users = [("alice".to_owned(), Default::default())].into(); + config.strict_keys = vec![public]; + }); + let b = sim.start("b", &["a"], Start::Join); + let c = sim.start("c", &["a"], Start::Join); + sim.wait_ready(&[&a, &b, &c], SOON).await; + let nodes = [a.clone(), b.clone(), c.clone()]; + sim.wait_until("every node is registered", SOON, || { + let nodes = nodes.clone(); + async move { + for node in &nodes { + if crate::swarm::members::members(&node.doc.read().await.doc).len() < 3 { + return false; + } + } + true + } + }) + .await; + let text = { + let replica = a.doc.read().await; + let swarm = store::read_path(&replica.doc, &path("_continuum/swarm")) + .unwrap() + .unwrap(); + let policy = json!({"version": 1, "values": "signed"}); + let line = strict::manifest_of(&replica.doc, swarm.as_str().unwrap(), policy, "alice", 1); + let signed = sshsig::sign(&key, strict::NAMESPACE, line.as_bytes()); + format!("{line}\n{signed}") + }; + let (status, report) = crate::remake::testing::start( + a.clone(), + json!({"kind": "strict", "spec": {"manifest": text}, "settle": "3s"}), + None, + ) + .await; + assert!(status.is_success(), "proposal refused: {status} {report}"); + let id = report["id"].as_str().expect("remake id").to_owned(); + let (a_ref, id_ref) = (&a, id.as_str()); + sim.wait_until("the strict remake is aborted", SOON, move || async move { + crate::remake::testing::report(a_ref, id_ref) + .await + .is_some_and(|report| report["phase"] == "aborted") + }) + .await; + let report = crate::remake::testing::report(&a, &id).await.unwrap(); + let reason = report.to_string(); + assert!( + reason.contains("a strict remake, not voted for by"), + "{reason}" + ); + assert!( + reason.contains(b.local_id()) && reason.contains(c.local_id()), + "{reason}" + ); + assert_eq!(a.local_epoch().generation, 0); +} diff --git a/src/state/host.rs b/src/state/host.rs index 82b6c7c..ac36171 100644 --- a/src/state/host.rs +++ b/src/state/host.rs @@ -424,10 +424,6 @@ impl Host for AppState { &self.strict_keys } - fn follows_strict_remakes(&self) -> bool { - self.follow_strict_remakes - } - fn begin_rejoin( &self, target: Epoch, diff --git a/src/state/mod.rs b/src/state/mod.rs index 6e25336..ed93fda 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -161,10 +161,8 @@ pub(crate) struct AppState { /// may make within it (`access::challenge`). pub(crate) challenge_lifetime: Duration, pub(crate) key_logins_per_user: usize, - /// Whose signature on a strict remake's manifest this node takes, and whether it - /// takes any without (`access::strict`). + /// Whose signature on a strict remake's manifest this node takes (`access::strict`). pub(crate) strict_keys: Arc<Vec<crate::access::sshsig::PublicKey>>, - pub(crate) follow_strict_remakes: bool, /// Origins whose pages may call this node's open endpoints (`api::cors`). pub(crate) cors_origins: Arc<Vec<String>>, /// This node's own files, and how much it read and wrote them. |
