diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:09 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:09 +0100 |
| commit | 1458ea92a41788a0f33e86e621817613f46055eb (patch) | |
| tree | 8cbe202657c5f9eb006b9612d9dfa0a4b829d589 /src/api | |
| parent | aea80cf1bfa39f17dbbd0b4782a71fca81e09f74 (diff) | |
dashboard: own links over the API, and CORS
GET /admin/dashboard/links/own and its event stream: a node's own links,
as LINKS_REPLY carried them, for other members' dashboards with a
token. CORS for the members' dashboard origins and --cors-origins,
without credentials.
Auth plan M3.
Diffstat (limited to 'src/api')
| -rw-r--r-- | src/api/cors.rs | 144 | ||||
| -rw-r--r-- | src/api/dashboard/mod.rs | 54 | ||||
| -rw-r--r-- | src/api/mod.rs | 4 |
3 files changed, 202 insertions, 0 deletions
diff --git a/src/api/cors.rs b/src/api/cors.rs new file mode 100644 index 0000000..313d331 --- /dev/null +++ b/src/api/cors.rs @@ -0,0 +1,144 @@ +//! Pages of other origins reading this node: the dashboards of other members, which a +//! page subscribes to for their own links (`/admin/dashboard/links/own`), refreshing its +//! token on any node (`/auth/refresh`, `/auth/whoami`). Only those endpoints, only for +//! the origins of the dashboards the member list names and those of `--cors-origins`, +//! and without credentials: a page sends its token itself (`Authorization: Bearer`), +//! so no cookie or password crosses origins (`access.md`, Tokens). + +use axum::{ + body::Body, + extract::State, + http::{HeaderValue, Method, Request, StatusCode, header}, + response::{IntoResponse, Response}, +}; + +use crate::state::AppState; +use crate::swarm::members; + +/// The endpoints other origins may call. +fn open(path: &str) -> bool { + matches!( + path, + "/admin/dashboard/links/own" + | "/admin/dashboard/links/own/events" + | "/auth/refresh" + | "/auth/whoami" + ) +} + +/// `url`'s origin (`scheme://host[:port]`), if it is a URL with one. +fn origin_of(url: &str) -> Option<String> { + let parsed = url::Url::parse(url).ok()?; + let origin = parsed.origin(); + origin.is_tuple().then(|| origin.ascii_serialization()) +} + +/// Whether pages of `origin` may call `open` endpoints: a member's dashboard's, or one +/// of `--cors-origins`. +async fn allowed(state: &AppState, origin: &str) -> bool { + if state.cors_origins.iter().any(|allowed| allowed == origin) { + return true; + } + let replica = state.doc.read().await; + members::members(&replica.doc).iter().any(|member| { + members::dashboard(&replica.doc, member) + .and_then(|url| origin_of(&url)) + .is_some_and(|theirs| theirs == origin) + }) +} + +/// Answers a preflight (`OPTIONS` with `Access-Control-Request-Method`) to an open +/// endpoint from an allowed origin, and adds the CORS headers to the answers of the +/// others from there; leaves everything else as it is (a browser then keeps a page of +/// another origin from reading it). Outside authentication: a preflight brings no +/// credentials. +pub(crate) async fn cors( + State(state): State<AppState>, + request: Request<Body>, + next: axum::middleware::Next, +) -> Response { + let origin = request + .headers() + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let Some(origin) = origin.filter(|_| open(request.uri().path())) else { + return next.run(request).await; + }; + if !allowed(&state, &origin).await { + return next.run(request).await; + } + let Ok(origin) = HeaderValue::from_str(&origin) else { + return next.run(request).await; + }; + let preflight = request.method() == Method::OPTIONS + && request + .headers() + .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD); + // Chrome's Private Network Access: a page on a public origin reaching a private + // address asks first. + let private = request + .headers() + .get("access-control-request-private-network") + .is_some_and(|value| value == "true"); + let mut response = if preflight { + let mut response = StatusCode::NO_CONTENT.into_response(); + let headers = response.headers_mut(); + headers.insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, POST"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("authorization, content-type"), + ); + headers.insert( + header::ACCESS_CONTROL_MAX_AGE, + HeaderValue::from_static("600"), + ); + if private { + headers.insert( + "access-control-allow-private-network", + HeaderValue::from_static("true"), + ); + } + response + } else { + next.run(request).await + }; + let headers = response.headers_mut(); + headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); + headers.append(header::VARY, HeaderValue::from_static("Origin")); + response +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn origins() { + assert_eq!( + origin_of("https://a.example.org/admin/dashboard").as_deref(), + Some("https://a.example.org") + ); + assert_eq!( + origin_of("http://10.0.0.1:3000/admin/dashboard").as_deref(), + Some("http://10.0.0.1:3000") + ); + assert_eq!( + origin_of("https://a.example.org:443/").as_deref(), + Some("https://a.example.org") + ); + assert_eq!(origin_of("not a url"), None); + } + + #[test] + fn only_some_endpoints() { + assert!(open("/admin/dashboard/links/own")); + assert!(open("/auth/refresh")); + assert!(!open("/auth/login")); + assert!(!open("/store")); + assert!(!open("/admin/dashboard/state")); + } +} diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs index b89955a..d5f0ed8 100644 --- a/src/api/dashboard/mod.rs +++ b/src/api/dashboard/mod.rs @@ -537,9 +537,63 @@ pub(crate) fn router(state: AppState) -> Router<AppState> { .route("/admin/dashboard/refresh", post(refresh)) .route("/admin/dashboard/links", post(set_links)) .route("/admin/dashboard/tree", get(writer_tree)) + .route("/admin/dashboard/links/own", get(own_links_now)) + .route("/admin/dashboard/links/own/events", get(own_links_events)) .layer(Extension(Hub::new(state, closing))) } +/// How often a stream of this node's own links looks whether they changed +/// (`own_links_events`). +const OWN_LINKS_EVERY: Duration = Duration::from_secs(2); + +/// This node's own links, as it measures them: its ID, its epoch, the links +/// (`links_json`), what a member's report on the map holds. +async fn own_report(state: &AppState) -> (Vec<Link>, JsonValue) { + let own = links::own_links(state).await; + let report = json!({ + "node": state.local_id(), + "epoch": crate::document::replica::EpochRecord::from(state.local_epoch()), + "links": links_json(&own), + }); + (own, report) +} + +/// `GET /admin/dashboard/links/own`: this node's own links (`own_report`), for a page +/// of this node or, through CORS, another member's (`api::cors`). +async fn own_links_now(Extension(hub): Extension<Arc<Hub>>) -> axum::Json<JsonValue> { + axum::Json(own_report(&hub.state).await.1) +} + +/// `GET /admin/dashboard/links/own/events`: server-sent events, `links` with this +/// node's own links at once, then whenever they changed (`links::changed`, looked at +/// every `OWN_LINKS_EVERY`); `offline` when the node stops, which ends it. +async fn own_links_events(Extension(hub): Extension<Arc<Hub>>) -> impl IntoResponse { + let stream = async_stream::stream! { + let (mut last, report) = own_report(&hub.state).await; + yield Ok::<_, Infallible>(Event::default().event("links").data(report.to_string())); + let mut every = tokio::time::interval(OWN_LINKS_EVERY); + every.tick().await; + loop { + tokio::select! { + _ = every.tick() => {} + () = hub.closed() => { + yield Ok(offline_event()); + break; + } + } + let (own, report) = own_report(&hub.state).await; + if links::changed(&last, &own) { + last = own; + yield Ok(Event::default().event("links").data(report.to_string())); + } + } + }; + ( + [(super::UNBUFFERED.0, super::UNBUFFERED.1)], + Sse::new(stream).keep_alive(KeepAlive::default()), + ) +} + /// The page as built into this binary. const PAGE: &str = include_str!("dashboard.html"); /// Where the page says which version of it it is. diff --git a/src/api/mod.rs b/src/api/mod.rs index b62163e..38868e2 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -5,6 +5,7 @@ mod access; pub(crate) mod auth; mod changes; mod config; +mod cors; #[cfg(feature = "dashboard")] mod dashboard; mod debug; @@ -105,6 +106,9 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> .layer(middleware::from_fn_with_state(auth_state, basic_auth)) // Outside authentication: a token, if the request brings one, names the user. .layer(middleware::from_fn_with_state(state.clone(), login::bearer)) + // Outermost: pages of other origins, and their preflights, which bring no + // credentials. + .layer(middleware::from_fn_with_state(state.clone(), cors::cors)) // The hand-written spec (`docs/openapi.yaml`), and the Swagger UI showing it. .merge( // Relative to the UI's page (`/swagger-ui/`), so it is found behind a proxy that |
