aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/cors.rs144
-rw-r--r--src/api/dashboard/mod.rs54
-rw-r--r--src/api/mod.rs4
-rw-r--r--src/config/mod.rs19
-rw-r--r--src/main.rs1
-rw-r--r--src/node.rs3
-rw-r--r--src/simtest/mod.rs1
-rw-r--r--src/state/mod.rs2
-rw-r--r--src/swarm/links.rs2
9 files changed, 229 insertions, 1 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
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)