mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
feat(admin): invoke community FI upgrades
Add a one-shot signed operator command and a pod runbook for testing a connected client across a policy change. Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
This commit is contained in:
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -969,6 +969,8 @@ name = "buzz-admin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"buzz-audit",
|
||||
"buzz-auth",
|
||||
"buzz-core",
|
||||
@@ -981,10 +983,13 @@ dependencies = [
|
||||
"clap",
|
||||
"hex",
|
||||
"nostr 0.44.7",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
|
||||
2
Justfile
2
Justfile
@@ -468,7 +468,7 @@ test-unit:
|
||||
# parser, validation, and propagation-evidence tests, and the partition
|
||||
# audit outcome contract, NIP-FI policy/backfill, and user-deletion intake.
|
||||
cargo nextest run -p buzz-admin \
|
||||
-E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^user_deletions::tests::/)'
|
||||
-E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^nip_fi_upgrade::tests::/) + test(/^user_deletions::tests::/)'
|
||||
# Multi-tenant conformance gate (buzz-conformance): the independent
|
||||
# replay checker + golden fixtures. No infra — pure in-process trace
|
||||
# replay — so it belongs in the unit job. Run all targets (lib + the
|
||||
|
||||
@@ -27,6 +27,9 @@ serde_json = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
# Redis TLS (rediss://, e.g. ElastiCache) uses rustls, which needs a process
|
||||
# CryptoProvider installed at startup. The workspace redis TLS feature compiles
|
||||
# both aws-lc-rs and ring in transitively, so rustls can't auto-select one — we
|
||||
@@ -39,4 +42,6 @@ uuid = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
axum = { workspace = true }
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
mod communities;
|
||||
mod deletions;
|
||||
mod nip_fi;
|
||||
mod nip_fi_upgrade;
|
||||
mod user_deletions;
|
||||
|
||||
use std::future::Future;
|
||||
@@ -124,6 +125,8 @@ enum Command {
|
||||
#[command(subcommand)]
|
||||
command: nip_fi::NipFiCommand,
|
||||
},
|
||||
/// Require NIP-FI through the relay's existing signed operator API.
|
||||
NipFiUpgrade(nip_fi_upgrade::Args),
|
||||
/// Durable CLI-only whole-community deletion control plane.
|
||||
Deletions {
|
||||
#[command(subcommand)]
|
||||
@@ -213,6 +216,11 @@ async fn run(cli: Cli) -> Result<i32> {
|
||||
Command::Deletions { command } => deletions::run(command).await,
|
||||
Command::UserDeletions { command } => user_deletions::run(command).await,
|
||||
Command::NipFi { command } => nip_fi::run(command).await,
|
||||
// Retain transport/file causes for this operator command without changing
|
||||
// the diagnostics of other admin commands. Key parse errors are redacted.
|
||||
Command::NipFiUpgrade(args) => nip_fi_upgrade::run(args)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error:#}")),
|
||||
Command::ReconcileChannels { channel, relay_key } => {
|
||||
reconcile_channels(channel, relay_key).await?;
|
||||
Ok(0)
|
||||
|
||||
526
crates/buzz-admin/src/nip_fi_upgrade.rs
Normal file
526
crates/buzz-admin/src/nip_fi_upgrade.rs
Normal file
@@ -0,0 +1,526 @@
|
||||
//! One-shot calls to the existing operator upgrade API; no database or worker startup.
|
||||
|
||||
use std::{io::Read, path::Path, path::PathBuf, time::Duration};
|
||||
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use clap::{Args as ClapArgs, Subcommand};
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
|
||||
use reqwest::{redirect::Policy, Method};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
const PATH: &str = "/operator/communities/nip-fi-upgrade";
|
||||
const MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Explicit operator destination and protected signing material.
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Must match the relay's RELAY_OPERATOR_API_ORIGIN (HTTP for a trusted mesh, or HTTPS).
|
||||
#[arg(long)]
|
||||
origin: String,
|
||||
/// File containing the authorized deployment operator's hex secret key or nsec.
|
||||
#[arg(long)]
|
||||
key_file: PathBuf,
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Accept or recover an upgrade using the exact saved request tuple.
|
||||
Enable {
|
||||
/// JSON with operation_id, community_id, expected_host, actor_pubkey,
|
||||
/// expected_owner_pubkey and expected_issuer. Reuse after response loss.
|
||||
#[arg(long)]
|
||||
request: PathBuf,
|
||||
},
|
||||
/// Read the original operator's durable receipt without sending a mutation.
|
||||
Status {
|
||||
#[arg(long)]
|
||||
operation_id: Uuid,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UpgradeRequest {
|
||||
operation_id: Uuid,
|
||||
community_id: Uuid,
|
||||
expected_host: String,
|
||||
actor_pubkey: String,
|
||||
expected_owner_pubkey: String,
|
||||
expected_issuer: String,
|
||||
}
|
||||
|
||||
impl UpgradeRequest {
|
||||
fn validate(&self) -> Result<()> {
|
||||
ensure!(
|
||||
!self.operation_id.is_nil() && !self.community_id.is_nil(),
|
||||
"UUIDs must not be nil"
|
||||
);
|
||||
let host = crate::communities::normalize_host_authority(&self.expected_host)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
ensure!(
|
||||
host == self.expected_host && !host.contains(':'),
|
||||
"expected_host must be canonical lowercase without a port or trailing dot"
|
||||
);
|
||||
for key in [&self.actor_pubkey, &self.expected_owner_pubkey] {
|
||||
ensure!(
|
||||
key.len() == 64
|
||||
&& key
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)),
|
||||
"owner public keys must be 64 lowercase hex characters"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bounded(path: &Path, limit: usize) -> Result<Vec<u8>> {
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(path)
|
||||
.with_context(|| format!("cannot open {}", path.display()))?
|
||||
.take((limit + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
ensure!(bytes.len() <= limit, "input file exceeds size limit");
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn endpoint(origin: &str, operation: Option<Uuid>) -> Result<Url> {
|
||||
let base = Url::parse(origin).context("invalid operator origin")?;
|
||||
ensure!(
|
||||
matches!(base.scheme(), "http" | "https")
|
||||
&& base.host_str().is_some()
|
||||
&& base.username().is_empty()
|
||||
&& base.password().is_none()
|
||||
&& base.path() == "/"
|
||||
&& base.query().is_none()
|
||||
&& base.fragment().is_none(),
|
||||
"operator origin must be an HTTP(S) origin without credentials, path, query or fragment"
|
||||
);
|
||||
let mut url = base.join(PATH)?;
|
||||
if let Some(id) = operation {
|
||||
ensure!(!id.is_nil(), "operation_id must not be nil");
|
||||
url.query_pairs_mut()
|
||||
.append_pair("operation_id", &id.to_string());
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn authorization(keys: &Keys, method: &Method, url: &Url, body: Option<&[u8]>) -> Result<String> {
|
||||
let mut tags = vec![
|
||||
Tag::parse(["u", url.as_str()])?,
|
||||
Tag::parse(["method", method.as_str()])?,
|
||||
Tag::parse(["nonce", &Uuid::new_v4().to_string()])?,
|
||||
];
|
||||
if let Some(body) = body {
|
||||
tags.push(Tag::parse(["payload", &hex::encode(Sha256::digest(body))])?);
|
||||
}
|
||||
let event = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)?;
|
||||
Ok(format!("Nostr {}", STANDARD.encode(event.as_json())))
|
||||
}
|
||||
|
||||
fn receipt_exit_code(
|
||||
receipt: &Value,
|
||||
operation_id: Uuid,
|
||||
operator: &str,
|
||||
request: Option<&UpgradeRequest>,
|
||||
) -> Result<i32> {
|
||||
ensure!(
|
||||
receipt["operation_id"].as_str() == Some(operation_id.to_string().as_str())
|
||||
&& receipt["operator_pubkey"].as_str() == Some(operator)
|
||||
&& receipt["requires_nip_fi"].as_bool() == Some(true)
|
||||
&& receipt["policy_generation"].as_i64().is_some_and(|g| g > 0),
|
||||
"unconfirmed: response is not the expected durable acceptance receipt"
|
||||
);
|
||||
if let Some(request) = request {
|
||||
let expected = serde_json::to_value(request)?;
|
||||
ensure!(
|
||||
expected
|
||||
.as_object()
|
||||
.context("request must be an object")?
|
||||
.iter()
|
||||
.all(|(k, v)| receipt[k] == *v),
|
||||
"unconfirmed: receipt tuple does not match the saved request"
|
||||
);
|
||||
}
|
||||
let reasons = receipt["pending_reasons"]
|
||||
.as_array()
|
||||
.context("unconfirmed: missing pending reasons")?;
|
||||
ensure!(
|
||||
reasons.iter().all(Value::is_string),
|
||||
"unconfirmed: invalid pending reasons"
|
||||
);
|
||||
match receipt["state"].as_str() {
|
||||
Some("active") if reasons.is_empty() => Ok(0),
|
||||
Some("pending") => Ok(10),
|
||||
Some("unavailable") => Ok(12),
|
||||
_ => anyhow::bail!("unconfirmed: invalid upgrade state"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send exactly one signed request. A timeout never means the upgrade was rejected.
|
||||
pub async fn run(args: Args) -> Result<i32> {
|
||||
let request = match &args.command {
|
||||
Command::Enable { request } => {
|
||||
let request: UpgradeRequest =
|
||||
serde_json::from_slice(&read_bounded(request, MAX_BYTES)?)?;
|
||||
request.validate()?;
|
||||
Some(request)
|
||||
}
|
||||
Command::Status { .. } => None,
|
||||
};
|
||||
let operation_id = match &args.command {
|
||||
Command::Enable { .. } => request.as_ref().context("missing request")?.operation_id,
|
||||
Command::Status { operation_id } => *operation_id,
|
||||
};
|
||||
let url = endpoint(&args.origin, request.is_none().then_some(operation_id))?;
|
||||
// Do not include the secret or its parse error in diagnostics.
|
||||
let secret = read_bounded(&args.key_file, 1024)?;
|
||||
let keys = std::str::from_utf8(&secret)
|
||||
.ok()
|
||||
.and_then(|s| Keys::parse(s.trim()).ok())
|
||||
.context("invalid operator key file")?;
|
||||
let body = request.as_ref().map(serde_json::to_vec).transpose()?;
|
||||
let method = if body.is_some() {
|
||||
Method::POST
|
||||
} else {
|
||||
Method::GET
|
||||
};
|
||||
let auth = authorization(&keys, &method, &url, body.as_deref())?;
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.retry(reqwest::retry::never())
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()?;
|
||||
let mut call = client.request(method, url).header("Authorization", auth);
|
||||
if let Some(body) = body {
|
||||
call = call.header("Content-Type", "application/json").body(body);
|
||||
}
|
||||
let mut response = call
|
||||
.send()
|
||||
.await
|
||||
.context("unconfirmed: request failed; recover the same operation_id")?;
|
||||
let status = response.status();
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.context("unconfirmed: response interrupted; recover the same operation_id")?
|
||||
{
|
||||
ensure!(
|
||||
bytes.len() + chunk.len() <= MAX_BYTES,
|
||||
"unconfirmed: response exceeds size limit"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
if !status.is_success() {
|
||||
// Preserve known machine-readable codes, without echoing arbitrary response bodies.
|
||||
let error = serde_json::from_slice::<Value>(&bytes).ok();
|
||||
let code = error.as_ref().and_then(|value| value["code"].as_str());
|
||||
let detail = match code {
|
||||
Some(
|
||||
code @ ("invalid_request"
|
||||
| "community_not_found"
|
||||
| "operation_not_found"
|
||||
| "idempotency_conflict"
|
||||
| "host_conflict"
|
||||
| "owner_conflict"
|
||||
| "lifecycle_conflict"
|
||||
| "issuer_conflict"
|
||||
| "upgrades_disabled"
|
||||
| "authorization_unavailable"),
|
||||
) => code,
|
||||
_ if error.is_none() => "non-JSON error response",
|
||||
_ => "unrecognized error code",
|
||||
};
|
||||
anyhow::bail!("operator API returned HTTP {} ({detail}); recover the same operation_id after an uncertain enable", status.as_u16());
|
||||
}
|
||||
// Print only structured relay output, never transport headers or signing material.
|
||||
let receipt: Value = serde_json::from_slice(&bytes).with_context(|| {
|
||||
format!(
|
||||
"unconfirmed: invalid JSON response (HTTP {})",
|
||||
status.as_u16()
|
||||
)
|
||||
})?;
|
||||
let code = receipt_exit_code(
|
||||
&receipt,
|
||||
operation_id,
|
||||
&keys.public_key().to_hex(),
|
||||
request.as_ref(),
|
||||
)?;
|
||||
println!("{}", serde_json::to_string(&receipt)?);
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{body::Bytes, http::HeaderMap, routing::any, Json, Router};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
fn tuple() -> UpgradeRequest {
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
UpgradeRequest {
|
||||
operation_id: Uuid::new_v4(),
|
||||
community_id: Uuid::new_v4(),
|
||||
expected_host: "test.communities.example.org".into(),
|
||||
actor_pubkey: owner.clone(),
|
||||
expected_owner_pubkey: owner,
|
||||
expected_issuer: "https://issuer.example.org".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_noncanonical_inputs_before_reading_key_or_sending_post() {
|
||||
for (field, value) in [
|
||||
("expected_host", "TEST.communities.example.org"),
|
||||
("expected_host", "test.communities.example.org."),
|
||||
("expected_host", "test.communities.example.org:443"),
|
||||
("expected_host", "https://test.communities.example.org"),
|
||||
("actor_pubkey", &"A".repeat(64)),
|
||||
("expected_owner_pubkey", &"A".repeat(64)),
|
||||
("actor_pubkey", &"a".repeat(63)),
|
||||
("expected_owner_pubkey", &"g".repeat(64)),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("request.json");
|
||||
let mut input = serde_json::to_value(tuple()).unwrap();
|
||||
input[field] = value.into();
|
||||
std::fs::write(&path, serde_json::to_vec(&input).unwrap()).unwrap();
|
||||
let error = run(Args {
|
||||
origin: "http://127.0.0.1:1".into(),
|
||||
key_file: dir.path().join("absent-key"),
|
||||
command: Command::Enable { request: path },
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
let message = format!("{error:#}");
|
||||
assert!(
|
||||
message.contains("host") || message.contains("public keys"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(
|
||||
!message.contains("absent-key"),
|
||||
"key read preceded validation: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_responses_preserve_status_and_known_codes_without_echoing_bodies() {
|
||||
use axum::http::StatusCode;
|
||||
|
||||
for (status, body, expected) in [
|
||||
(
|
||||
409,
|
||||
r#"{"code":"owner_conflict","error":"private detail"}"#,
|
||||
"owner_conflict",
|
||||
),
|
||||
(409, r#"{"code":"issuer_conflict"}"#, "issuer_conflict"),
|
||||
(409, r#"{"code":"host_conflict"}"#, "host_conflict"),
|
||||
(
|
||||
409,
|
||||
r#"{"code":"idempotency_conflict"}"#,
|
||||
"idempotency_conflict",
|
||||
),
|
||||
(503, r#"{"code":"upgrades_disabled"}"#, "upgrades_disabled"),
|
||||
(
|
||||
502,
|
||||
"<html>private detail</html>",
|
||||
"non-JSON error response",
|
||||
),
|
||||
(
|
||||
409,
|
||||
r#"{"code":"private detail"}"#,
|
||||
"unrecognized error code",
|
||||
),
|
||||
] {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let origin = format!("http://{}", listener.local_addr().unwrap());
|
||||
let app = Router::new().route(
|
||||
PATH,
|
||||
any(move || async move { (StatusCode::from_u16(status).unwrap(), body) }),
|
||||
);
|
||||
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key_file = dir.path().join("key");
|
||||
let request_file = dir.path().join("request.json");
|
||||
let keys = Keys::generate();
|
||||
std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap();
|
||||
std::fs::write(&request_file, serde_json::to_vec(&tuple()).unwrap()).unwrap();
|
||||
let error = run(Args {
|
||||
origin,
|
||||
key_file,
|
||||
command: Command::Enable {
|
||||
request: request_file,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
let message = format!("{error:#}");
|
||||
assert!(message.contains(&format!("HTTP {status}")), "{message}");
|
||||
assert!(message.contains(expected), "{message}");
|
||||
assert!(!message.contains("private detail"), "{message}");
|
||||
assert!(!message.contains(&keys.secret_key().display_secret().to_string()));
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn receipt(request: &UpgradeRequest, keys: &Keys, state: &str) -> Value {
|
||||
let mut value = serde_json::to_value(request).unwrap();
|
||||
value["operator_pubkey"] = keys.public_key().to_hex().into();
|
||||
value["policy_generation"] = 1.into();
|
||||
value["requires_nip_fi"] = true.into();
|
||||
value["state"] = state.into();
|
||||
value["pending_reasons"] = serde_json::json!([]);
|
||||
value
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_signs_exact_post_body_and_get_query_and_preserves_operation() {
|
||||
let keys = Keys::generate();
|
||||
let tuple = tuple();
|
||||
let response = receipt(&tuple, &keys, "pending");
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let origin = format!("http://{}", listener.local_addr().unwrap());
|
||||
let (sender, mut requests) = mpsc::channel(4);
|
||||
let app = Router::new().route(
|
||||
PATH,
|
||||
any(
|
||||
move |method: Method, uri: axum::http::Uri, headers: HeaderMap, body: Bytes| {
|
||||
let response = response.clone();
|
||||
let sender = sender.clone();
|
||||
async move {
|
||||
sender.send((method, uri, headers, body)).await.unwrap();
|
||||
Json(response)
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let key_file = dir.path().join("key");
|
||||
let request_file = dir.path().join("request.json");
|
||||
std::fs::write(&key_file, keys.secret_key().display_secret().to_string()).unwrap();
|
||||
std::fs::write(&request_file, serde_json::to_vec(&tuple).unwrap()).unwrap();
|
||||
|
||||
for command in [
|
||||
Command::Enable {
|
||||
request: request_file,
|
||||
},
|
||||
Command::Status {
|
||||
operation_id: tuple.operation_id,
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
run(Args {
|
||||
origin: origin.clone(),
|
||||
key_file: key_file.clone(),
|
||||
command
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
10
|
||||
);
|
||||
let (method, uri, headers, body) = requests.recv().await.unwrap();
|
||||
let encoded = headers["Authorization"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.strip_prefix("Nostr ")
|
||||
.unwrap();
|
||||
let event = String::from_utf8(STANDARD.decode(encoded).unwrap()).unwrap();
|
||||
let url = format!("{origin}{uri}");
|
||||
let pubkey = buzz_auth::verify_nip98_event(
|
||||
&event,
|
||||
&url,
|
||||
method.as_str(),
|
||||
(method == Method::POST).then_some(body.as_ref()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pubkey, keys.public_key());
|
||||
if method == Method::POST {
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&body).unwrap(),
|
||||
serde_json::to_value(&tuple).unwrap()
|
||||
);
|
||||
assert!(
|
||||
buzz_auth::verify_nip98_event(&event, &url, "POST", Some(b"changed")).is_err()
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
uri.query(),
|
||||
Some(format!("operation_id={}", tuple.operation_id).as_str())
|
||||
);
|
||||
assert!(buzz_auth::verify_nip98_event(
|
||||
&event,
|
||||
&format!("{origin}{PATH}"),
|
||||
"GET",
|
||||
None
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
assert!(requests.try_recv().is_err());
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_matching_required_receipts_can_report_success() {
|
||||
let keys = Keys::generate();
|
||||
let request = tuple();
|
||||
let operator = keys.public_key().to_hex();
|
||||
for (state, code) in [("active", 0), ("pending", 10), ("unavailable", 12)] {
|
||||
assert_eq!(
|
||||
receipt_exit_code(
|
||||
&receipt(&request, &keys, state),
|
||||
request.operation_id,
|
||||
&operator,
|
||||
Some(&request)
|
||||
)
|
||||
.unwrap(),
|
||||
code
|
||||
);
|
||||
}
|
||||
for (field, bad) in [
|
||||
("operation_id", Value::from(Uuid::new_v4().to_string())),
|
||||
(
|
||||
"operator_pubkey",
|
||||
Value::from(Keys::generate().public_key().to_hex()),
|
||||
),
|
||||
("community_id", Value::from(Uuid::new_v4().to_string())),
|
||||
("requires_nip_fi", Value::from(false)),
|
||||
("policy_generation", Value::from(0)),
|
||||
("state", Value::from("unknown")),
|
||||
("pending_reasons", serde_json::json!(["not_drained"])),
|
||||
] {
|
||||
let mut value = receipt(&request, &keys, "active");
|
||||
value[field] = bad;
|
||||
assert!(
|
||||
receipt_exit_code(&value, request.operation_id, &operator, Some(&request)).is_err(),
|
||||
"accepted invalid {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_cannot_select_a_different_path_or_embed_credentials() {
|
||||
for origin in [
|
||||
"https://user:password@example.org",
|
||||
"https://example.org/wrong",
|
||||
"https://example.org/?q=x",
|
||||
"https://example.org/#fragment",
|
||||
"file:///tmp/key",
|
||||
] {
|
||||
assert!(endpoint(origin, None).is_err(), "accepted {origin}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ A deployment that provisions communities under one host suffix, for example
|
||||
each community in `community_nip_fi_issuers`. The relay writes these rows when
|
||||
it creates a community; `buzz-admin nip-fi` backfills and edits them.
|
||||
|
||||
To make an existing community require federated sign-in, use the separate
|
||||
[community upgrade command and manual test](operator-nip-fi-upgrade.md).
|
||||
An issuer binding alone does not change that community's required policy.
|
||||
|
||||
A host may hold a binding only when it is exactly one DNS label under the
|
||||
suffix, has no port, and is not *reserved*. A host is reserved when
|
||||
`https://<host>` is a `command_audiences` entry of any issuer in
|
||||
|
||||
213
docs/operator-nip-fi-upgrade.md
Normal file
213
docs/operator-nip-fi-upgrade.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Require NIP-FI on an existing community
|
||||
|
||||
`buzz-admin nip-fi-upgrade` makes one signed HTTP call to the existing relay
|
||||
operator API. It needs no database access, application startup, sponsorship or
|
||||
billing workflow. `enable` accepts the saved upgrade tuple; `status` reads its
|
||||
durable receipt. Both require an authorized deployment operator key.
|
||||
The [upgrade API contract](community-auth-upgrades.md) defines the receipt and
|
||||
policy semantics used below.
|
||||
|
||||
This command is a trusted operator surface. The operator must independently
|
||||
authenticate the community owner and verify the owner's registered public key
|
||||
before putting it in `actor_pubkey`. A caller-supplied public key is not proof
|
||||
of the owner's identity. The relay still checks current ownership, community
|
||||
identity, issuer trust and the upgrade admission gate. This command does not
|
||||
replace an application's owner authentication or admission policy.
|
||||
|
||||
## Before the test
|
||||
|
||||
Use a disposable staging community whose policy is not yet required. Verify its
|
||||
UUID, canonical lowercase host, current owner and active issuer binding against
|
||||
authoritative records. Use the current owner's key for both public-key fields.
|
||||
The issuer must match the relay's provisioning configuration and active trust.
|
||||
|
||||
Verify `BUZZ_NIP_FI_MODE=shadow` on **every serving replica** before connecting
|
||||
the test client. Shadow observes optional communities and enforces their stored
|
||||
required policy after an upgrade. `off` ignores stored requirements; `enforce`
|
||||
already requires sign-in everywhere. Either mode invalidates this transition
|
||||
test. Keep shadow mode unchanged throughout the test and record it in evidence.
|
||||
|
||||
All serving relay replicas must support persisted required policy and closing
|
||||
existing sessions after an upgrade. Apply their required migrations and finish
|
||||
the rollout before the test. The deployment must already have authorized
|
||||
`BUZZ_NIP_FI_UPGRADES_ENABLED=true`; this command does not change that gate.
|
||||
Only the deployment's authorized operator may enable that gate, after verifying
|
||||
the fleet prerequisites in the API contract. Record that operator's approval.
|
||||
Use a client that supports required-policy discovery and federated sign-in.
|
||||
|
||||
Identify the configured `RELAY_OPERATOR_API_ORIGIN` and a key in
|
||||
`RELAY_OPERATOR_PUBKEYS`. The origin is the operator API origin, not the target
|
||||
community URL. Use HTTPS outside a trusted internal mesh. Provision the key as
|
||||
a protected file/Secret through the deployment's existing secret process; never
|
||||
paste it into arguments, logs or a committed manifest. Do not borrow a client
|
||||
or managed agent key.
|
||||
|
||||
## Save the operation, then leave the client connected
|
||||
|
||||
Save `request.json` with one newly generated operation UUID and the verified
|
||||
values. Keep this file unchanged for retries. The command refuses noncanonical
|
||||
hosts or public keys before sending a POST. These are example values only:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation_id": "deecfc7a-9ec2-4ec5-b15b-9e567ad59740",
|
||||
"community_id": "00fb4822-7930-40fc-9eed-315926f83ca7",
|
||||
"expected_host": "test.communities.example.org",
|
||||
"actor_pubkey": "<verified owner public key: 64 lowercase hex characters>",
|
||||
"expected_owner_pubkey": "<same verified owner public key>",
|
||||
"expected_issuer": "https://issuer.example.org"
|
||||
}
|
||||
```
|
||||
|
||||
Open the test community in an existing client and confirm it can read and send
|
||||
a message without federated sign-in. Keep that session connected. Record the
|
||||
client version, relay image digests and time, plus the community's `/info`
|
||||
response showing `requires_nip_fi: false`. Do not sign out, restart the client,
|
||||
or change relay configuration during the acceptance test.
|
||||
|
||||
## Run one pod
|
||||
|
||||
Use a relay image containing this command, pinned to its verified digest. The
|
||||
normal relay image includes `/usr/local/bin/buzz-admin`. Check the command's
|
||||
`--help` before the test if using a previously built image.
|
||||
|
||||
Select the staging context and namespace explicitly. Create the input ConfigMap
|
||||
from the saved request, and use an existing Secret with a `key` entry:
|
||||
|
||||
```sh
|
||||
kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" create configmap nip-fi-upgrade-input \
|
||||
--from-file=request.json
|
||||
```
|
||||
|
||||
Replace the image, origin and Secret name below with reviewed deployment
|
||||
values. Save as `upgrade-pod.yaml`. This is a single Pod, with no Job controller,
|
||||
retry policy or application/service labels. It mounts only the input and key.
|
||||
Use the deployment's approved network/mesh settings if its operator API needs
|
||||
them; confirm the pod can reach that API without adding it to serving workloads.
|
||||
If a mesh sidecar is injected, use the platform's approved one-shot completion
|
||||
mechanism (for example, native sidecars). An ordinary sidecar can keep this Pod
|
||||
running after the command exits; retain the upgrade container's exit status and
|
||||
logs, and arrange sidecar shutdown without weakening required network policy.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: nip-fi-upgrade-enable
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
activeDeadlineSeconds: 60
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: upgrade
|
||||
image: ghcr.io/block/buzz@sha256:<verified-digest>
|
||||
command: [/usr/local/bin/buzz-admin]
|
||||
args:
|
||||
- nip-fi-upgrade
|
||||
- --origin
|
||||
- https://operator.example.org
|
||||
- --key-file
|
||||
- /operator/key
|
||||
- enable
|
||||
- --request
|
||||
- /input/request.json
|
||||
volumeMounts:
|
||||
- {name: operator, mountPath: /operator, readOnly: true}
|
||||
- {name: input, mountPath: /input, readOnly: true}
|
||||
volumes:
|
||||
- name: operator
|
||||
secret:
|
||||
secretName: reviewed-upgrade-operator
|
||||
defaultMode: 0440
|
||||
- name: input
|
||||
configMap:
|
||||
name: nip-fi-upgrade-input
|
||||
```
|
||||
|
||||
```sh
|
||||
kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" create -f upgrade-pod.yaml
|
||||
kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" logs -f pod/nip-fi-upgrade-enable -c upgrade
|
||||
kubectl --context "$STAGING_CONTEXT" -n "$NAMESPACE" get pod nip-fi-upgrade-enable -o json
|
||||
```
|
||||
|
||||
The last command records the pod UID, observed image ID and container exit code.
|
||||
Do not use Pod phase alone: the expected accepted outcome in this relay version
|
||||
is exit **10**, which Kubernetes labels Failed. This is acceptance, not a command
|
||||
failure or a certified fleet-wide connection drain.
|
||||
|
||||
| Exit | Meaning |
|
||||
| --- | --- |
|
||||
| 10 | Expected success: valid matching receipt, `requires_nip_fi: true`, state `pending` |
|
||||
| 12 | Valid acceptance receipt, target/trust currently `unavailable` |
|
||||
| 5 | Request error or unconfirmed result; acceptance may already have happened |
|
||||
|
||||
This relay version never reports `active`. The command reserves exit 0 for a
|
||||
future valid `active` receipt with no pending reasons; do not wait for it here.
|
||||
`connection_closure_unverified` is expected even after successful socket sweeps:
|
||||
the receipt does not certify fleet-wide closure. Continue with the client test.
|
||||
Retain and investigate other/unknown pending reasons; they remain unverified.
|
||||
|
||||
Each HTTP call has a 30-second timeout, no automatic retry and no redirects.
|
||||
The command verifies the returned operation, operator, required policy and
|
||||
positive generation. For enable it also verifies every saved tuple field.
|
||||
|
||||
## Confirm policy and recover the same operation
|
||||
|
||||
Create another pod from the same manifest with a new pod name and replace the
|
||||
`enable --request /input/request.json` arguments with:
|
||||
|
||||
```yaml
|
||||
- status
|
||||
- --operation-id
|
||||
- deecfc7a-9ec2-4ec5-b15b-9e567ad59740
|
||||
```
|
||||
|
||||
Use the actual saved operation UUID, same API origin and same operator key.
|
||||
Compare the receipt's complete tuple and generation with the saved evidence.
|
||||
A successful receipt must report `requires_nip_fi: true`. Also read the target
|
||||
community's `/info` and confirm it advertises required policy.
|
||||
|
||||
After timeout, response loss or pod termination, status may recover an accepted
|
||||
receipt. If it cannot confirm acceptance, repeat enable with the **same saved
|
||||
request** and a new pod name. Never create a new operation UUID to escape an
|
||||
uncertain result. `pending` and `unavailable` do not undo required policy; retain
|
||||
their state and reasons. A pending receipt with `connection_closure_unverified`
|
||||
does not prevent the manual client test from passing on observed evidence.
|
||||
An unavailable target or unknown obligations require investigation.
|
||||
Turning off admission, deleting the pod or rolling back the command cannot undo
|
||||
an accepted policy change.
|
||||
|
||||
## Observe the existing client
|
||||
|
||||
Record `BUZZ_COMMUNITY_REVALIDATE_INTERVAL_SECS` on every serving replica (default
|
||||
30 seconds, clamped to 1–300). Before the test, choose and record a finite client
|
||||
reconnect/sign-in budget from that client's configured reconnect behavior. Wait
|
||||
at least the largest replica interval plus that client budget after acceptance.
|
||||
Record the resulting observation deadline. If sign-in has not appeared by then,
|
||||
record the test as failed or blocked with logs; do not wait indefinitely.
|
||||
|
||||
1. After the upgrade, verify the already-connected client demands federated
|
||||
sign-in before continuing to read or send in the community. Let the client
|
||||
handle the disconnect/reconnect. Do not manually sign out or restart it.
|
||||
2. Complete sign-in with the test user's allowed issuer, then verify reading and
|
||||
sending work again in that same community.
|
||||
3. Record a failure if access continues without sign-in, sign-in never appears,
|
||||
or access does not resume. A changed `/info` response alone is not a pass.
|
||||
|
||||
Record the exact invocation/manifest, saved request, operator public key,
|
||||
community UUID/host, pod UID/observed image ID, all serving relay versions and
|
||||
shadow modes, revalidator intervals, client reconnect budget/deadline,
|
||||
client version, pre/post `/info`, receipt/generation/state, timestamps and observed
|
||||
client behavior. Keep secret key bytes and authorization headers out of evidence.
|
||||
Mark unexecuted steps as **not run**, and keep policy confirmation separate from
|
||||
the client result. Export logs and pod metadata before deleting the temporary
|
||||
pods and input ConfigMap. Use a fresh staging community for another first-upgrade
|
||||
test; do not clear persisted policy to reset this one.
|
||||
For a mistaken acceptance, stop the caller and follow the API contract's
|
||||
containment procedure. Archive or isolate the affected community through the
|
||||
deployment's authorized operator process; there is no policy rollback command.
|
||||
Reference in New Issue
Block a user