Skip to main content

max / makenotwork

sando: seal self-update provenance + bearer-gate reads (Security A+, ultra-fuzz Run 2 deep Phase 1) Drive the Security axis from A- to A+: - self-update provenance (SERIOUS-1): the root updater now refuses any sha that is not an ancestor of origin/<deploy-branch>, so the bearer token can no longer install an arbitrary remote commit as root. A non-deploy-branch sha exits 4 before the build/install lines. Signed-tag verification tracked as a follow-up. - caller identity: the bearer middleware logs the peer address on every authenticated POST, so a prod ship/rollback is attributable; main serves with ConnectInfo to surface it. - reads bearer-gated (M1): /state, /runs, /logs, /events now require the token too (they expose versions, SHAs, gate logs, the event stream). /metrics stays the one open endpoint. The TUI presents the token on its poll + the /events WS upgrade; loopback/dev stays tokenless. - gate-log allowlist (D12): the /logs gate segment is parsed to GateKind, so only the five known <kind>.log files are reachable (no *.log probing). Tests: read-route auth (401 without / 200 with token; open when unset), unknown-gate-kind 404. clippy -D warnings clean; native fw13. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 03:21 UTC
Commit: 907b1593cb8af6f1cc0f1d4c815832ddf92add9f
Parent: dff1bfc
4 files changed, +150 insertions, -29 deletions
@@ -76,7 +76,13 @@ async fn main() -> Result<()> {
76 76 let app = routes::router(app_state);
77 77 tracing::info!(%addr, "sando daemon listening");
78 78 let listener = tokio::net::TcpListener::bind(addr).await?;
79 - axum::serve(listener, app).await?;
79 + // `into_make_service_with_connect_info` surfaces the peer `SocketAddr` in
80 + // request extensions so the bearer middleware can log who issued a mutation.
81 + axum::serve(
82 + listener,
83 + app.into_make_service_with_connect_info::<SocketAddr>(),
84 + )
85 + .await?;
80 86 Ok(())
81 87 }
82 88
@@ -11,30 +11,33 @@ pub fn router(state: AppState) -> Router {
11 11 let prom = state.prom.clone();
12 12 let token = state.api_token.clone();
13 13
14 - // Deploy mutators require a bearer token (when one is configured). Reads
15 - // (/state, /logs, /events) stay open so the TUI's polling + event stream
16 - // need no credential — only the prod-deploy authority is gated (CF2).
17 - let mutating = Router::new()
14 + // Every route requires the bearer token (when one is configured): the
15 + // mutators carry prod-deploy authority (CF2), and the reads expose prod
16 + // state — deployed versions, build SHAs, full gate logs, the live event
17 + // stream — that a tailnet peer should not get unauthenticated. `/metrics`
18 + // is the one deliberate opt-out (Prometheus convention, no secrets), added
19 + // outside the layered group below. The bearer-success path logs the caller
20 + // identity for mutating (POST) requests so a prod ship is attributable.
21 + let protected = Router::new()
22 + // mutators — prod-deploy authority
18 23 .route("/promote/{tier}", post(promote))
19 24 .route("/rollback/{tier}", post(rollback))
20 25 .route("/rebuild", post(rebuild))
21 26 .route("/self-update", post(self_update))
22 27 .route("/confirm/{tier}", post(confirm))
23 28 .route("/backup/fetch", post(backup_fetch))
24 - .route_layer(axum::middleware::from_fn(move |req, next| {
25 - require_bearer(token.clone(), req, next)
26 - }));
27 -
28 - let open = Router::new()
29 + // reads — prod state, now also gated
29 30 .route("/state", get(get_state))
30 31 .route("/runs/{id}", get(get_run))
31 32 .route("/runs/{id}/wait", get(get_run_wait))
32 33 .route("/logs/{version}/{gate}", get(get_gate_log))
33 - .route("/events", get(events_ws));
34 + .route("/events", get(events_ws))
35 + .route_layer(axum::middleware::from_fn(move |req, next| {
36 + require_bearer(token.clone(), req, next)
37 + }));
34 38
35 39 Router::new()
36 - .merge(mutating)
37 - .merge(open)
40 + .merge(protected)
38 41 .with_state(state)
39 42 .route("/metrics", get(crate::metrics::render).with_state(prom))
40 43 }
@@ -51,6 +54,7 @@ async fn require_bearer(
51 54 return next.run(req).await;
52 55 };
53 56 let path = req.uri().path().to_string();
57 + let method = req.method().clone();
54 58 let ok = req
55 59 .headers()
56 60 .get(axum::http::header::AUTHORIZATION)
@@ -58,9 +62,23 @@ async fn require_bearer(
58 62 .and_then(|h| h.strip_prefix("Bearer "))
59 63 .is_some_and(|t| ct_eq(t, expected));
60 64 if ok {
65 + // Attribute mutations (POST) to a caller. Reads are GET and poll every
66 + // few seconds, so logging them would drown the journal; the audit value
67 + // is in *who shipped/rolled back prod*, which is always a POST. The peer
68 + // address is the closest identity we have without per-operator tokens
69 + // (`tailscale whois` is a future refinement); a shared token at least
70 + // gets a source host into the trail.
71 + if method == axum::http::Method::POST {
72 + let peer = req
73 + .extensions()
74 + .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
75 + .map(|ci| ci.0.to_string())
76 + .unwrap_or_else(|| "unknown".into());
77 + tracing::info!(path = %path, peer = %peer, "authenticated deploy mutation");
78 + }
61 79 next.run(req).await
62 80 } else {
63 - tracing::warn!(path = %path, "rejected unauthenticated request to a deploy mutator");
81 + tracing::warn!(path = %path, "rejected unauthenticated request");
64 82 (axum::http::StatusCode::UNAUTHORIZED, "missing or invalid bearer token\n").into_response()
65 83 }
66 84 }
@@ -1012,7 +1030,7 @@ async fn get_gate_log(
1012 1030 State(s): State<AppState>,
1013 1031 Path((version, gate)): Path<(String, String)>,
1014 1032 ) -> Result<axum::response::Response> {
1015 - // Guard against `..` / absolute paths — both segments must be a single
1033 + // Guard against `..` / absolute paths — the version segment must be a single
1016 1034 // safe component. Without this, `GET /logs/..%2Fetc/passwd` would escape
1017 1035 // logs_root.
1018 1036 fn safe(seg: &str) -> bool {
@@ -1022,10 +1040,17 @@ async fn get_gate_log(
1022 1040 && seg != "."
1023 1041 && seg != ".."
1024 1042 }
1025 - if !safe(&version) || !safe(&gate) {
1043 + if !safe(&version) {
1026 1044 return Err(crate::error::Error::NotFound);
1027 1045 }
1028 - let path = s.cfg.logs_root.join(&version).join(format!("{gate}.log"));
1046 + // The gate segment is an allowlisted kind, not a free-form filename: parse it
1047 + // to `GateKind` so only the five known `<kind>.log` files are reachable. This
1048 + // closes `*.log` filename probing within a version dir (traversal was already
1049 + // blocked; this bounds the basename to the known set).
1050 + let kind: crate::domain::GateKind = gate
1051 + .parse()
1052 + .map_err(|_| crate::error::Error::NotFound)?;
1053 + let path = s.cfg.logs_root.join(&version).join(format!("{}.log", kind.as_str()));
1029 1054 // Bound how much a single request can pull into the daemon's memory: a
1030 1055 // runaway gate log shouldn't be read whole. Past the cap we return the tail
1031 1056 // (the recent, relevant output) behind a truncation marker.
@@ -1933,17 +1958,54 @@ mod tests {
1933 1958 }
1934 1959
1935 1960 #[tokio::test]
1936 - async fn read_routes_stay_open_when_token_set() {
1937 - // /state carries no deploy authority; the TUI polls it without a token.
1961 + async fn read_routes_require_token_when_set() {
1962 + // Reads expose prod state (versions, SHAs, gate logs, the event stream),
1963 + // so they are bearer-gated too — not just the mutators. A tailnet peer
1964 + // without the token gets 401; the TUI presents the token and gets 200.
1938 1965 let mut state = test_state().await;
1939 1966 state.api_token = Some(std::sync::Arc::from("s3cr3t"));
1940 1967 let app = router(state);
1968 +
1969 + // No token -> 401 on a read.
1970 + let resp = app.clone()
1971 + .oneshot(Request::builder().uri("/state").body(Body::empty()).unwrap())
1972 + .await.unwrap();
1973 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1974 +
1975 + // Correct token -> 200.
1976 + let resp = app
1977 + .oneshot(
1978 + Request::builder().uri("/state")
1979 + .header("authorization", "Bearer s3cr3t").body(Body::empty()).unwrap(),
1980 + )
1981 + .await.unwrap();
1982 + assert_eq!(resp.status(), StatusCode::OK);
1983 + }
1984 +
1985 + #[tokio::test]
1986 + async fn read_routes_open_without_a_token() {
1987 + // The loopback/dev posture: no token configured, reads pass through so a
1988 + // local TUI needs no credential.
1989 + let state = test_state().await;
1990 + let app = router(state);
1941 1991 let resp = app
1942 1992 .oneshot(Request::builder().uri("/state").body(Body::empty()).unwrap())
1943 1993 .await.unwrap();
1944 1994 assert_eq!(resp.status(), StatusCode::OK);
1945 1995 }
1946 1996
1997 + #[tokio::test]
1998 + async fn gate_log_rejects_unknown_gate_kind() {
1999 + // The gate segment is an allowlisted GateKind, not a free-form filename:
2000 + // an unknown kind is a 404, so `*.log` basenames can't be probed.
2001 + let state = test_state().await;
2002 + let app = router(state);
2003 + let resp = app
2004 + .oneshot(Request::builder().uri("/logs/0.9.6/passwd").body(Body::empty()).unwrap())
2005 + .await.unwrap();
2006 + assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2007 + }
2008 +
1947 2009 #[test]
1948 2010 fn ct_eq_matches_only_identical_strings() {
1949 2011 assert!(ct_eq("abc", "abc"));
@@ -17,6 +17,15 @@
17 17 # SANDO_UPSTREAM_URL git@ssh.makenot.work:max/makenotwork.git
18 18 # SANDO_BUILD_USER sando
19 19 # SANDO_BIN /usr/local/bin/sandod install destination
20 + # SANDO_DEPLOY_BRANCH main the only branch a self-update sha may live on
21 + #
22 + # Provenance: sandod (bearer-gated) only *triggers* this unit with a hex sha; it
23 + # does not prove the sha is a commit anyone intended to deploy. Without a check,
24 + # the deploy token would be root code-exec on this host — push any branch to the
25 + # remote, self-update to its tip. So after fetch we REFUSE any sha that is not an
26 + # ancestor of origin/$SANDO_DEPLOY_BRANCH: a feature-branch tip, a force-pushed
27 + # commit, or an unknown sha never reaches the build/install lines (exit 4). A
28 + # signed-tag check is the planned follow-up once release signing exists.
20 29 set -euo pipefail
21 30
22 31 SHA="${1:-}"
@@ -29,14 +38,15 @@ SELF_DIR="${SANDO_SELF_UPDATE_DIR:-/srv/sando/self-update}"
29 38 UPSTREAM_URL="${SANDO_UPSTREAM_URL:-git@ssh.makenot.work:max/makenotwork.git}"
30 39 BUILD_USER="${SANDO_BUILD_USER:-sando}"
31 40 BIN="${SANDO_BIN:-/usr/local/bin/sandod}"
41 + DEPLOY_BRANCH="${SANDO_DEPLOY_BRANCH:-main}"
32 42 REPO_DIR="$SELF_DIR/MNW"
33 43 BUILD_HOME="$(getent passwd "$BUILD_USER" | cut -d: -f6)"
34 44
35 - echo "sando-self-update: building sandod @ $SHA as $BUILD_USER"
45 + echo "sando-self-update: building sandod @ $SHA as $BUILD_USER (provenance: origin/$DEPLOY_BRANCH)"
36 46
37 - # Fetch + checkout + build, all as the unprivileged build user. The clone is
38 - # created once; thereafter we just fetch the new sha. Detached checkout so the
39 - # dedicated tree never carries a branch to drift.
47 + # Fetch + provenance check + checkout + build, all as the unprivileged build
48 + # user. The clone is created once; thereafter we just fetch the new sha. Detached
49 + # checkout so the dedicated tree never carries a branch to drift.
40 50 install -d -o "$BUILD_USER" -g "$BUILD_USER" "$SELF_DIR"
41 51 runuser -u "$BUILD_USER" -- env \
42 52 HOME="$BUILD_HOME" \
@@ -47,6 +57,14 @@ runuser -u "$BUILD_USER" -- env \
47 57 fi
48 58 cd '$REPO_DIR'
49 59 git fetch --prune origin
60 + # Provenance seal: the sha must be reachable from the deploy branch on the
61 + # canonical remote. --is-ancestor exits 1 for a non-ancestor and >1 for a
62 + # bad/unresolvable ref, so any non-deploy-branch sha is refused fail-closed
63 + # before a single line is built or installed.
64 + if ! git merge-base --is-ancestor '$SHA' 'origin/$DEPLOY_BRANCH'; then
65 + echo \"sando-self-update: refusing sha '$SHA' — not an ancestor of origin/$DEPLOY_BRANCH\" >&2
66 + exit 4
67 + fi
50 68 git checkout --detach '$SHA'
51 69 cd sando
52 70 cargo build --release --locked -p sando-daemon
@@ -243,6 +243,14 @@ impl Drop for TerminalGuard {
243 243
244 244 fn main() -> Result<()> {
245 245 let daemon = std::env::var("SANDO_DAEMON").unwrap_or_else(|_| "http://127.0.0.1:7766".into());
246 + // The daemon now bearer-gates reads too (not just mutators), so the polling
247 + // and event-stream tasks must present the token. Same value the operator
248 + // already exports for POSTs; absent on a loopback/dev daemon, where reads
249 + // stay open.
250 + let token = std::env::var("SANDO_API_TOKEN")
251 + .ok()
252 + .map(|s| s.trim().to_string())
253 + .filter(|s| !s.is_empty());
246 254 let shared = Arc::new(Mutex::new(Shared::default()));
247 255
248 256 let rt = tokio::runtime::Builder::new_multi_thread()
@@ -252,8 +260,8 @@ fn main() -> Result<()> {
252 260
253 261 // Background pollers + WS subscriber.
254 262 let _g = rt.enter();
255 - rt.spawn(state_poller(daemon.clone(), shared.clone()));
256 - rt.spawn(events_subscriber(daemon.clone(), shared.clone()));
263 + rt.spawn(state_poller(daemon.clone(), token.clone(), shared.clone()));
264 + rt.spawn(events_subscriber(daemon.clone(), token.clone(), shared.clone()));
257 265
258 266 // Restore the terminal on panic BEFORE the default hook prints, so the
259 267 // panic message + backtrace land on the normal screen rather than being
@@ -277,11 +285,15 @@ fn main() -> Result<()> {
277 285
278 286 // ---------- background tasks ----------
279 287
280 - async fn state_poller(daemon: String, shared: Arc<Mutex<Shared>>) {
288 + async fn state_poller(daemon: String, token: Option<String>, shared: Arc<Mutex<Shared>>) {
281 289 let url = format!("{}/state", daemon);
282 290 let client = reqwest::Client::new();
283 291 loop {
284 - match client.get(&url).timeout(Duration::from_secs(2)).send().await {
292 + let mut req = client.get(&url).timeout(Duration::from_secs(2));
293 + if let Some(tok) = &token {
294 + req = req.header(reqwest::header::AUTHORIZATION, format!("Bearer {tok}"));
295 + }
296 + match req.send().await {
285 297 Ok(resp) if resp.status().is_success() => match resp.json::<StateView>().await {
286 298 Ok(s) => {
287 299 let mut g = shared.lock().unwrap();
@@ -303,11 +315,34 @@ async fn state_poller(daemon: String, shared: Arc<Mutex<Shared>>) {
303 315 }
304 316 }
305 317
306 - async fn events_subscriber(daemon: String, shared: Arc<Mutex<Shared>>) {
318 + async fn events_subscriber(daemon: String, token: Option<String>, shared: Arc<Mutex<Shared>>) {
319 + use tokio_tungstenite::tungstenite::client::IntoClientRequest;
307 320 let ws_url = ws_url_from(&daemon);
308 321
309 322 loop {
310 - match tokio_tungstenite::connect_async(&ws_url).await {
323 + // Build the upgrade request fresh each attempt so the bearer header (when
324 + // configured) rides the /events handshake — the daemon gates the WS route
325 + // like every other read. A malformed request is treated like a dial
326 + // failure: mark the stream down and retry.
327 + let request = match ws_url.as_str().into_client_request() {
328 + Ok(mut r) => {
329 + if let Some(tok) = &token
330 + && let Ok(val) = format!("Bearer {tok}").parse()
331 + {
332 + r.headers_mut().insert(
333 + tokio_tungstenite::tungstenite::http::header::AUTHORIZATION,
334 + val,
335 + );
336 + }
337 + r
338 + }
339 + Err(_) => {
340 + shared.lock().unwrap().ws_ok = false;
341 + tokio::time::sleep(Duration::from_secs(3)).await;
342 + continue;
343 + }
344 + };
345 + match tokio_tungstenite::connect_async(request).await {
311 346 Ok((mut socket, _resp)) => {
312 347 shared.lock().unwrap().ws_ok = true;
313 348 while let Some(msg) = socket.next().await {