aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 22:35:09 +0100
committertv <tv@krebsco.de>2026-11-07 22:35:09 +0100
commit1458ea92a41788a0f33e86e621817613f46055eb (patch)
tree8cbe202657c5f9eb006b9612d9dfa0a4b829d589 /src/api
parentaea80cf1bfa39f17dbbd0b4782a71fca81e09f74 (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.rs144
-rw-r--r--src/api/dashboard/mod.rs54
-rw-r--r--src/api/mod.rs4
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