summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-24 19:25:54 +0200
committertv <tv@krebsco.de>2026-10-24 19:25:54 +0200
commit815ec2e9a641ad5ac5c2f886291e63c89966bd6c (patch)
tree5840ab9e9adc8e5ab5259ef0da7949880cde5f53 /src
parent09f2dfd083c4fcdce4c5ca7376353906eb025c42 (diff)
only protocol-writers may modify _continuum
Diffstat (limited to 'src')
-rw-r--r--src/api/auth.rs25
-rw-r--r--src/api/mod.rs27
-rw-r--r--src/api/store.rs12
-rw-r--r--src/config/mod.rs9
-rw-r--r--src/main.rs1
-rw-r--r--src/state.rs3
6 files changed, 63 insertions, 14 deletions
diff --git a/src/api/auth.rs b/src/api/auth.rs
index 70b1af5..de4220c 100644
--- a/src/api/auth.rs
+++ b/src/api/auth.rs
@@ -72,14 +72,18 @@ fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> {
Some((user.to_owned(), pass.to_owned()))
}
-async fn authenticate(state: &AuthState, headers: &HeaderMap) -> bool {
- let Some((user, pass)) = parse_basic(headers) else {
- return false;
- };
- let Some(hash) = state.users.get(&user).cloned() else {
- return false;
- };
+/// The user a request authenticated as (a request extension, set by [`basic_auth`]).
+#[derive(Clone, Debug)]
+pub(crate) struct AuthenticatedUser(pub(crate) String);
+/// The user, if the credentials are valid.
+async fn authenticate(state: &AuthState, headers: &HeaderMap) -> Option<String> {
+ let (user, pass) = parse_basic(headers)?;
+ let hash = state.users.get(&user).cloned()?;
+ verify(state, &user, pass, hash).await.then_some(user)
+}
+
+async fn verify(state: &AuthState, user: &str, pass: String, hash: String) -> bool {
let key = *blake3::hash(format!("{user}\0{pass}\0{hash}").as_bytes()).as_bytes();
// Fast path. The shard guard lives only until the end of this `match`;
@@ -124,10 +128,10 @@ async fn authenticate(state: &AuthState, headers: &HeaderMap) -> bool {
pub async fn basic_auth(
State(state): State<AuthState>,
headers: HeaderMap,
- request: Request<Body>,
+ mut request: Request<Body>,
next: axum::middleware::Next,
) -> Response {
- if !authenticate(&state, &headers).await {
+ let Some(user) = authenticate(&state, &headers).await else {
sleep(Duration::from_millis(1500)).await;
return (
StatusCode::UNAUTHORIZED,
@@ -135,7 +139,8 @@ pub async fn basic_auth(
"Unauthorized",
)
.into_response();
- }
+ };
+ request.extensions_mut().insert(AuthenticatedUser(user));
next.run(request).await
}
diff --git a/src/api/mod.rs b/src/api/mod.rs
index ce1f6ae..1fba48b 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -24,7 +24,7 @@ use tracing::{error, warn};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
-use crate::api::auth::{AuthState, basic_auth};
+use crate::api::auth::{AuthState, AuthenticatedUser, basic_auth};
use crate::document::store::StoreError;
use crate::state::AppState;
@@ -104,6 +104,31 @@ fn parse_key_path(key: &str) -> Result<Vec<String>, StatusCode> {
Ok(segments)
}
+/// A path for a write. Protocol data (`_continuum`: remake records, members, forget
+/// records, epoch metadata) is what every node acts on, so a write there acts on the
+/// whole swarm: only users listed in `--protocol-writers` may, and each such write is
+/// logged. Epoch metadata (and `_continuum` as a whole, which contains it) is never
+/// writable: the protocol reads it at the snapshot change, so an edit only misleads
+/// whoever reads it later. `403` otherwise.
+fn parse_write_path(
+ state: &AppState,
+ user: &AuthenticatedUser,
+ key: &str,
+ method: &str,
+) -> Result<Vec<String>, StatusCode> {
+ let path = parse_key_path(key)?;
+ if path.first().map(String::as_str) != Some(crate::remake::snapshot::META_ROOT) {
+ return Ok(path);
+ }
+ let epochs = path.len() < 2 || path[1] == crate::remake::snapshot::EPOCHS;
+ if epochs || !state.protocol_writers.contains(&user.0) {
+ warn!(user = %user.0, method, path = %key, "Refused a write to protocol data");
+ return Err(StatusCode::FORBIDDEN);
+ }
+ warn!(user = %user.0, method, path = %key, "Protocol data written through the store API; this acts on the whole swarm");
+ Ok(path)
+}
+
fn response_for_value(
value: &JsonValue,
accept: Option<&HeaderValue>,
diff --git a/src/api/store.rs b/src/api/store.rs
index d6ff306..aa9465c 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -4,6 +4,7 @@ use std::io::Error as IoError;
use automerge::ChangeHash;
use axum::{
+ Extension,
body::{Body, Bytes},
extract::{Path, Query, State},
http::{
@@ -17,7 +18,8 @@ use serde_json::Value as JsonValue;
use tokio::sync::broadcast;
use tracing::{debug, error, instrument};
-use super::{parse_key_path, response_for_value, store_status};
+use super::auth::AuthenticatedUser;
+use super::{parse_key_path, parse_write_path, response_for_value, store_status};
use crate::document::store::{self, StoreError};
use crate::state::{AppState, ReadSource};
use crate::swarm::sync::paths_related;
@@ -194,6 +196,7 @@ fn mark_source(
responses(
(status = 200, description = "Value written successfully"),
(status = 400, description = "Invalid path, malformed JSON, invalid UTF-8, or unsupported JSON value"),
+ (status = 403, description = "Protocol data (_continuum) and the user isn't a protocol writer, or epoch metadata"),
(status = 409, description = "Path conflicts with an existing non-map value"),
(status = 415, description = "Unsupported Content-Type"),
(status = 500, description = "Internal error or synchronization notification failure"),
@@ -202,6 +205,7 @@ fn mark_source(
)]
pub(crate) async fn put_store_entry(
State(state): State<AppState>,
+ Extension(user): Extension<AuthenticatedUser>,
Path(key): Path<String>,
headers: HeaderMap,
body: Bytes,
@@ -210,7 +214,7 @@ pub(crate) async fn put_store_entry(
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
- let path = parse_key_path(&key)?;
+ let path = parse_write_path(&state, &user, &key, "PUT")?;
let content_type = headers
.get(CONTENT_TYPE)
@@ -251,6 +255,7 @@ pub(crate) async fn put_store_entry(
responses(
(status = 204, description = "Value deleted successfully"),
(status = 400, description = "Invalid store path"),
+ (status = 403, description = "Protocol data (_continuum) and the user isn't a protocol writer, or epoch metadata"),
(status = 404, description = "Value does not exist"),
(status = 500, description = "Deletion or synchronization notification failed"),
(status = 503, description = "Node is not ready")
@@ -259,10 +264,11 @@ pub(crate) async fn put_store_entry(
#[instrument(skip(state), fields(path, prune = query.prune))]
pub(crate) async fn delete_store_entry(
State(state): State<AppState>,
+ Extension(user): Extension<AuthenticatedUser>,
Path(key): Path<String>,
Query(query): Query<DeleteQuery>,
) -> Result<StatusCode, StatusCode> {
- let path = parse_key_path(&key)?;
+ let path = parse_write_path(&state, &user, &key, "DELETE")?;
tracing::Span::current().record("path", path.join("/").as_str());
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 4914fb6..ef28542 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -82,6 +82,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[
"Users of the HTTP API (htpasswd file with Argon2 hashes). Required.",
),
option(
+ "protocol-writers",
+ Kind::List,
+ "USER,[USER...]",
+ None,
+ "HTTP API users (from htpasswd) who may write protocol data under /store/_continuum (remake records, members, forget records), e.g. to repair a stuck remake. Such writes act on the whole swarm and are logged. Epoch metadata is never writable.",
+ ),
+ option(
"debug-api",
Kind::Flag,
"BOOL",
@@ -201,6 +208,8 @@ pub(crate) struct Settings {
pub(crate) bind_addr: SocketAddr,
pub(crate) htpasswd: String,
#[serde(default)]
+ pub(crate) protocol_writers: Vec<String>,
+ #[serde(default)]
pub(crate) debug_api: bool,
#[serde(default)]
pub(crate) seeds: Vec<String>,
diff --git a/src/main.rs b/src/main.rs
index 228fa54..98abf7a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -140,6 +140,7 @@ async fn main() -> Result<()> {
alias: args.alias.clone(),
electorate_window: Duration::from_secs(args.electorate_window_days * 24 * 3600),
blobs: Arc::new(swarm::blob::Blobs::new(args.blob_chunk_kib * 1024)),
+ protocol_writers: Arc::new(args.protocol_writers.iter().cloned().collect()),
config: Arc::new(config.report()),
neighbours: Default::default(),
pulling: Default::default(),
diff --git a/src/state.rs b/src/state.rs
index 1ea5aae..c51446f 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -599,6 +599,9 @@ pub(crate) struct AppState {
pub(crate) electorate_window: Duration,
/// Chunked transfers served and fetched (`swarm::blob`).
pub(crate) blobs: Arc<crate::swarm::blob::Blobs>,
+ /// HTTP API users who may write protocol data through the store API
+ /// (`--protocol-writers`).
+ pub(crate) protocol_writers: Arc<std::collections::HashSet<String>>,
/// The effective configuration with sources, imported values left out
/// (`config::Loaded::report`), for `GET /admin/config`.
pub(crate) config: Arc<serde_json::Value>,