max / makenotwork
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 { |