Skip to main content

max / makenotwork

wam: fail closed on the ingest API, require a bearer token The token machinery already ran end to end (require_auth, the server client, the offsite-sync scripts) but was optional, so with no WAM_TOKEN set the API served open on the tailnet and anything reachable could forge tickets. Make `wam serve` require a token. AppState.token is non-optional and require_auth always enforces it. resolve_or_create_token resolves in order from --token, WAM_TOKEN, a token persisted at <data-dir>/token, or a freshly generated one written 0600 and printed once. Bind stays on 0.0.0.0 by design: peer sync and cross-machine producers need the tailnet interface, so auth is the boundary. Update the README and CLI help; add tests for token precedence, generate/persist/reuse, and the blank-file case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 19:09 UTC
Commit: bf1be38f363f7fec6d211c452e5d18823ed73a01
Parent: be18913
4 files changed, +168 insertions, -47 deletions
M wam/README.md +8 -5
@@ -40,11 +40,14 @@ one-shot CLI use.
40 40
41 41 ## Auth
42 42
43 - The HTTP API runs on the tailnet. Pass `--token` (or set `WAM_TOKEN`) to require
44 - `Authorization: Bearer <token>` on every request; peer sync presents the same
45 - token. With no token the API stays open and logs a warning at startup, so the
46 - tailnet-ACL dependency is explicit. Producers (MNW server, PoM, the offsite-sync
47 - scripts) send the token when `WAM_TOKEN` is set in their environment.
43 + The HTTP API runs on the tailnet and fails closed: it never serves without a
44 + shared bearer token, so `Authorization: Bearer <token>` is required on every
45 + request and peer sync presents the same token. The token is resolved in order
46 + from `--token`, the `WAM_TOKEN` env var, a token persisted beside the database
47 + (`<data-dir>/token`), or, failing all of those, a freshly generated one that is
48 + saved with `0600` permissions and printed once at startup. When a token is
49 + auto-generated, copy it into `WAM_TOKEN` on every producer (MNW server, PoM, the
50 + offsite-sync scripts) and peer.
48 51
49 52 ## Layout
50 53
M wam/src/api.rs +145 -34
@@ -1,14 +1,16 @@
1 1 //! HTTP API for programmatic ticket management and peer sync.
2 2 //!
3 - //! The API runs on the tailnet. Tailnet membership is the outer boundary, but
4 - //! that is not sufficient on its own: anything that can reach the port can
5 - //! otherwise forge tickets. When a shared token is configured (`--token` or the
6 - //! `WAM_TOKEN` env var) every request must carry `Authorization: Bearer <token>`;
7 - //! peer sync presents the same token. With no token set the API stays open and
8 - //! logs a warning at startup, so the tailnet-ACL dependency is explicit.
3 + //! The API runs on the tailnet, but tailnet membership is not sufficient on its
4 + //! own: anything that can reach the port could otherwise forge tickets. So the
5 + //! server fails closed -- it never serves without a shared bearer token. The
6 + //! token is resolved in order from `--token`, the `WAM_TOKEN` env var, a token
7 + //! persisted beside the database, or, failing all of those, a freshly generated
8 + //! one that is persisted and printed once at startup. Every request (including
9 + //! peer sync) must carry `Authorization: Bearer <token>`.
9 10
10 11 use std::sync::Arc;
11 12
13 + use color_eyre::eyre::WrapErr;
12 14 use tokio::sync::Mutex;
13 15
14 16 use axum::{
@@ -25,20 +27,21 @@ use serde::Deserialize;
25 27 use crate::db::{self, ListFilter};
26 28 use crate::types::{Channel, NewTicket, Priority, Status, Ticket};
27 29
28 - /// Shared state: SQLite connection + node identity + optional shared token.
30 + /// Shared state: SQLite connection + node identity + shared bearer token.
29 31 #[derive(Clone)]
30 32 pub(crate) struct AppState {
31 33 pub db: Arc<Mutex<Connection>>,
32 34 pub node_id: String,
33 - /// Shared bearer token. `None` leaves the API unauthenticated (tailnet-only).
34 - pub token: Option<Arc<str>>,
35 + /// Shared bearer token required on every request and presented to peers.
36 + /// Always present -- the server refuses to start without one.
37 + pub token: Arc<str>,
35 38 }
36 39
37 40 /// Start the HTTP server, optionally syncing with peers.
38 41 ///
39 - /// `token` is the CLI-supplied shared secret; it falls back to the `WAM_TOKEN`
40 - /// env var. When resolved to `Some`, every request (including peer sync) must
41 - /// present it as a bearer token.
42 + /// `token` is the CLI-supplied shared secret. Token resolution fails closed:
43 + /// see [`resolve_or_create_token`]. Every request (including peer sync) must
44 + /// present the resolved token as a bearer token.
42 45 pub(crate) async fn serve(
43 46 conn: Connection,
44 47 port: u16,
@@ -48,16 +51,12 @@ pub(crate) async fn serve(
48 51 let node_id = db::get_or_create_node_id(&conn)?;
49 52 eprintln!("node: {}", &node_id[..8]);
50 53
51 - let token = token
52 - .or_else(|| std::env::var("WAM_TOKEN").ok())
53 - .map(|t| t.trim().to_owned())
54 - .filter(|t| !t.is_empty())
55 - .map(Arc::<str>::from);
54 + let token = resolve_or_create_token(token)?;
56 55
57 56 let app_state = AppState {
58 57 db: Arc::new(Mutex::new(conn)),
59 58 node_id,
60 - token: token.clone(),
59 + token: Arc::clone(&token),
61 60 };
62 61
63 62 // Spawn sync loop if peers are configured
@@ -86,15 +85,7 @@ pub(crate) async fn serve(
86 85 let addr = format!("0.0.0.0:{port}");
87 86 let listener = tokio::net::TcpListener::bind(&addr).await?;
88 87 eprintln!("wam serving on {addr}");
89 - if token.is_some() {
90 - eprintln!("auth: bearer token required on every request");
91 - } else {
92 - eprintln!(
93 - "WARNING: no token set (--token / WAM_TOKEN) -- the API is unauthenticated. \
94 - Anything that can reach {addr} can forge or read tickets; access is bounded \
95 - only by the tailnet ACL."
96 - );
97 - }
88 + eprintln!("auth: bearer token required on every request");
98 89 if !peers.is_empty() {
99 90 eprintln!("syncing with {} peer(s)", peers.len());
100 91 }
@@ -102,12 +93,74 @@ pub(crate) async fn serve(
102 93 Ok(())
103 94 }
104 95
105 - /// Reject any request lacking a valid `Authorization: Bearer <token>` header
106 - /// when a shared token is configured. A no-op when the token is unset.
96 + /// Resolve the shared bearer token, failing closed so the API is never open.
97 + ///
98 + /// Precedence: the explicit `--token` flag, then the `WAM_TOKEN` env var, then a
99 + /// token persisted beside the database from a previous run, then a freshly
100 + /// generated one (persisted with `0600` perms and printed once). The result is
101 + /// always a usable token; there is no unauthenticated path.
102 + fn resolve_or_create_token(cli_token: Option<String>) -> color_eyre::eyre::Result<Arc<str>> {
103 + // An explicit flag or environment variable wins, in that order.
104 + let supplied = cli_token
105 + .or_else(|| std::env::var("WAM_TOKEN").ok())
106 + .map(|t| t.trim().to_owned())
107 + .filter(|t| !t.is_empty());
108 + if let Some(tok) = supplied {
109 + return Ok(Arc::from(tok));
110 + }
111 +
112 + token_from_dir(&db::data_dir()?)
113 + }
114 +
115 + /// Reuse a token persisted in `dir/token`, or mint, persist, and print a new
116 + /// one. Split out from [`resolve_or_create_token`] so the disk paths are
117 + /// testable against a scratch directory.
118 + fn token_from_dir(dir: &std::path::Path) -> color_eyre::eyre::Result<Arc<str>> {
119 + let path = dir.join("token");
120 +
121 + // Reuse a token persisted by an earlier run.
122 + if let Ok(saved) = std::fs::read_to_string(&path) {
123 + let saved = saved.trim().to_owned();
124 + if !saved.is_empty() {
125 + eprintln!("auth: using persisted token at {}", path.display());
126 + return Ok(Arc::from(saved));
127 + }
128 + }
129 +
130 + // Nothing configured: mint one, persist it, and print it once so the
131 + // operator can propagate it to producers and peers.
132 + let token = format!(
133 + "{}{}",
134 + uuid::Uuid::new_v4().simple(),
135 + uuid::Uuid::new_v4().simple()
136 + );
137 + write_token_file(&path, &token)?;
138 + eprintln!(
139 + "auth: no token supplied -- generated one and saved it to {}",
140 + path.display()
141 + );
142 + eprintln!(" token: {token}");
143 + eprintln!(" set WAM_TOKEN to this value on every producer and peer.");
144 + Ok(Arc::from(token))
145 + }
146 +
147 + /// Write the token to `path`, restricting it to owner-only (`0600`) on unix.
148 + fn write_token_file(path: &std::path::Path, token: &str) -> color_eyre::eyre::Result<()> {
149 + std::fs::write(path, format!("{token}\n"))
150 + .wrap_err_with(|| format!("write token file: {}", path.display()))?;
151 + #[cfg(unix)]
152 + {
153 + use std::os::unix::fs::PermissionsExt;
154 + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
155 + .wrap_err_with(|| format!("restrict token file perms: {}", path.display()))?;
156 + }
157 + Ok(())
158 + }
159 +
160 + /// Reject any request lacking a valid `Authorization: Bearer <token>` header.
161 + /// The token is always set, so every route behind this layer is authenticated.
107 162 async fn require_auth(State(state): State<AppState>, req: Request, next: Next) -> Response {
108 - let Some(expected) = state.token.as_deref() else {
109 - return next.run(req).await;
110 - };
163 + let expected = state.token.as_ref();
111 164 let presented = req
112 165 .headers()
113 166 .get(axum::http::header::AUTHORIZATION)
@@ -166,6 +219,64 @@ mod tests {
166 219 let _ = with_token(client.get("http://127.0.0.1/x"), Some("tok"));
167 220 let _ = with_token(client.get("http://127.0.0.1/x"), None);
168 221 }
222 +
223 + /// A unique scratch directory under the system temp dir, removed on drop.
224 + struct ScratchDir(std::path::PathBuf);
225 +
226 + impl ScratchDir {
227 + fn new() -> Self {
228 + let dir = std::env::temp_dir().join(format!("wam-test-{}", uuid::Uuid::new_v4()));
229 + std::fs::create_dir_all(&dir).unwrap();
230 + Self(dir)
231 + }
232 + }
233 +
234 + impl Drop for ScratchDir {
235 + fn drop(&mut self) {
236 + let _ = std::fs::remove_dir_all(&self.0);
237 + }
238 + }
239 +
240 + #[test]
241 + fn explicit_token_wins_and_is_trimmed() {
242 + // A supplied token short-circuits before any env or disk lookup.
243 + let tok = resolve_or_create_token(Some(" s3cr3t ".to_string())).unwrap();
244 + assert_eq!(&*tok, "s3cr3t");
245 + }
246 +
247 + #[test]
248 + fn token_from_dir_generates_persists_and_reuses() {
249 + let scratch = ScratchDir::new();
250 +
251 + // First call mints a token and writes it to dir/token.
252 + let first = token_from_dir(&scratch.0).unwrap();
253 + assert!(!first.is_empty());
254 + let path = scratch.0.join("token");
255 + assert!(path.exists());
256 + assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), &*first);
257 +
258 + // On unix the file is owner-only.
259 + #[cfg(unix)]
260 + {
261 + use std::os::unix::fs::PermissionsExt;
262 + let mode = std::fs::metadata(&path).unwrap().permissions().mode();
263 + assert_eq!(mode & 0o777, 0o600);
264 + }
265 +
266 + // A second call reuses the persisted token rather than minting anew.
267 + let second = token_from_dir(&scratch.0).unwrap();
268 + assert_eq!(&*first, &*second);
269 + }
270 +
271 + #[test]
272 + fn token_from_dir_ignores_blank_file() {
273 + let scratch = ScratchDir::new();
274 + std::fs::write(scratch.0.join("token"), " \n").unwrap();
275 + // A whitespace-only file is treated as absent: a real token is minted.
276 + let tok = token_from_dir(&scratch.0).unwrap();
277 + assert!(!tok.trim().is_empty());
278 + assert_eq!(tok.len(), 64); // two hyphen-free v4 UUIDs
279 + }
169 280 }
170 281
171 282 // -- Ticket handlers ----------------------------------------------------------
@@ -398,7 +509,7 @@ async fn sync_with_peer(
398 509 "{peer_url}/sync/pull?since={}",
399 510 urlencoding::encode(&cursor)
400 511 );
401 - let resp: serde_json::Value = with_token(client.get(&pull_url), state.token.as_deref())
512 + let resp: serde_json::Value = with_token(client.get(&pull_url), Some(state.token.as_ref()))
402 513 .send()
403 514 .await?
404 515 .json()
@@ -438,7 +549,7 @@ async fn sync_with_peer(
438 549 let push_url = format!("{peer_url}/sync/push");
439 550 let resp: serde_json::Value = with_token(
440 551 client.post(&push_url).json(&our_tickets),
441 - state.token.as_deref(),
552 + Some(state.token.as_ref()),
442 553 )
443 554 .send()
444 555 .await?
M wam/src/cli.rs +3 -2
@@ -76,8 +76,9 @@ pub(crate) enum Command {
76 76 #[arg(long)]
77 77 peer: Vec<String>,
78 78 /// Shared bearer token required on every request (and presented to
79 - /// peers). Falls back to the WAM_TOKEN env var. When unset the API is
80 - /// unauthenticated and relies on the tailnet ACL alone.
79 + /// peers). Falls back to the WAM_TOKEN env var, then a token persisted
80 + /// beside the database, then a freshly generated one. The API always
81 + /// requires a token; it never serves unauthenticated.
81 82 #[arg(long)]
82 83 token: Option<String>,
83 84 },
M wam/src/db.rs +12 -6
@@ -8,15 +8,21 @@ use rusqlite::{Connection, Row, params};
8 8
9 9 use crate::types::{Channel, NewTicket, Priority, Status, Ticket};
10 10
11 - /// Open (or create) the WAM database and run migrations.
12 - pub(crate) fn open_db() -> Result<Connection> {
11 + /// Resolve (creating if absent) the per-user data directory that holds the
12 + /// database and the shared-token file. Shared by `open_db` and the API's token
13 + /// resolution so both agree on one location.
14 + pub(crate) fn data_dir() -> Result<std::path::PathBuf> {
13 15 let dirs = directories::ProjectDirs::from("work", "makenot", "wam")
14 16 .ok_or_else(|| eyre!("cannot determine data directory"))?;
15 - let data_dir = dirs.data_dir();
16 - std::fs::create_dir_all(data_dir)
17 - .wrap_err_with(|| format!("create data dir: {}", data_dir.display()))?;
17 + let dir = dirs.data_dir().to_path_buf();
18 + std::fs::create_dir_all(&dir)
19 + .wrap_err_with(|| format!("create data dir: {}", dir.display()))?;
20 + Ok(dir)
21 + }
18 22
19 - let db_path = data_dir.join("wam.db");
23 + /// Open (or create) the WAM database and run migrations.
24 + pub(crate) fn open_db() -> Result<Connection> {
25 + let db_path = data_dir()?.join("wam.db");
20 26 let conn = Connection::open(&db_path)
21 27 .wrap_err_with(|| format!("open database: {}", db_path.display()))?;
22 28