aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/access.md11
-rw-r--r--docs/example.toml8
-rw-r--r--src/config/mod.rs12
-rw-r--r--src/main.rs1
-rw-r--r--src/node.rs4
-rw-r--r--src/remake/coordinator.rs43
-rw-r--r--src/remake/mod.rs4
-rw-r--r--src/remake/vote.rs9
-rw-r--r--src/simtest/mod.rs1
-rw-r--r--src/simtest/policy.rs66
-rw-r--r--src/state/host.rs4
-rw-r--r--src/state/mod.rs4
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, &register)
+ ),
)
.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, &register)
+ ),
+ )
+ .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.