diff options
| -rw-r--r-- | docs/access.md | 6 | ||||
| -rw-r--r-- | docs/example.toml | 6 | ||||
| -rw-r--r-- | docs/openapi.yaml | 35 | ||||
| -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 | ||||
| -rw-r--r-- | src/config/mod.rs | 19 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/node.rs | 3 | ||||
| -rw-r--r-- | src/simtest/mod.rs | 1 | ||||
| -rw-r--r-- | src/state/mod.rs | 2 | ||||
| -rw-r--r-- | src/swarm/links.rs | 2 |
12 files changed, 276 insertions, 1 deletions
diff --git a/docs/access.md b/docs/access.md index e8c5455..71aebea 100644 --- a/docs/access.md +++ b/docs/access.md @@ -105,6 +105,12 @@ curl -H "Authorization: Bearer ct1.…" http://other-node:3000/store `GET /auth/whoami` says who a request's user is, how it proved it, and what it may do. +Pages of other origins, the dashboards of other members, may call a node with a token: +its own links (`/admin/dashboard/links/own`, and its events), `/auth/refresh` and +`/auth/whoami`, nothing else. A node allows the origins of the dashboards its member +list names (`--dashboard-url`) and those of `--cors-origins`; without credentials (no +cookie or password crosses origins: the page sends its token). + ## Managing users Through `/admin/users` ([`openapi.yaml`](openapi.yaml), Users): looking needs `read` on diff --git a/docs/example.toml b/docs/example.toml index ab8237c..222db52 100644 --- a/docs/example.toml +++ b/docs/example.toml @@ -78,6 +78,12 @@ htpasswd = "/etc/continuum/htpasswd" # the login's end (session-lifetime). # token-lifetime = "12h" +# Origins (scheme://host[:port]) whose pages may read this node's own links and refresh +# tokens here, with a token (docs/access.md, Tokens): the dashboards of other members are +# allowed anyway, as the member list names them (dashboard-url). Without credentials: a +# page sends its token itself. +# cors-origins = ["https://dashboard.example.org"] + # How long after a login (POST /auth/login) its tokens may be refreshed; then the user # logs in again. # session-lifetime = "7d" diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 64c5b9b..f8fb25d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1015,6 +1015,41 @@ paths: type: object required: [node, stats, peers, graph] additionalProperties: true + /admin/dashboard/links/own: + get: + tags: [Admin] + summary: This node's own links + description: | + As it measures them: its ID, its epoch, and each link (what the map draws). + Readable by pages of other members' dashboards, with a token (CORS: docs/access.md, Tokens). + responses: + '200': + description: The links. + content: + application/json: + schema: + type: object + properties: + node: { type: string } + epoch: { type: object } + links: { type: array, items: { type: object } } + '401': + $ref: '#/components/responses/Unauthorized' + + /admin/dashboard/links/own/events: + get: + tags: [Admin] + summary: This node's own links, as they change + description: Server-sent events, `links` with what `/admin/dashboard/links/own` answers at once and whenever it changed, `offline` when the node stops. Readable by pages of other members' dashboards, as that is. + responses: + '200': + description: The events. + content: + text/event-stream: + schema: { type: string } + '401': + $ref: '#/components/responses/Unauthorized' + /admin/dashboard/refresh: post: tags: [Admin] 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 diff --git a/src/config/mod.rs b/src/config/mod.rs index 60ad413..f0e5dc5 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -138,6 +138,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "How long after a login (POST /auth/login) its tokens may be refreshed; then the user logs in again.", ), option( + "cors-origins", + Kind::List, + "ORIGIN,[ORIGIN...]", + None, + "Origins (scheme://host[:port]) whose pages may read this node's own links and refresh tokens here, with a token (docs/access.md, Tokens): the dashboards of other members are allowed anyway, as the member list names them (dashboard-url). Without credentials: a page sends its token itself.", + ), + option( "bootstrap-users", Kind::List, "USER,[USER...]", @@ -557,6 +564,8 @@ pub(crate) struct Settings { pub(crate) protocol_writers: Vec<String>, #[serde(default)] pub(crate) bootstrap_users: Vec<String>, + #[serde(default)] + pub(crate) cors_origins: Vec<String>, #[serde( default = "default_token_lifetime", with = "crate::units::duration_option" @@ -790,6 +799,16 @@ impl Settings { if self.remake.is_some() && !self.genesis { bail!("remake makes a new swarm's document; it needs genesis"); } + if let Some(bad) = self.cors_origins.iter().find(|origin| { + url::Url::parse(origin) + .ok() + .filter(|url| url.origin().is_tuple()) + .is_none_or(|url| url.origin().ascii_serialization() != **origin) + }) { + bail!( + "cors-origins: {bad:?} isn't an origin (scheme://host[:port], no path; * isn't taken)" + ); + } if !self.bootstrap_users.is_empty() && !self.genesis { bail!( "bootstrap-users names the first users of a new swarm; it needs genesis (drop it once the swarm exists)" diff --git a/src/main.rs b/src/main.rs index 12e57dc..074890f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -254,6 +254,7 @@ async fn main() -> Result<()> { node_key: Some(Arc::clone(&node_key)), token_lifetime: args.token_lifetime, session_lifetime: args.session_lifetime, + cors_origins: args.cors_origins.clone(), files, state_file, document_file, diff --git a/src/node.rs b/src/node.rs index c3be694..9b14447 100644 --- a/src/node.rs +++ b/src/node.rs @@ -69,6 +69,8 @@ pub(crate) struct NodeConfig { /// `--token-lifetime`, `--session-lifetime`. pub(crate) token_lifetime: Duration, pub(crate) session_lifetime: Duration, + /// `--cors-origins`. + pub(crate) cors_origins: Vec<String>, pub(crate) files: Arc<Files>, pub(crate) state_file: Option<Arc<StateFile>>, pub(crate) document_file: Option<Arc<DocumentFile>>, @@ -152,6 +154,7 @@ impl Node { node_key: config.node_key, token_lifetime: config.token_lifetime, session_lifetime: config.session_lifetime, + cors_origins: Arc::new(config.cors_origins), state_file: config.state_file, document_file: config.document_file, files: config.files, diff --git a/src/simtest/mod.rs b/src/simtest/mod.rs index c68fe57..118ec9d 100644 --- a/src/simtest/mod.rs +++ b/src/simtest/mod.rs @@ -216,6 +216,7 @@ impl Sim { node_key: None, token_lifetime: Duration::from_secs(3600), session_lifetime: Duration::from_secs(86400), + cors_origins: Vec::new(), files: Arc::new(Files::default()), state_file: None, document_file: None, diff --git a/src/state/mod.rs b/src/state/mod.rs index 223c0c9..272855f 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -149,6 +149,8 @@ pub(crate) struct AppState { pub(crate) node_key: Option<Arc<crate::identity::NodeKey>>, pub(crate) token_lifetime: Duration, pub(crate) session_lifetime: Duration, + /// Origins whose pages may call this node's open endpoints (`api::cors`). + pub(crate) cors_origins: Arc<Vec<String>>, /// This node's own files, and how much it read and wrote them. pub(crate) files: Arc<crate::files::Files>, /// This node's document on disk (`--document-file`), if configured. diff --git a/src/swarm/links.rs b/src/swarm/links.rs index ea5564e..2234783 100644 --- a/src/swarm/links.rs +++ b/src/swarm/links.rs @@ -1019,7 +1019,7 @@ pub(crate) async fn watch(state: AppState) { /// dormancy, a round trip measured or moved by more than a fifth (and 1 ms), or a clock /// measured or moved beyond both bounds. Round trips and clock samples wobble; small /// moves aren't news. -fn changed(old: &[Link], new: &[Link]) -> bool { +pub(crate) fn changed(old: &[Link], new: &[Link]) -> bool { let clock_moved = |a: Option<ClockOffset>, b: Option<ClockOffset>| match (a, b) { (Some(a), Some(b)) => { a.offset_ms.abs_diff(b.offset_ms) > a.within_ms.saturating_add(b.within_ms) |
