| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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?
|