diff options
| author | tv <tv@krebsco.de> | 2026-11-08 16:27:35 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-08 17:40:49 +0100 |
| commit | 5f69ecf6284a8061a2b8fae5b8f98d67a67dad5f (patch) | |
| tree | 4af21471a3b7d76ba61cbe60455385774708aacb /src/remake/api.rs | |
| parent | d0b773065c441d3e153d9d53bca20ead655b17e7 (diff) | |
remake: turning a running swarm strict, keeping what an operator signed
Until a swarm is strict, the user a change names is its node's word, so
any member could have planted registry entries, policy, bans or
user-signed values as any user, plain or signed by a key it put there
itself; writing the strict policy trusted all of it as it was.
A remake of a new kind, `strict`, turns a swarm strict instead:
* Its content is a manifest (access::strict): the swarm, the strict
policy, and each value kept at the places values are signed, by the
SHA-256 of its JSON, signed by an operator (ssh-keygen -Y sign -n
continuum-strict).
* Every node builds the next epoch as compaction does, keeping at the
signed places only the values the manifest names, as they were at
the base, and writing its policy (RemakeKind::protocol, the one kind
that changes protocol data). Writes after the base reach the new
epoch only by replays, judged by the strict policy.
* A node votes for one only with its manifest signed by one of
--strict-keys, keys given out of the document; without any, for none
(logged once, `Not voting for a strict remake`), unless
--follow-strict-remakes. A coordinator proposes only what it would
vote for (403). Once a majority commits one, every node switches, as
with any remake: refusing to switch would only cut a node off.
* GET /admin/strict-manifest gives the manifest keeping everything held
now, the values for review and the heads they are at;
continuum-signed strict shows them, signs once confirmed and proposes
at those heads.
The sim a_swarm_turning_strict_through_a_remake: a member plants a
registry entry as a manager; the operator's manifest leaves it out; a
and b, given the key, commit; c, without, follows; the entry is gone
and the policy strict everywhere.
Diffstat (limited to 'src/remake/api.rs')
| -rw-r--r-- | src/remake/api.rs | 68 |
1 files changed, 68 insertions, 0 deletions
diff --git a/src/remake/api.rs b/src/remake/api.rs index 7888c37..79b551f 100644 --- a/src/remake/api.rs +++ b/src/remake/api.rs @@ -8,6 +8,11 @@ //! * `GET /admin/remakes` lists all remakes. //! * `GET /admin/remakes/{id}` reports one; `?wait=true` long-polls until decided //! (bounded by the proposal's own expiry). +//! * `GET /admin/strict-manifest`: a manifest turning the swarm strict (`access::strict`), +//! keeping every value at the places values are signed as they are now, for the +//! caller to look over and sign; with those values and the heads they are at, the +//! base to propose it with (`{"kind": "strict", "spec": {"manifest": …}, "baseHeads": +//! …}`). //! * `GET /admin/epochs` shows the local epoch, the archived previous ones and whether //! the node is catching up with a newer epoch from a peer. //! * `DELETE /admin/remakes/{id}` asks the coordinator to abort. Only possible while @@ -374,6 +379,29 @@ async fn start_kind<H: Host>( } }; + // A strict remake: proposed only with a manifest this node would vote for. + if kind.name() == "strict" { + let swarm = host + .read_doc(|doc| { + crate::document::store::read_path( + doc, + &crate::document::meta::path(&[crate::document::meta::SWARM]), + ) + .ok() + .flatten() + .and_then(|swarm| swarm.as_str().map(str::to_owned)) + .unwrap_or_default() + }) + .await; + if let Err(why) = super::vote::strict_taken(&host, prepared.content.as_deref(), &swarm) { + return error_response( + StatusCode::FORBIDDEN, + format!("this node takes no such manifest: {why}"), + None, + ); + } + } + if kind.base_at_commit() && request.base_heads.is_some() { return error_response( StatusCode::BAD_REQUEST, @@ -782,6 +810,46 @@ async fn abort_expired<H: Host>(host: &H, current: Report, force: &Force) -> Res } } +/// `GET /admin/strict-manifest`. +pub(crate) async fn strict_manifest<H: Host>( + State(host): State<H>, + axum::Extension(user): axum::Extension<crate::api::auth::AuthenticatedUser>, +) -> Response { + let at = host.now_millis().as_u64(); + let caller = user.0.clone(); + let (manifest, values, heads) = host + .read_doc(move |doc| { + let read = |path: &[&str]| { + crate::document::store::read_plain(doc, &crate::document::meta::path(path)) + .ok() + .flatten() + }; + let swarm = read(&[crate::document::meta::SWARM]) + .and_then(|swarm| swarm.as_str().map(str::to_owned)) + .unwrap_or_default(); + let mut policy = match read(&[crate::document::meta::ACCESS, "policy"]) { + Some(JsonValue::Object(policy)) => JsonValue::Object(policy), + _ => json!({"version": 1}), + }; + policy["values"] = json!("signed"); + let manifest = crate::access::strict::manifest_of(doc, &swarm, policy, &caller, at); + let values: Vec<JsonValue> = crate::access::strict::held(doc) + .into_iter() + .map(|(path, value)| json!({ "path": path, "value": value })) + .collect(); + let heads = records::encode_heads(&doc.get_heads()); + (manifest, values, heads) + }) + .await; + Json(json!({ + "manifest": manifest, + "values": values, + "heads": heads, + "namespace": crate::access::strict::NAMESPACE, + })) + .into_response() +} + /// `GET /admin/epochs`: the local epoch, the previous epochs this node still keeps, and /// an ongoing catch-up with a peer's newer epoch (`null` if none). pub(crate) async fn epochs<H: Host>(State(host): State<H>) -> Response { |
