| 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 |
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 |
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 |
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 |
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 |
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"));
|