From 52b05398b1216dfc519fa9bbcba119b783357b6e Mon Sep 17 00:00:00 2001 From: Dennis Paler Date: Thu, 17 Sep 2026 22:30:11 +0800 Subject: [PATCH 01/12] feat: unify dashboard indigo branding, refresh screenshots, and upgrade Bun to 1.4.2~ --- .github/workflows/release.yml | 39 ++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cc382c..2922390 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,11 +94,10 @@ jobs: artifact: checkgate.linux-arm64-gnu.node cross: true - # Linux x64 (musl — Alpine) — cross-compile via napi-rs cross toolchain + # Linux x64 (musl — Alpine) — build with musl-gcc - os: ubuntu-latest target: x86_64-unknown-linux-musl artifact: checkgate.linux-x64-musl.node - cross: true # macOS x64 — cross-compile natively from arm64 runner - os: macos-latest @@ -118,10 +117,19 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} + # Install targets on the toolchain selected by rust-toolchain.toml. + # Installing them on `stable` can leave the pinned toolchain without core/std. + - name: Install Rust toolchain and target + shell: bash + run: | + rustup show + rustup target add "${{ matrix.target }}" + + - name: Install musl compiler + if: matrix.target == 'x86_64-unknown-linux-musl' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 @@ -141,10 +149,13 @@ jobs: shell: bash run: | JS_FLAGS="--js native-binding.js --dts native-binding.d.ts" - if [ "${{ matrix.target }}" = "aarch64-unknown-linux-musl" ]; then - # Satisfy host-side NAPI-RS linker check (will use real linker inside Docker) - sudo ln -s /usr/bin/true /usr/bin/aarch64-linux-musl-gcc - npx napi build --platform --release --target ${{ matrix.target }} --use-napi-cross $JS_FLAGS + if [ "${{ matrix.target }}" = "x86_64-unknown-linux-musl" ]; then + # NAPI's cross toolchain supports GNU targets only. A Node addon is + # a shared library, so disable musl's default static CRT linkage. + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc + export TARGET_CC=musl-gcc + export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-C target-feature=-crt-static" + npx napi build --platform --release --target ${{ matrix.target }} $JS_FLAGS elif [ "${{ matrix.cross }}" = "true" ]; then npx napi build --platform --release --target ${{ matrix.target }} --use-napi-cross $JS_FLAGS else @@ -179,10 +190,10 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown + - name: Install Rust toolchain and target + run: | + rustup show + rustup target add wasm32-unknown-unknown - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 From c93c04e57a6e7465350c25c833f526cf0684e6df Mon Sep 17 00:00:00 2001 From: Dennis Paler Date: Thu, 17 Sep 2026 22:50:07 +0800 Subject: [PATCH 02/12] feat: unify dashboard indigo branding, refresh screenshots, and upgrade Bun to 1.4.2~ --- .github/workflows/release.yml | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2922390..e09fb0b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,7 +94,7 @@ jobs: artifact: checkgate.linux-arm64-gnu.node cross: true - # Linux x64 (musl — Alpine) — build with musl-gcc + # Linux x64 (musl — Alpine) — cross-compile with Zig - os: ubuntu-latest target: x86_64-unknown-linux-musl artifact: checkgate.linux-x64-musl.node @@ -125,11 +125,11 @@ jobs: rustup show rustup target add "${{ matrix.target }}" - - name: Install musl compiler + - name: Install Zig for musl if: matrix.target == 'x86_64-unknown-linux-musl' - run: | - sudo apt-get update - sudo apt-get install -y musl-tools + uses: mlugg/setup-zig@v2 + with: + version: 0.14.1 - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 @@ -150,12 +150,9 @@ jobs: run: | JS_FLAGS="--js native-binding.js --dts native-binding.d.ts" if [ "${{ matrix.target }}" = "x86_64-unknown-linux-musl" ]; then - # NAPI's cross toolchain supports GNU targets only. A Node addon is - # a shared library, so disable musl's default static CRT linkage. - export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc - export TARGET_CC=musl-gcc - export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-C target-feature=-crt-static" - npx napi build --platform --release --target ${{ matrix.target }} $JS_FLAGS + # Zig supplies the musl runtime and unwinder that Ubuntu's + # musl-gcc wrapper lacks. NAPI also disables static CRT linkage. + npx napi build --platform --release --target ${{ matrix.target }} --cross-compile $JS_FLAGS elif [ "${{ matrix.cross }}" = "true" ]; then npx napi build --platform --release --target ${{ matrix.target }} --use-napi-cross $JS_FLAGS else From f351cc1425bea5db536ea7c6134afa5429409b10 Mon Sep 17 00:00:00 2001 From: Dennis Paler Date: Thu, 29 Oct 2026 13:37:49 +0800 Subject: [PATCH 03/12] fix(server): resolve client IPs via trusted proxies; per-class rate limits The rate limiter believed the first X-Forwarded-For entry from any peer, so any client could pick its own rate-limit identity by sending the header, and one 60/min bucket covered every route. - New client_ip module: the TCP peer is the client unless it is listed in TRUSTED_PROXIES (IPs/CIDRs). From a trusted peer the X-Forwarded-For chain is walked from the right, skipping trusted hops; malformed or non-ASCII input falls back to the nearest trusted address, never to a client-chosen one. The walk is bounded. Invalid TRUSTED_PROXIES refuses startup. - Separate budgets for auth/setup, API, SDK snapshot, SSE connect and ingest (RATE_LIMIT_*_PER_MINUTE, 0 disables a class). /health is exempt. Only opening an SSE stream is counted, never events on it. - IPv6 clients are keyed by /64; idle entries are evicted every minute so limiter state stays bounded by recently active clients. - 429s carry Retry-After and a JSON body the dashboard login already reads. - Login lockout records and SDK health use the resolved client IP. Tests: resolver and limiter unit tests (spoofed headers, trusted, untrusted and multi-hop chains, malformed input, independent budgets, eviction, 429 shape, health exemption) and tests/rate_limit.rs against the real binary. The integration suite now marks 127.0.0.1 as a trusted proxy, which its X-Forwarded-For scenarios rely on. --- Cargo.lock | 2 + server/Cargo.toml | 4 + server/src/api/session.rs | 8 +- server/src/client_ip.rs | 311 ++++++++++++++++ server/src/main.rs | 33 +- server/src/rate_limit.rs | 681 ++++++++++++++++++++++++++++++++++-- server/src/scheduler.rs | 1 - server/src/state.rs | 3 - server/src/stream.rs | 9 +- server/tests/common/mod.rs | 327 +++++++++++++++++ server/tests/integration.rs | 4 + server/tests/rate_limit.rs | 271 ++++++++++++++ 12 files changed, 1603 insertions(+), 51 deletions(-) create mode 100644 server/src/client_ip.rs create mode 100644 server/tests/common/mod.rs create mode 100644 server/tests/rate_limit.rs diff --git a/Cargo.lock b/Cargo.lock index 89bcdb8..fb0e844 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2228,6 +2228,7 @@ dependencies = [ "futures-util", "governor", "hmac", + "ipnet", "rand 0.10.2", "redis", "reqwest", @@ -2238,6 +2239,7 @@ dependencies = [ "time", "tokio", "tokio-stream", + "tower", "tower-http", "tracing", "tracing-subscriber", diff --git a/server/Cargo.toml b/server/Cargo.toml index 1a400ab..826889d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -17,6 +17,7 @@ constant_time_eq = "0.4.2" cookie = { version = "0.18", features = ["private", "key-expansion"] } futures-util = "0.3.32" governor = "0.10.4" +ipnet = "2.12" redis = { version = "1.0.4", features = ["tokio-comp", "connection-manager"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" @@ -34,6 +35,9 @@ tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] } # Integration + e2e tests drive the spawned server over HTTP. `cookies` is for # the dashboard session flow; `stream` is for consuming the live SSE stream. reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies", "stream"] } +# Drives routers in-process (`ServiceExt::oneshot`) for middleware unit tests. +# Already in the build graph through axum. +tower = { version = "0.5", features = ["util"] } [lints] workspace = true diff --git a/server/src/api/session.rs b/server/src/api/session.rs index 1f24c26..1e329fc 100644 --- a/server/src/api/session.rs +++ b/server/src/api/session.rs @@ -4,17 +4,17 @@ use argon2::{ }; use axum::{ Json, Router, - extract::{ConnectInfo, State}, + extract::State, http::StatusCode, routing::{get, post}, }; use axum_extra::extract::cookie::{Cookie, PrivateCookieJar, SameSite}; use serde::{Deserialize, Serialize}; use sqlx::Row; -use std::net::SocketAddr; use time::Duration; use tracing::{error, info, warn}; +use crate::client_ip::ClientIp; use crate::state::AppState; // --------------------------------------------------------------------------- @@ -244,12 +244,12 @@ async fn clear_attempts(state: &AppState, email: &str) { )] pub async fn login( State(state): State, - ConnectInfo(addr): ConnectInfo, + ClientIp(client_ip): ClientIp, jar: PrivateCookieJar, Json(req): Json, ) -> Result<(PrivateCookieJar, Json), (StatusCode, Json)> { let email = req.email.trim().to_lowercase(); - let ip = addr.ip().to_string(); + let ip = client_ip.to_string(); if email.is_empty() || req.password.is_empty() { return Err(( diff --git a/server/src/client_ip.rs b/server/src/client_ip.rs new file mode 100644 index 0000000..f9c7e7f --- /dev/null +++ b/server/src/client_ip.rs @@ -0,0 +1,311 @@ +//! Resolves the client IP address a request is attributed to. +//! +//! The TCP peer address is the only value a client cannot choose, so it is the +//! default. `X-Forwarded-For` is consulted only when that peer is a proxy the +//! operator listed in `TRUSTED_PROXIES`; otherwise any client could pick its +//! own rate-limit identity (or frame another address) by sending the header. +//! +//! Proxies append the address they received a connection from, so the chain is +//! read from the right: each hop that is itself a trusted proxy is skipped, and +//! the first untrusted hop is the client. Entries left of that hop were written +//! by the client and are never used. + +use axum::{ + extract::{ConnectInfo, FromRequestParts, State}, + http::{HeaderMap, Request, StatusCode, request::Parts}, + middleware::Next, + response::Response, +}; +use ipnet::IpNet; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +/// Upper bound on the hops examined, so a long header costs bounded work. +const MAX_HOPS: usize = 32; + +/// Proxies whose `X-Forwarded-For` header is believed. +#[derive(Clone, Debug, Default)] +pub struct TrustedProxies { + nets: Vec, +} + +impl TrustedProxies { + /// Parses a comma- or whitespace-separated list of IP addresses and CIDR + /// ranges, e.g. `"10.0.0.0/8, 192.168.1.10, fd00::/8"`. + pub fn parse(spec: &str) -> Result { + let nets = spec + .split(|c: char| c == ',' || c.is_whitespace()) + .filter(|entry| !entry.is_empty()) + .map(|entry| { + entry + .parse::() + .or_else(|_| entry.parse::().map(IpNet::from)) + .map(|net| net.trunc()) + .map_err(|_| format!("invalid TRUSTED_PROXIES entry {entry:?}")) + }) + .collect::, _>>()?; + Ok(Self { nets }) + } + + /// Reads `TRUSTED_PROXIES`. Unset or empty means no proxy is trusted. + pub fn from_env() -> Result { + Self::parse(&std::env::var("TRUSTED_PROXIES").unwrap_or_default()) + } + + pub fn is_empty(&self) -> bool { + self.nets.is_empty() + } + + pub fn is_trusted(&self, ip: IpAddr) -> bool { + let ip = ip.to_canonical(); + self.nets.iter().any(|net| net.contains(&ip)) + } + + /// The address the request is attributed to. + pub fn resolve(&self, peer: IpAddr, headers: &HeaderMap) -> IpAddr { + let peer = peer.to_canonical(); + if !self.is_trusted(peer) { + return peer; + } + let mut hops = Vec::new(); + for value in headers.get_all("x-forwarded-for") { + // A header that is not visible ASCII cannot be a list of addresses; + // ignore the whole chain rather than guess which part is genuine. + let Ok(value) = value.to_str() else { + return peer; + }; + hops.extend(value.split(',').map(str::trim)); + } + let mut client = peer; + for hop in hops.iter().rev().take(MAX_HOPS) { + // A malformed hop ends the walk. Everything to its right was + // written by trusted proxies, so the nearest of those stands in for + // the client: a broken header can only merge clients into a + // proxy's budget, never hand a client a fresh one. + let Some(ip) = parse_hop(hop) else { + return client; + }; + client = ip; + if !self.is_trusted(ip) { + return ip; + } + } + client + } +} + +/// Parses one `X-Forwarded-For` entry: a bare address, or an address with a +/// port (`203.0.113.7:4711`, `[2001:db8::1]:443`), which some proxies emit. +fn parse_hop(hop: &str) -> Option { + let ip = hop + .parse::() + .ok() + .or_else(|| hop.parse::().ok().map(|addr| addr.ip())) + .or_else(|| { + hop.strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .and_then(|h| h.parse::().ok()) + })?; + Some(ip.to_canonical()) +} + +/// The resolved client address, placed in request extensions by +/// [`resolve_client_ip`]. Handlers extract it instead of `ConnectInfo` so that +/// logs, lockout records and the SDK health view agree with the rate limiter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ClientIp(pub IpAddr); + +impl FromRequestParts for ClientIp +where + S: Send + Sync, +{ + type Rejection = StatusCode; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + if let Some(ip) = parts.extensions.get::() { + return Ok(*ip); + } + // Without the middleware, the TCP peer is still the safe answer. + parts + .extensions + .get::>() + .map(|ConnectInfo(addr)| ClientIp(addr.ip().to_canonical())) + .ok_or(StatusCode::INTERNAL_SERVER_ERROR) + } +} + +/// Middleware that resolves the client address once per request. +pub async fn resolve_client_ip( + State(trusted): State>, + ConnectInfo(addr): ConnectInfo, + mut req: Request, + next: Next, +) -> Response { + let ip = trusted.resolve(addr.ip(), req.headers()); + req.extensions_mut().insert(ClientIp(ip)); + next.run(req).await +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn ip(s: &str) -> IpAddr { + s.parse().unwrap() + } + + fn xff(values: &[&str]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for v in values { + headers.append("x-forwarded-for", HeaderValue::from_str(v).unwrap()); + } + headers + } + + #[test] + fn direct_request_ignores_spoofed_header() { + let trusted = TrustedProxies::default(); + let headers = xff(&["198.51.100.1"]); + assert_eq!( + trusted.resolve(ip("203.0.113.9"), &headers), + ip("203.0.113.9") + ); + } + + #[test] + fn untrusted_proxy_header_is_ignored() { + let trusted = TrustedProxies::parse("10.0.0.0/8").unwrap(); + let headers = xff(&["198.51.100.1"]); + // The peer is not in the trusted range, so it is the client. + assert_eq!(trusted.resolve(ip("192.0.2.5"), &headers), ip("192.0.2.5")); + } + + #[test] + fn trusted_proxy_forwards_client() { + let trusted = TrustedProxies::parse("10.0.0.1").unwrap(); + let headers = xff(&["198.51.100.1"]); + assert_eq!( + trusted.resolve(ip("10.0.0.1"), &headers), + ip("198.51.100.1") + ); + } + + #[test] + fn trusted_proxy_without_header_is_the_client() { + let trusted = TrustedProxies::parse("10.0.0.1").unwrap(); + assert_eq!( + trusted.resolve(ip("10.0.0.1"), &HeaderMap::new()), + ip("10.0.0.1") + ); + } + + #[test] + fn multi_hop_chain_takes_rightmost_untrusted() { + let trusted = TrustedProxies::parse("10.0.0.0/8, 172.16.0.0/12").unwrap(); + // Client spoofs 1.1.1.1; the edge proxy (172.16.0.2) appended the real + // client 198.51.100.7; the inner proxy appended the edge proxy. + let headers = xff(&["1.1.1.1, 198.51.100.7, 172.16.0.2"]); + assert_eq!( + trusted.resolve(ip("10.0.0.1"), &headers), + ip("198.51.100.7") + ); + } + + #[test] + fn chain_split_across_header_lines_is_joined_in_order() { + let trusted = TrustedProxies::parse("10.0.0.0/8").unwrap(); + let headers = xff(&["1.1.1.1", "198.51.100.7, 10.0.0.2"]); + assert_eq!( + trusted.resolve(ip("10.0.0.1"), &headers), + ip("198.51.100.7") + ); + } + + #[test] + fn all_trusted_chain_falls_back_to_leftmost_hop() { + let trusted = TrustedProxies::parse("10.0.0.0/8").unwrap(); + let headers = xff(&["10.0.0.3, 10.0.0.2"]); + assert_eq!(trusted.resolve(ip("10.0.0.1"), &headers), ip("10.0.0.3")); + } + + #[test] + fn malformed_hop_stops_at_nearest_trusted_address() { + let trusted = TrustedProxies::parse("10.0.0.0/8").unwrap(); + for bad in ["not-an-ip", "", "unknown", "999.1.1.1", "1.2.3.4/24"] { + let headers = xff(&[&format!("198.51.100.7, {bad}")]); + assert_eq!( + trusted.resolve(ip("10.0.0.1"), &headers), + ip("10.0.0.1"), + "hop {bad:?}" + ); + let headers = xff(&[&format!("{bad}, 10.0.0.2")]); + assert_eq!( + trusted.resolve(ip("10.0.0.1"), &headers), + ip("10.0.0.2"), + "hop {bad:?} left of a trusted hop" + ); + } + } + + #[test] + fn non_ascii_header_is_ignored() { + let trusted = TrustedProxies::parse("10.0.0.1").unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + HeaderValue::from_bytes(b"198.51.100.1\xff").unwrap(), + ); + assert_eq!(trusted.resolve(ip("10.0.0.1"), &headers), ip("10.0.0.1")); + } + + #[test] + fn hops_with_ports_and_brackets_parse() { + let trusted = TrustedProxies::parse("10.0.0.1").unwrap(); + for (hop, want) in [ + ("198.51.100.1:4711", "198.51.100.1"), + ("[2001:db8::1]:443", "2001:db8::1"), + ("[2001:db8::2]", "2001:db8::2"), + ("2001:db8::3", "2001:db8::3"), + ] { + assert_eq!( + trusted.resolve(ip("10.0.0.1"), &xff(&[hop])), + ip(want), + "{hop}" + ); + } + } + + #[test] + fn ipv4_mapped_addresses_are_canonical() { + let trusted = TrustedProxies::parse("10.0.0.1").unwrap(); + let headers = xff(&["::ffff:198.51.100.1"]); + assert_eq!( + trusted.resolve(ip("::ffff:10.0.0.1"), &headers), + ip("198.51.100.1") + ); + } + + #[test] + fn hop_walk_is_bounded() { + let trusted = TrustedProxies::parse("10.0.0.0/8").unwrap(); + let mut chain = vec!["198.51.100.1".to_string()]; + chain.extend((0..100).map(|i| format!("10.0.{}.{}", i / 250, i % 250 + 1))); + let headers = xff(&[&chain.join(", ")]); + // The real client sits beyond MAX_HOPS trusted hops, so the walk stops + // on a trusted address instead of reaching it. + assert!(trusted.is_trusted(trusted.resolve(ip("10.0.0.1"), &headers))); + } + + #[test] + fn parse_accepts_ips_and_cidrs_and_rejects_garbage() { + let trusted = TrustedProxies::parse(" 10.0.0.0/8,192.168.1.10 fd00::/8 ").unwrap(); + assert!(trusted.is_trusted(ip("10.255.0.1"))); + assert!(trusted.is_trusted(ip("192.168.1.10"))); + assert!(!trusted.is_trusted(ip("192.168.1.11"))); + assert!(trusted.is_trusted(ip("fd12::1"))); + assert!(TrustedProxies::parse("").unwrap().is_empty()); + assert!(TrustedProxies::parse("10.0.0.0/33").is_err()); + assert!(TrustedProxies::parse("proxy.internal").is_err()); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index b9b7739..b5ea363 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -12,6 +12,7 @@ mod api; mod auth; +mod client_ip; mod integrations; mod notify; mod rate_limit; @@ -32,7 +33,7 @@ use checkgate_core::evaluator::Flag; use checkgate_core::store::FlagStore; use dashmap::DashMap; use rand::RngExt as _; -use rate_limit::new_rate_limiter; +use rate_limit::{RateLimitConfig, RateLimiters}; use sqlx::{Row, postgres::PgPoolOptions}; use state::SdkKeyEntry; use std::sync::Arc; @@ -228,6 +229,27 @@ async fn main() -> Result<(), Box> { } }; + // ── Client identity & rate limits ───────────────────────────────────────── + + let trusted_proxies = client_ip::TrustedProxies::from_env().map_err(|e| { + error!(error = %e, "Invalid TRUSTED_PROXIES"); + e + })?; + if trusted_proxies.is_empty() { + info!("TRUSTED_PROXIES not set — clients are identified by their TCP peer address"); + } else { + info!("TRUSTED_PROXIES set — X-Forwarded-For is honoured from those peers only"); + } + let trusted_proxies = Arc::new(trusted_proxies); + + let rate_limit_config = RateLimitConfig::from_env().map_err(|e| { + error!(error = %e, "Invalid rate limit configuration"); + e + })?; + info!(config = ?rate_limit_config, "Rate limits per client per minute"); + let rate_limiters = Arc::new(RateLimiters::new(rate_limit_config)); + rate_limiters.spawn_eviction(); + // ── Broadcast channel ───────────────────────────────────────────────────── const BROADCAST_CHANNEL_CAPACITY: usize = 256; @@ -407,7 +429,6 @@ async fn main() -> Result<(), Box> { store, flag_tx, sdk_keys, - rate_limiter: new_rate_limiter(), session_key, connected_clients: Arc::new(DashMap::new()), webhook_client, @@ -449,7 +470,7 @@ async fn main() -> Result<(), Box> { // Protected write: additionally requires admin role. // // Layer execution order for a write request (outermost → innermost): - // cors → rate_limit → csrf → require_auth → require_editor/require_admin → handler + // cors → client_ip → rate_limit → csrf → require_auth → require_editor/require_admin → handler let auth_routes = api::auth_router(); let setup_routes = api::setup_router(); @@ -511,9 +532,13 @@ async fn main() -> Result<(), Box> { .layer(middleware::from_fn(api::security_headers)) .layer(middleware::from_fn(api::csrf_protection)) .layer(middleware::from_fn_with_state( - app_state.clone(), + rate_limiters, rate_limit::rate_limit, )) + .layer(middleware::from_fn_with_state( + trusted_proxies, + client_ip::resolve_client_ip, + )) .layer(cors) .fallback_service(serve_dashboard) .with_state(app_state); diff --git a/server/src/rate_limit.rs b/server/src/rate_limit.rs index 0de4e9c..52fadd4 100644 --- a/server/src/rate_limit.rs +++ b/server/src/rate_limit.rs @@ -1,52 +1,665 @@ +//! Per-client rate limiting with a separate budget for each kind of endpoint. +//! +//! Clients are identified by [`ClientIp`], which only honours forwarding +//! headers from trusted proxies (see [`crate::client_ip`]). Each route class +//! has its own quota, so a burst of SDK polling cannot lock an operator out of +//! the dashboard and a password-guessing run cannot spend the SDK budget. +//! +//! The middleware runs once per HTTP request. An SSE stream is one request, so +//! only opening it is counted; events delivered on an open stream never are. + +use crate::client_ip::ClientIp; use axum::{ - extract::{ConnectInfo, State}, - http::{Request, StatusCode}, + Json, + extract::State, + http::{HeaderValue, Method, Request, StatusCode, header}, middleware::Next, - response::Response, + response::{IntoResponse, Response}, }; -use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; -use std::{net::IpAddr, num::NonZeroU32, sync::Arc}; - -pub type IpRateLimiter = Arc>; - -/// 60 requests per minute per source IP on all API routes. -/// -/// `RATE_PER_MINUTE` is a `const`, so the `NonZeroU32` conversion is checked at -/// compile time — there is no runtime unwrap to fail. -const RATE_PER_MINUTE: NonZeroU32 = match NonZeroU32::new(60) { - Some(n) => n, - None => unreachable!(), +use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter, clock::Clock}; +use std::{ + net::{IpAddr, Ipv6Addr}, + num::NonZeroU32, + sync::Arc, + time::Duration, }; -pub fn new_rate_limiter() -> IpRateLimiter { - Arc::new(RateLimiter::keyed(Quota::per_minute(RATE_PER_MINUTE))) +/// How often idle per-client entries are dropped from the limiter maps. +const EVICTION_INTERVAL: Duration = Duration::from_secs(60); + +/// The budget a request is charged to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RouteClass { + /// `/health`: probes from orchestrators and load balancers are never limited. + Exempt, + /// Password login and first-run setup — the credential-guessing surface. + Auth, + /// Dashboard and automation REST API. + Api, + /// SDK snapshot polling (`/flags/snapshot`). + Sdk, + /// Opening an SSE stream (`/stream`). + SseConnect, + /// Impression and event ingestion from SDKs. + Ingest, +} + +impl RouteClass { + fn name(self) -> &'static str { + match self { + RouteClass::Exempt => "exempt", + RouteClass::Auth => "auth", + RouteClass::Api => "api", + RouteClass::Sdk => "sdk", + RouteClass::SseConnect => "sse_connect", + RouteClass::Ingest => "ingest", + } + } +} + +/// Maps a request to its budget. +pub fn classify(method: &Method, path: &str) -> RouteClass { + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + match (method, segments.as_slice()) { + (_, ["health"]) => RouteClass::Exempt, + (&Method::POST, ["api", "auth", "login"]) | (_, ["api", "setup", ..]) => RouteClass::Auth, + (_, ["stream"]) => RouteClass::SseConnect, + (_, ["flags", "snapshot"]) => RouteClass::Sdk, + (&Method::POST, ["api", "environments", _, "impressions" | "events"]) => RouteClass::Ingest, + _ => RouteClass::Api, + } +} + +/// Requests per minute allowed per client for each class. `None` disables +/// limiting for that class (for deployments that limit at a gateway). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RateLimitConfig { + pub auth: Option, + pub api: Option, + pub sdk: Option, + pub sse_connect: Option, + pub ingest: Option, +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + auth: NonZeroU32::new(20), + api: NonZeroU32::new(300), + sdk: NonZeroU32::new(600), + sse_connect: NonZeroU32::new(120), + ingest: NonZeroU32::new(600), + } + } +} + +impl RateLimitConfig { + /// Reads `RATE_LIMIT_{AUTH,API,SDK,SSE,INGEST}_PER_MINUTE`. Unset keeps the + /// default, `0` disables the class, anything else must be a positive integer. + pub fn from_env() -> Result { + Self::from_lookup(|name| std::env::var(name).ok()) + } + + fn from_lookup(lookup: impl Fn(&str) -> Option) -> Result { + let defaults = Self::default(); + let read = |name: &str, default: Option| match lookup(name) { + None => Ok(default), + Some(v) => v + .trim() + .parse::() + .map(NonZeroU32::new) + .map_err(|_| format!("{name} must be a non-negative integer, got {v:?}")), + }; + Ok(Self { + auth: read("RATE_LIMIT_AUTH_PER_MINUTE", defaults.auth)?, + api: read("RATE_LIMIT_API_PER_MINUTE", defaults.api)?, + sdk: read("RATE_LIMIT_SDK_PER_MINUTE", defaults.sdk)?, + sse_connect: read("RATE_LIMIT_SSE_PER_MINUTE", defaults.sse_connect)?, + ingest: read("RATE_LIMIT_INGEST_PER_MINUTE", defaults.ingest)?, + }) + } +} + +type Limiter = DefaultKeyedRateLimiter; + +/// One keyed limiter per route class. +pub struct RateLimiters { + auth: Option, + api: Option, + sdk: Option, + sse_connect: Option, + ingest: Option, +} + +impl RateLimiters { + pub fn new(config: RateLimitConfig) -> Self { + // A per-minute quota allows a burst of the whole minute's budget, then + // refills one request every 60/n seconds. + let per_minute = |n: Option| n.map(Quota::per_minute); + Self::with_quotas( + per_minute(config.auth), + per_minute(config.api), + per_minute(config.sdk), + per_minute(config.sse_connect), + per_minute(config.ingest), + ) + } + + fn with_quotas( + auth: Option, + api: Option, + sdk: Option, + sse_connect: Option, + ingest: Option, + ) -> Self { + let build = |q: Option| q.map(RateLimiter::keyed); + Self { + auth: build(auth), + api: build(api), + sdk: build(sdk), + sse_connect: build(sse_connect), + ingest: build(ingest), + } + } + + fn limiter(&self, class: RouteClass) -> Option<&Limiter> { + match class { + RouteClass::Exempt => None, + RouteClass::Auth => self.auth.as_ref(), + RouteClass::Api => self.api.as_ref(), + RouteClass::Sdk => self.sdk.as_ref(), + RouteClass::SseConnect => self.sse_connect.as_ref(), + RouteClass::Ingest => self.ingest.as_ref(), + } + } + + fn all(&self) -> impl Iterator { + [ + &self.auth, + &self.api, + &self.sdk, + &self.sse_connect, + &self.ingest, + ] + .into_iter() + .flatten() + } + + /// Charges one request to `class` for `ip`. On refusal, returns how long + /// until the next request would be accepted. + pub fn check(&self, class: RouteClass, ip: IpAddr) -> Result<(), Duration> { + let Some(limiter) = self.limiter(class) else { + return Ok(()); + }; + limiter + .check_key(&rate_key(ip)) + .map_err(|not_until| not_until.wait_time_from(limiter.clock().now())) + } + + /// Drops entries whose bucket has fully refilled — they are + /// indistinguishable from a client never seen — so the maps hold only + /// clients active within the last refill period. + pub fn evict_idle(&self) { + for limiter in self.all() { + limiter.retain_recent(); + limiter.shrink_to_fit(); + } + } + + /// Number of tracked client keys across all classes. + pub fn tracked_keys(&self) -> usize { + self.all().map(Limiter::len).sum() + } + + /// Runs [`Self::evict_idle`] in the background for the life of the process. + pub fn spawn_eviction(self: &Arc) { + let limiters = Arc::clone(self); + tokio::spawn(async move { + let mut tick = tokio::time::interval(EVICTION_INTERVAL); + loop { + tick.tick().await; + limiters.evict_idle(); + tracing::debug!( + tracked_keys = limiters.tracked_keys(), + "Rate limiter idle entries evicted" + ); + } + }); + } +} + +/// The key a client is limited under. An IPv6 client usually controls a whole +/// /64, so keying single addresses would hand it 2^64 fresh budgets (and grow +/// the map without bound); the /64 prefix is the unit it actually owns. +fn rate_key(ip: IpAddr) -> IpAddr { + match ip.to_canonical() { + IpAddr::V4(v4) => IpAddr::V4(v4), + IpAddr::V6(v6) => { + let mut segments = v6.segments(); + for host in segments.iter_mut().skip(4) { + *host = 0; + } + IpAddr::V6(Ipv6Addr::from(segments)) + } + } +} + +fn too_many_requests(wait: Duration) -> Response { + let retry_after = wait.as_secs().saturating_add(1); + let mut response = ( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({ + "error": "Too many requests. Please slow down and try again shortly.", + "retry_after_seconds": retry_after, + })), + ) + .into_response(); + response + .headers_mut() + .insert(header::RETRY_AFTER, HeaderValue::from(retry_after)); + response } pub async fn rate_limit( - State(state): State, - ConnectInfo(addr): ConnectInfo, + State(limiters): State>, + ClientIp(client_ip): ClientIp, req: Request, next: Next, -) -> Result { - // Prefer the first address in X-Forwarded-For so that rate limiting works - // correctly behind a reverse proxy. Fall back to the direct TCP peer. - let client_ip: IpAddr = req - .headers() - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.split(',').next()) - .and_then(|s| s.trim().parse().ok()) - .unwrap_or_else(|| addr.ip()); - - match state.rate_limiter.check_key(&client_ip) { - Ok(_) => Ok(next.run(req).await), - Err(_) => { +) -> Response { + let class = classify(req.method(), req.uri().path()); + match limiters.check(class, client_ip) { + Ok(()) => next.run(req).await, + Err(wait) => { tracing::warn!( client_ip = %client_ip, + class = class.name(), path = %req.uri().path(), "Rate limit exceeded" ); - Err(StatusCode::TOO_MANY_REQUESTS) + too_many_requests(wait) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client_ip::{TrustedProxies, resolve_client_ip}; + use axum::{ + Router, + body::Body, + extract::ConnectInfo, + middleware, + routing::{get, post}, + }; + use std::net::SocketAddr; + use tower::ServiceExt; + + fn nz(n: u32) -> Option { + NonZeroU32::new(n) + } + + fn ip(s: &str) -> IpAddr { + s.parse().unwrap() + } + + #[test] + fn classification() { + let cases = [ + (Method::GET, "/health", RouteClass::Exempt), + (Method::POST, "/api/auth/login", RouteClass::Auth), + (Method::GET, "/api/auth/me", RouteClass::Api), + (Method::POST, "/api/auth/logout", RouteClass::Api), + (Method::GET, "/api/setup/key", RouteClass::Auth), + (Method::POST, "/api/setup/complete", RouteClass::Auth), + (Method::GET, "/stream", RouteClass::SseConnect), + (Method::GET, "/flags/snapshot", RouteClass::Sdk), + ( + Method::POST, + "/api/environments/e/impressions", + RouteClass::Ingest, + ), + ( + Method::POST, + "/api/environments/e/events", + RouteClass::Ingest, + ), + (Method::GET, "/api/environments/e/events", RouteClass::Api), + (Method::GET, "/api/environments/e/flags", RouteClass::Api), + (Method::POST, "/api/projects", RouteClass::Api), + (Method::GET, "/api/health/connections", RouteClass::Api), + ]; + for (method, path, want) in cases { + assert_eq!(classify(&method, path), want, "{method} {path}"); + } + } + + #[test] + fn config_from_env_values() { + let config = RateLimitConfig::from_lookup(|name| match name { + "RATE_LIMIT_AUTH_PER_MINUTE" => Some("5".into()), + "RATE_LIMIT_INGEST_PER_MINUTE" => Some("0".into()), + _ => None, + }) + .unwrap(); + assert_eq!(config.auth, nz(5)); + assert_eq!(config.ingest, None, "0 disables the class"); + assert_eq!(config.api, RateLimitConfig::default().api); + assert!( + RateLimitConfig::from_lookup( + |n| (n == "RATE_LIMIT_API_PER_MINUTE").then(|| "lots".to_string()) + ) + .is_err() + ); + assert!( + RateLimitConfig::from_lookup( + |n| (n == "RATE_LIMIT_SDK_PER_MINUTE").then(|| "-1".to_string()) + ) + .is_err() + ); + } + + #[test] + fn budgets_are_independent_per_class_and_client() { + let limiters = RateLimiters::new(RateLimitConfig { + auth: nz(2), + api: nz(3), + sdk: nz(1), + sse_connect: nz(1), + ingest: None, + }); + let a = ip("198.51.100.1"); + let b = ip("198.51.100.2"); + assert!(limiters.check(RouteClass::Auth, a).is_ok()); + assert!(limiters.check(RouteClass::Auth, a).is_ok()); + assert!( + limiters.check(RouteClass::Auth, a).is_err(), + "auth exhausted" + ); + // Other classes and other clients are untouched. + for _ in 0..3 { + assert!(limiters.check(RouteClass::Api, a).is_ok()); + } + assert!(limiters.check(RouteClass::Api, a).is_err()); + assert!(limiters.check(RouteClass::Sdk, a).is_ok()); + assert!(limiters.check(RouteClass::SseConnect, a).is_ok()); + assert!(limiters.check(RouteClass::Auth, b).is_ok()); + // Disabled and exempt classes never refuse. + for _ in 0..1000 { + assert!(limiters.check(RouteClass::Ingest, a).is_ok()); + assert!(limiters.check(RouteClass::Exempt, a).is_ok()); + } + } + + #[test] + fn ipv6_clients_share_their_slash_64() { + let limiters = RateLimiters::new(RateLimitConfig { + auth: nz(1), + ..RateLimitConfig::default() + }); + assert!( + limiters + .check(RouteClass::Auth, ip("2001:db8:1:2::1")) + .is_ok() + ); + assert!( + limiters + .check(RouteClass::Auth, ip("2001:db8:1:2:ffff::9")) + .is_err(), + "same /64" + ); + assert!( + limiters + .check(RouteClass::Auth, ip("2001:db8:1:3::1")) + .is_ok() + ); + // IPv4-mapped IPv6 is the IPv4 client. + assert!(limiters.check(RouteClass::Auth, ip("192.0.2.1")).is_ok()); + assert!( + limiters + .check(RouteClass::Auth, ip("::ffff:192.0.2.1")) + .is_err() + ); + } + + #[test] + fn idle_entries_are_evicted() { + let fast = Quota::with_period(Duration::from_millis(5)).unwrap(); + let limiters = RateLimiters::with_quotas(Some(fast), None, None, None, None); + for i in 0..50u8 { + limiters + .check(RouteClass::Auth, IpAddr::from([198, 51, 100, i])) + .unwrap(); + } + assert_eq!(limiters.tracked_keys(), 50); + std::thread::sleep(Duration::from_millis(30)); + limiters.evict_idle(); + assert_eq!(limiters.tracked_keys(), 0, "refilled buckets are dropped"); + } + + // --- Middleware, driven through a real router --------------------------- + + fn app(trusted: &str, config: RateLimitConfig) -> Router { + let limiters = Arc::new(RateLimiters::new(config)); + let trusted = Arc::new(TrustedProxies::parse(trusted).unwrap()); + Router::new() + .route("/health", get(|| async { "OK" })) + .route("/api/auth/login", post(|| async { "login" })) + .route("/api/projects", get(|| async { "projects" })) + .route("/stream", get(|| async { "stream" })) + .route( + "/whoami", + get(|ClientIp(ip): ClientIp| async move { ip.to_string() }), + ) + .layer(middleware::from_fn_with_state(limiters, rate_limit)) + .layer(middleware::from_fn_with_state(trusted, resolve_client_ip)) + } + + async fn send( + app: &Router, + method: Method, + path: &str, + peer: &str, + xff: Option<&str>, + ) -> Response { + let mut req = Request::builder().method(method).uri(path); + if let Some(xff) = xff { + req = req.header("x-forwarded-for", xff); + } + let mut req = req.body(Body::empty()).unwrap(); + let peer: SocketAddr = format!("{peer}:40000").parse().unwrap(); + req.extensions_mut().insert(ConnectInfo(peer)); + app.clone().oneshot(req).await.unwrap() + } + + async fn body_text(response: Response) -> String { + let bytes = axum::body::to_bytes(response.into_body(), 1 << 16) + .await + .unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() + } + + fn tight() -> RateLimitConfig { + RateLimitConfig { + auth: nz(2), + api: nz(3), + sdk: nz(3), + sse_connect: nz(1), + ingest: nz(3), + } + } + + #[tokio::test] + async fn spoofed_header_cannot_escape_the_budget() { + let app = app("", tight()); + // A direct client rotating X-Forwarded-For still spends one budget. + for i in 0..2 { + let r = send( + &app, + Method::POST, + "/api/auth/login", + "203.0.113.5", + Some(&format!("198.51.100.{i}")), + ) + .await; + assert_eq!(r.status(), StatusCode::OK); + } + let r = send( + &app, + Method::POST, + "/api/auth/login", + "203.0.113.5", + Some("198.51.100.99"), + ) + .await; + assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS); + // And the header does not change who the request is attributed to. + let r = send( + &app, + Method::GET, + "/whoami", + "203.0.113.5", + Some("198.51.100.1"), + ) + .await; + assert_eq!(body_text(r).await, "203.0.113.5"); + } + + #[tokio::test] + async fn trusted_proxy_clients_get_separate_budgets() { + let app = app("10.0.0.0/8", tight()); + for client in ["198.51.100.1", "198.51.100.2"] { + for _ in 0..2 { + let r = send( + &app, + Method::POST, + "/api/auth/login", + "10.0.0.7", + Some(client), + ) + .await; + assert_eq!(r.status(), StatusCode::OK, "{client}"); + } + let r = send( + &app, + Method::POST, + "/api/auth/login", + "10.0.0.7", + Some(client), + ) + .await; + assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS, "{client}"); + } + let r = send( + &app, + Method::GET, + "/whoami", + "10.0.0.7", + Some("1.1.1.1, 198.51.100.3, 10.0.0.9"), + ) + .await; + assert_eq!(body_text(r).await, "198.51.100.3", "multi-hop chain"); + } + + #[tokio::test] + async fn untrusted_proxy_is_limited_as_itself() { + let app = app("10.0.0.0/8", tight()); + for client in ["198.51.100.1", "198.51.100.2"] { + let r = send( + &app, + Method::POST, + "/api/auth/login", + "192.0.2.50", + Some(client), + ) + .await; + assert_eq!(r.status(), StatusCode::OK); + } + let r = send( + &app, + Method::POST, + "/api/auth/login", + "192.0.2.50", + Some("198.51.100.3"), + ) + .await; + assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn invalid_forwarded_header_falls_back_to_the_proxy() { + let app = app("10.0.0.0/8", tight()); + for bad in ["garbage", "", "198.51.100.1, nope"] { + let r = send(&app, Method::GET, "/whoami", "10.0.0.7", Some(bad)).await; + assert_eq!(body_text(r).await, "10.0.0.7", "{bad:?}"); + } + } + + #[tokio::test] + async fn refusal_is_a_429_with_retry_after_and_json_body() { + let app = app("", tight()); + for _ in 0..3 { + send(&app, Method::GET, "/api/projects", "203.0.113.5", None).await; + } + let r = send(&app, Method::GET, "/api/projects", "203.0.113.5", None).await; + assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS); + let retry: u64 = r.headers()[header::RETRY_AFTER] + .to_str() + .unwrap() + .parse() + .unwrap(); + assert!((1..=61).contains(&retry), "retry-after {retry}"); + let body: serde_json::Value = serde_json::from_str(&body_text(r).await).unwrap(); + assert_eq!(body["retry_after_seconds"], retry); + assert!(body["error"].is_string()); + } + + #[tokio::test] + async fn classes_do_not_share_budgets_through_the_middleware() { + let app = app("", tight()); + for _ in 0..3 { + send(&app, Method::GET, "/api/projects", "203.0.113.5", None).await; + } + assert_eq!( + send(&app, Method::GET, "/api/projects", "203.0.113.5", None) + .await + .status(), + StatusCode::TOO_MANY_REQUESTS + ); + assert_eq!( + send(&app, Method::POST, "/api/auth/login", "203.0.113.5", None) + .await + .status(), + StatusCode::OK + ); + assert_eq!( + send(&app, Method::GET, "/stream", "203.0.113.5", None) + .await + .status(), + StatusCode::OK + ); + assert_eq!( + send(&app, Method::GET, "/stream", "203.0.113.5", None) + .await + .status(), + StatusCode::TOO_MANY_REQUESTS, + "one SSE connection per minute in this config" + ); + } + + #[tokio::test] + async fn health_checks_are_never_limited() { + let app = app("", tight()); + for _ in 0..3 { + send(&app, Method::GET, "/api/projects", "203.0.113.5", None).await; + } + for _ in 0..500 { + assert_eq!( + send(&app, Method::GET, "/health", "203.0.113.5", None) + .await + .status(), + StatusCode::OK + ); } } } diff --git a/server/src/scheduler.rs b/server/src/scheduler.rs index 9ed7c50..cdd6140 100644 --- a/server/src/scheduler.rs +++ b/server/src/scheduler.rs @@ -161,7 +161,6 @@ mod tests { store: Arc::new(FlagStore::new()), flag_tx: broadcast::channel(256).0, sdk_keys: Arc::new(RwLock::new(vec![])), - rate_limiter: crate::rate_limit::new_rate_limiter(), session_key: Key::generate(), connected_clients: Arc::new(DashMap::new()), webhook_client: reqwest::Client::new(), diff --git a/server/src/state.rs b/server/src/state.rs index 281181c..f66224f 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -1,4 +1,3 @@ -use crate::rate_limit::IpRateLimiter; use axum::extract::FromRef; use axum_extra::extract::cookie::Key; use checkgate_core::store::FlagStore; @@ -51,8 +50,6 @@ pub struct AppState { pub flag_tx: broadcast::Sender, /// Local key-management cache. Authentication always checks PostgreSQL. pub sdk_keys: Arc>>, - /// Per-IP rate limiter applied to all API routes. - pub rate_limiter: IpRateLimiter, /// Encryption key for `PrivateCookieJar`. Derived from `SESSION_SECRET` env var. pub session_key: Key, /// Live SSE connections keyed by connection_id. Per-instance only. diff --git a/server/src/stream.rs b/server/src/stream.rs index cbdbbd3..4cf5713 100644 --- a/server/src/stream.rs +++ b/server/src/stream.rs @@ -1,10 +1,11 @@ use crate::auth::{AuthContext, get_session_claims}; +use crate::client_ip::ClientIp; use crate::state::SdkKeyEntry; use crate::state::{AppState, ConnectedClient}; use axum::http::{HeaderName, HeaderValue}; use axum::{ Json, - extract::{ConnectInfo, State}, + extract::State, http::StatusCode, response::sse::{Event, KeepAlive, Sse}, }; @@ -13,7 +14,7 @@ use futures_util::stream::Stream; use rand::RngExt as _; use sqlx::Row; use std::collections::HashSet; -use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration}; +use std::{convert::Infallible, sync::Arc, time::Duration}; use tokio::sync::broadcast; use tracing::{info, warn}; @@ -41,13 +42,11 @@ fn random_connection_id() -> String { reason = "seconds since the Unix epoch; wrapping i64 would require a clock set beyond the year 292 billion" )] pub async fn sse_handler( - ConnectInfo(addr): ConnectInfo, + ClientIp(client_ip): ClientIp, State(state): State, ctx: AuthContext, req: axum::http::Request, ) -> Result>>, StatusCode> { - let client_ip = addr.ip(); - let sdk_key = req.extensions().get::(); let environment_id = sdk_key.and_then(|key| key.environment_id.clone()); let sdk_key_name = sdk_key.map(|key| key.name.clone()); diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs new file mode 100644 index 0000000..6de0b19 --- /dev/null +++ b/server/tests/common/mod.rs @@ -0,0 +1,327 @@ +//! Shared harness for server integration tests that spawn the real binary. +//! +//! Unlike `integration.rs` and `e2e_sse.rs`, which reset the `public` schema of +//! the database they are given, these tests each create a database of their +//! own next to it, so tests in one binary can run in parallel. +//! +//! Requires `CHECKGATE_TEST_DATABASE_URL` and `CHECKGATE_TEST_REDIS_URL`; the +//! database role must be allowed to `CREATE DATABASE`. Without them the tests +//! skip, unless `CHECKGATE_REQUIRE_INTEGRATION` is set. +#![allow( + dead_code, + reason = "each test binary compiles this module and uses a different subset of it" +)] + +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +pub const SETUP_TOKEN: &str = "test-setup-token-0123456789abcdef"; +pub const ADMIN_EMAIL: &str = "admin@acme.test"; +pub const ADMIN_PASSWORD: &str = "supersecret1"; +const SESSION_SECRET: &str = "test-secret-0123456789-0123456789-0123456789-0123456789"; + +pub struct TestEnv { + pub admin_db_url: String, + pub redis_url: String, +} + +pub fn test_env() -> Option { + match ( + std::env::var("CHECKGATE_TEST_DATABASE_URL"), + std::env::var("CHECKGATE_TEST_REDIS_URL"), + ) { + (Ok(admin_db_url), Ok(redis_url)) => Some(TestEnv { + admin_db_url, + redis_url, + }), + _ => { + assert!( + std::env::var("CHECKGATE_REQUIRE_INTEGRATION").is_err(), + "CHECKGATE_REQUIRE_INTEGRATION is set but CHECKGATE_TEST_DATABASE_URL / \ + CHECKGATE_TEST_REDIS_URL are not" + ); + eprintln!( + "SKIP: set CHECKGATE_TEST_DATABASE_URL and CHECKGATE_TEST_REDIS_URL to run server integration tests" + ); + None + } + } +} + +impl TestEnv { + /// Creates (or recreates) an empty database named `name` and returns its URL. + pub async fn fresh_database(&self, name: &str) -> String { + let pool = sqlx::PgPool::connect(&self.admin_db_url) + .await + .expect("connect to test database"); + sqlx::query(&format!("DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)")) + .execute(&pool) + .await + .expect("drop test database"); + sqlx::query(&format!("CREATE DATABASE \"{name}\"")) + .execute(&pool) + .await + .expect("create test database (the role needs CREATEDB)"); + pool.close().await; + let mut url = reqwest::Url::parse(&self.admin_db_url).expect("database URL"); + url.set_path(&format!("/{name}")); + url.to_string() + } +} + +/// Kills the spawned server when dropped (including on panic). +pub struct Server { + pub base: String, + /// Working directory; a generated setup token file lands here. + pub dir: PathBuf, + child: Child, +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +pub fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +impl Server { + /// Spawns the server binary and waits until `/health` answers. + /// `extra_env` overrides the defaults; a value of `None` removes a variable. + pub async fn spawn(db_url: &str, redis_url: &str, extra_env: &[(&str, Option<&str>)]) -> Self { + let port = free_port(); + let dir = std::env::temp_dir().join(format!("checkgate-test-{port}")); + // A directory left by an earlier run on the same port is stale. + let _ = std::fs::remove_dir_all(&dir); + Self::spawn_in(&dir, port, db_url, redis_url, extra_env).await + } + + /// Like [`Server::spawn`], reusing `dir` (to restart an instance) and + /// appending its stdout — the JSON log — to `dir/server.log`. + pub async fn spawn_in( + dir: &Path, + port: u16, + db_url: &str, + redis_url: &str, + extra_env: &[(&str, Option<&str>)], + ) -> Self { + let child = command(dir, port, db_url, redis_url, extra_env) + .spawn() + .expect("spawn server binary"); + let server = Server { + base: format!("http://127.0.0.1:{port}"), + dir: dir.to_path_buf(), + child, + }; + server.wait_healthy().await; + server + } + + /// Restarts the process in place: same port, directory and environment. + pub async fn restart( + self, + db_url: &str, + redis_url: &str, + extra_env: &[(&str, Option<&str>)], + ) -> Self { + let port: u16 = self.base.rsplit(':').next().unwrap().parse().unwrap(); + let dir = self.dir.clone(); + drop(self); + Self::spawn_in(&dir, port, db_url, redis_url, extra_env).await + } + + pub fn log(&self) -> String { + std::fs::read_to_string(self.dir.join("server.log")).unwrap_or_default() + } + + pub async fn wait_healthy(&self) { + let client = reqwest::Client::new(); + for _ in 0..120 { + if let Ok(resp) = client.get(format!("{}/health", self.base)).send().await + && resp.status().is_success() + { + return; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + panic!("server did not become healthy at {}", self.base); + } + + pub fn url(&self, path: &str) -> String { + format!("{}{}", self.base, path) + } +} + +fn command( + dir: &Path, + port: u16, + db_url: &str, + redis_url: &str, + extra_env: &[(&str, Option<&str>)], +) -> Command { + std::fs::create_dir_all(dir).unwrap(); + let log = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("server.log")) + .unwrap(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_server")); + cmd.env("DATABASE_URL", db_url) + .env("REDIS_URL", redis_url) + .env("PORT", port.to_string()) + .env("SESSION_SECRET", SESSION_SECRET) + .env("COOKIE_SECURE", "false") + .env("SETUP_TOKEN", SETUP_TOKEN) + .env("DB_MAX_CONNECTIONS", "5") + .env("PUBLIC_DIR", dir) + .env("RUST_LOG", "info") + .env_remove("TRUSTED_PROXIES") + .env_remove("SETUP_TOKEN_FILE") + .current_dir(dir) + .stdout(Stdio::from(log)) + .stderr(Stdio::inherit()); + for (key, value) in extra_env { + match value { + Some(value) => cmd.env(key, value), + None => cmd.env_remove(key), + }; + } + cmd +} + +/// Runs the server with a configuration it must refuse, and returns its log. +pub async fn expect_startup_failure( + db_url: &str, + redis_url: &str, + extra_env: &[(&str, Option<&str>)], +) -> String { + let port = free_port(); + let dir = std::env::temp_dir().join(format!("checkgate-test-{port}")); + let _ = std::fs::remove_dir_all(&dir); + let mut child = command(&dir, port, db_url, redis_url, extra_env) + .spawn() + .expect("spawn server binary"); + for _ in 0..240 { + if let Some(status) = child.try_wait().unwrap() { + assert!(!status.success(), "server exited successfully"); + return std::fs::read_to_string(dir.join("server.log")).unwrap_or_default(); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + let _ = child.kill(); + panic!("server kept running with an invalid configuration"); +} + +pub fn session_client() -> reqwest::Client { + reqwest::Client::builder() + .cookie_store(true) + .build() + .unwrap() +} + +pub fn setup_body(email: &str, password: &str) -> Value { + json!({ + "workspace_name": "Acme", + "project_name": "Web", + "name": "Admin", + "email": email, + "password": password, + }) +} + +/// Sends `POST /api/setup/complete` with the given token (if any). +pub async fn post_setup( + client: &reqwest::Client, + base: &str, + token: Option<&str>, + body: &Value, +) -> reqwest::Response { + let mut req = client + .post(format!("{base}/api/setup/complete")) + .header("X-Checkgate-Request", "true") + .json(body); + if let Some(token) = token { + req = req.header("X-Checkgate-Setup-Token", token); + } + req.send().await.unwrap() +} + +/// Completes first-run setup. Returns a logged-in admin client, the seeded +/// SDK key and its (Production) environment id. +pub async fn complete_setup(server: &Server) -> (reqwest::Client, String, String) { + let key: Value = reqwest::Client::new() + .get(server.url("/api/setup/key")) + .header("X-Checkgate-Setup-Token", SETUP_TOKEN) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let admin = session_client(); + let r = post_setup( + &admin, + &server.base, + Some(SETUP_TOKEN), + &setup_body(ADMIN_EMAIL, ADMIN_PASSWORD), + ) + .await; + assert_eq!(r.status(), 200, "setup/complete"); + ( + admin, + key["key"].as_str().unwrap().to_string(), + key["environment_id"].as_str().unwrap().to_string(), + ) +} + +/// Sends a JSON request, asserts the status and returns the parsed body. +pub async fn request( + client: &reqwest::Client, + method: &str, + url: &str, + body: Option, + expected: u16, +) -> Value { + let mut req = client + .request(method.parse().unwrap(), url) + .header("X-Checkgate-Request", "true"); + if let Some(body) = body { + req = req.json(&body); + } + let response = req.send().await.unwrap(); + let status = response.status().as_u16(); + let text = response.text().await.unwrap(); + assert_eq!(status, expected, "{method} {url}: {text}"); + serde_json::from_str(&text).unwrap_or(Value::Null) +} + +pub fn bearer_client(token: &str) -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("Authorization", format!("Bearer {token}").parse().unwrap()); + reqwest::Client::builder() + .default_headers(headers) + .build() + .unwrap() +} + +pub async fn login(server: &Server, email: &str, password: &str) -> reqwest::Client { + let client = session_client(); + request( + &client, + "POST", + &server.url("/api/auth/login"), + Some(json!({ "email": email, "password": password })), + 200, + ) + .await; + client +} diff --git a/server/tests/integration.rs b/server/tests/integration.rs index 653a34b..75ede1c 100644 --- a/server/tests/integration.rs +++ b/server/tests/integration.rs @@ -138,6 +138,10 @@ async fn full_api_flow() { "test-secret-0123456789-0123456789-0123456789-0123456789-0123456789", ) .env("COOKIE_SECURE", "false") + // The test client stands in for a reverse proxy: some scenarios send + // X-Forwarded-For to get a rate-limit budget of their own, which the + // server honours only from a trusted peer. + .env("TRUSTED_PROXIES", "127.0.0.1") .env("PUBLIC_DIR", public_dir.to_str().unwrap()) .env("RUST_LOG", "warn") .stdout(Stdio::null()) diff --git a/server/tests/rate_limit.rs b/server/tests/rate_limit.rs new file mode 100644 index 0000000..00f5259 --- /dev/null +++ b/server/tests/rate_limit.rs @@ -0,0 +1,271 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::arithmetic_side_effects, + reason = "test code: a panic here is an assertion failure, not a DoS" +)] +//! Client identity and per-class rate limits, through the real server binary. +//! The tests connect from 127.0.0.1, so whether that address is listed in +//! `TRUSTED_PROXIES` decides whether `X-Forwarded-For` is believed. + +mod common; + +use common::*; +use futures_util::StreamExt as _; +use serde_json::{Value, json}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +/// A failed login. Each uses a new email so the per-account lockout (5 +/// failures) never answers before the rate limiter does. +async fn login_attempt(server: &Server, xff: Option<&str>) -> reqwest::Response { + static ATTEMPT: AtomicUsize = AtomicUsize::new(0); + let n = ATTEMPT.fetch_add(1, Ordering::Relaxed); + let mut req = reqwest::Client::new() + .post(server.url("/api/auth/login")) + .header("X-Checkgate-Request", "true") + .json(&json!({ "email": format!("nobody-{n}@acme.test"), "password": "wrong-password" })); + if let Some(xff) = xff { + req = req.header("X-Forwarded-For", xff); + } + req.send().await.unwrap() +} + +async fn lockout_ips(db_url: &str) -> Vec { + let pool = sqlx::PgPool::connect(db_url).await.unwrap(); + let ips = sqlx::query_scalar("SELECT DISTINCT ip FROM login_attempts ORDER BY ip") + .fetch_all(&pool) + .await + .unwrap(); + pool.close().await; + ips +} + +#[tokio::test] +async fn spoofed_forwarded_for_from_a_direct_client_is_ignored() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_rl_direct").await; + let server = Server::spawn( + &db, + &env.redis_url, + &[("RATE_LIMIT_AUTH_PER_MINUTE", Some("3"))], + ) + .await; + + // Rotating the header does not buy a fresh budget. + for i in 0..3 { + let r = login_attempt(&server, Some(&format!("198.51.100.{i}"))).await; + assert_eq!(r.status(), 401, "attempt {i} reaches the handler"); + } + let r = login_attempt(&server, Some("198.51.100.200")).await; + assert_eq!(r.status(), 429); + let retry_after: u64 = r.headers()["retry-after"] + .to_str() + .unwrap() + .parse() + .unwrap(); + assert!(retry_after >= 1); + let body: Value = r.json().await.unwrap(); + assert_eq!(body["retry_after_seconds"], retry_after); + assert!(body["error"].is_string(), "dashboard reads `error`"); + + // Nor does it change who the attempts are recorded against. + assert_eq!(lockout_ips(&db).await, vec!["127.0.0.1".to_string()]); + + // Other budgets are independent of the exhausted one... + let r = reqwest::Client::new() + .get(server.url("/api/auth/workspace")) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200, "API budget unaffected"); + // ...and health checks are never limited. + for _ in 0..100 { + let r = reqwest::get(server.url("/health")).await.unwrap(); + assert_eq!(r.status(), 200); + } +} + +#[tokio::test] +async fn trusted_proxy_forwards_distinct_client_identities() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_rl_trusted").await; + let server = Server::spawn( + &db, + &env.redis_url, + &[ + ("RATE_LIMIT_AUTH_PER_MINUTE", Some("2")), + ("TRUSTED_PROXIES", Some("127.0.0.1/32")), + ], + ) + .await; + + for client in ["198.51.100.1", "198.51.100.2"] { + for _ in 0..2 { + assert_eq!(login_attempt(&server, Some(client)).await.status(), 401); + } + assert_eq!( + login_attempt(&server, Some(client)).await.status(), + 429, + "{client} has its own budget" + ); + } + // Multi-hop: the client-supplied left part is ignored; the right-most + // untrusted hop is the client. + assert_eq!( + login_attempt(&server, Some("198.51.100.1, 203.0.113.9, 127.0.0.1")) + .await + .status(), + 401, + "203.0.113.9 is a new client" + ); + // Garbage in the header falls back to the proxy's own budget. + assert_eq!( + login_attempt(&server, Some("not-an-ip")).await.status(), + 401 + ); + assert_eq!(login_attempt(&server, None).await.status(), 401); + assert_eq!(login_attempt(&server, Some("")).await.status(), 429); + + assert_eq!( + lockout_ips(&db).await, + vec!["127.0.0.1", "198.51.100.1", "198.51.100.2", "203.0.113.9"], + "lockout records the resolved client" + ); +} + +#[tokio::test] +async fn untrusted_proxy_cannot_forward_identities() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_rl_untrusted").await; + // 127.0.0.1 is not in the trusted range, so it is treated as the client. + let server = Server::spawn( + &db, + &env.redis_url, + &[ + ("RATE_LIMIT_AUTH_PER_MINUTE", Some("2")), + ("TRUSTED_PROXIES", Some("10.0.0.0/8")), + ], + ) + .await; + assert_eq!( + login_attempt(&server, Some("198.51.100.1")).await.status(), + 401 + ); + assert_eq!( + login_attempt(&server, Some("198.51.100.2")).await.status(), + 401 + ); + assert_eq!( + login_attempt(&server, Some("198.51.100.3")).await.status(), + 429 + ); +} + +#[tokio::test] +async fn invalid_rate_limit_or_proxy_configuration_is_refused() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_rl_config").await; + let log = expect_startup_failure( + &db, + &env.redis_url, + &[("TRUSTED_PROXIES", Some("10.0.0.0/8, proxy.internal"))], + ) + .await; + assert!(log.contains("TRUSTED_PROXIES"), "{log}"); + let log = expect_startup_failure( + &db, + &env.redis_url, + &[("RATE_LIMIT_SSE_PER_MINUTE", Some("plenty"))], + ) + .await; + assert!(log.contains("RATE_LIMIT_SSE_PER_MINUTE"), "{log}"); +} + +/// Reads SSE frames until one with `event: update` arrives for `flag_key`. +async fn next_update_for>( + stream: &mut (impl futures_util::Stream> + Unpin), + buf: &mut String, + flag_key: &str, +) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + while let Some(end) = buf.find("\n\n") { + let frame: String = buf.drain(..end + 2).collect(); + if frame.contains("event: update") && frame.contains(&format!("\"key\":\"{flag_key}\"")) + { + return; + } + } + let chunk = tokio::time::timeout_at(deadline, stream.next()) + .await + .expect("update arrives in time") + .expect("stream open") + .unwrap(); + buf.push_str(&String::from_utf8_lossy(chunk.as_ref())); + } +} + +#[tokio::test] +async fn sse_limits_connections_not_delivered_events() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_rl_sse").await; + let server = Server::spawn( + &db, + &env.redis_url, + &[("RATE_LIMIT_SSE_PER_MINUTE", Some("2"))], + ) + .await; + let (admin, sdk_key, env_id) = complete_setup(&server).await; + let sdk = bearer_client(&sdk_key); + + let first = sdk.get(server.url("/stream")).send().await.unwrap(); + assert_eq!(first.status(), 200); + let mut events = first.bytes_stream(); + let mut buf = String::new(); + + // Far more events than the connection budget flow over the open stream. + let flags = server.url(&format!("/api/environments/{env_id}/flags")); + for i in 0..10 { + let key = format!("sse-flag-{i}"); + request( + &admin, + "POST", + &flags, + Some(json!({ "key": key, "is_enabled": true, "flag_type": "boolean", "rules": [] })), + 200, + ) + .await; + next_update_for(&mut events, &mut buf, &key).await; + } + + // Opening streams is what is counted. + assert_eq!( + sdk.get(server.url("/stream")) + .send() + .await + .unwrap() + .status(), + 200 + ); + assert_eq!( + sdk.get(server.url("/stream")) + .send() + .await + .unwrap() + .status(), + 429, + "third connection in a minute" + ); + // Snapshot polling has its own budget. + assert_eq!( + sdk.get(server.url("/flags/snapshot")) + .send() + .await + .unwrap() + .status(), + 200 + ); +} From 1e64f0b7ae73fe1062ac381ef86d2011816f1d29 Mon Sep 17 00:00:00 2001 From: Dennis Paler Date: Thu, 29 Oct 2026 13:41:45 +0800 Subject: [PATCH 04/12] fix(server): make first-run setup atomic and require a one-time setup token The setup-complete check ran before the transaction that created the admin, so concurrent requests could all pass it, and the admin upsert (ON CONFLICT DO UPDATE) let a later racer overwrite the winner's password and role. Anyone who reached a fresh installation first could also claim it. - setup_complete takes pg_advisory_xact_lock and re-reads setup_complete under the lock, then creates the admin, workspace name, project name and completion flag in one transaction. Exactly one request wins, across any number of instances; the rest get 404. Any failure rolls everything back. - The admin upsert only claims a pre-password-auth row (password_hash IS NULL); an account that has a password is never overwritten (409). - /api/setup/key and /api/setup/complete require X-Checkgate-Setup-Token (header only). The token is SETUP_TOKEN (min. 16 chars, else startup fails) or, when unset, a random per-instance token written to SETUP_TOKEN_FILE (0600) with only its SHA-256 stored in settings, so every instance accepts it. The token is never logged. Completing setup revokes all tokens in the same transaction; stale token files are removed on the next start. - Dashboard wizard asks for the token on its first step and validates it before continuing; the token stays in memory only. - The image writes its token to /var/lib/checkgate/setup-token. Tests: tests/setup_ownership.rs (2 and 12 concurrent requests, concurrent requests on two instances, missing/wrong/query-string tokens, repeated setup, login after setup, injected failure rolls back and retry succeeds, generated tokens shared across instances and absent from logs, password accounts not overwritten, short token refused). The existing suites, the Node SDK e2e and the screenshot fixture pass the token. --- .github/workflows/ci.yml | 7 +- .gitignore | 3 + Dockerfile | 4 +- dashboard/scripts/capture-screenshots.mjs | 9 +- dashboard/src/context/AuthContext.tsx | 15 +- dashboard/src/pages/Setup.test.tsx | 77 +++++ dashboard/src/pages/Setup.tsx | 98 +++++- docker-compose.screenshots.yml | 1 + docker-compose.yml | 3 + docs/api-reference.md | 90 +++++- docs/self-hosting.md | 71 +++++ sdks/nodejs/e2e/e2e.cjs | 8 +- server/src/api/keys.rs | 11 +- server/src/api/session.rs | 71 +++-- server/src/main.rs | 15 + server/src/scheduler.rs | 1 + server/src/setup.rs | 198 ++++++++++++ server/src/state.rs | 3 + server/tests/e2e_sse.rs | 6 + server/tests/integration.rs | 7 + server/tests/setup_ownership.rs | 371 ++++++++++++++++++++++ 21 files changed, 1022 insertions(+), 47 deletions(-) create mode 100644 dashboard/src/pages/Setup.test.tsx create mode 100644 server/src/setup.rs create mode 100644 server/tests/setup_ownership.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1cb65a..a8261e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,6 +144,8 @@ jobs: run: | cargo test -p server --test integration -- --nocapture cargo test -p server --test e2e_sse -- --nocapture + cargo test -p server --test rate_limit -- --nocapture + cargo test -p server --test setup_ownership -- --nocapture # --------------------------------------------------------------------------- # Native SDK e2e — build the real @checkgate/node addon and drive it over a @@ -203,12 +205,15 @@ jobs: REDIS_URL: redis://localhost:6379 run: | PORT=3111 + SETUP_TOKEN="ci-setup-token-$(openssl rand -hex 16)" SESSION_SECRET=ci-secret-0123456789-0123456789-0123456789-0123456789 \ + SETUP_TOKEN="$SETUP_TOKEN" \ COOKIE_SECURE=false PORT=$PORT PUBLIC_DIR="$(mktemp -d)" RUST_LOG=warn \ ./target/debug/server > server.log 2>&1 & SRV=$! for i in $(seq 1 60); do curl -sf "http://127.0.0.1:$PORT/health" >/dev/null && break; sleep 1; done - CHECKGATE_URL="http://127.0.0.1:$PORT" node sdks/nodejs/e2e/e2e.cjs + CHECKGATE_URL="http://127.0.0.1:$PORT" CHECKGATE_SETUP_TOKEN="$SETUP_TOKEN" \ + node sdks/nodejs/e2e/e2e.cjs RC=$? kill $SRV 2>/dev/null || true if [ $RC -ne 0 ]; then echo "=== server log ==="; cat server.log; fi diff --git a/.gitignore b/.gitignore index f45c362..b917ea7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ docs/bun.lock .env .env.* !.env.example + +# One-time setup token written by a local server that has not been set up. +checkgate-setup-token diff --git a/Dockerfile b/Dockerfile index 798deb6..13da9f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -77,7 +77,8 @@ COPY --from=dashboard-builder /app/server/public ./public COPY docker/entrypoint-full.sh /entrypoint.sh RUN sed -i 's/\r//' /entrypoint.sh && chmod +x /entrypoint.sh && \ - mkdir -p /var/lib/postgresql/data /var/log && \ + mkdir -p /var/lib/postgresql/data /var/log /var/lib/checkgate && \ + chmod 700 /var/lib/checkgate && \ chown -R postgres:postgres /var/lib/postgresql && \ touch /var/log/postgresql.log /var/log/redis.log && \ chown postgres:postgres /var/log/postgresql.log /var/log/redis.log @@ -89,6 +90,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ CMD curl -f http://localhost:3000/health || exit 1 ENV PUBLIC_DIR=/app/public \ + SETUP_TOKEN_FILE=/var/lib/checkgate/setup-token \ PGDATA=/var/lib/postgresql/data \ POSTGRES_USER=checkgate \ POSTGRES_PASSWORD=checkgate \ diff --git a/dashboard/scripts/capture-screenshots.mjs b/dashboard/scripts/capture-screenshots.mjs index 0a7b425..64c3c9f 100644 --- a/dashboard/scripts/capture-screenshots.mjs +++ b/dashboard/scripts/capture-screenshots.mjs @@ -8,6 +8,7 @@ const baseURL = process.env.CHECKGATE_SCREENSHOT_BASE_URL ?? 'http://127.0.0.1:3 const output = (process.env.CHECKGATE_SCREENSHOT_OUTPUT ?? fileURLToPath(new URL('../../assets/screenshots/', import.meta.url))).replace(/\/$/, '') const staging = `${output}.capture` +const setupToken = process.env.CHECKGATE_SCREENSHOT_SETUP_TOKEN ?? 'checkgate-screenshot-demo-setup-token' const workspaceName = 'Vantage Robotics' const projectName = 'Vantage Mobile' const password = 'checkgate-screenshot-demo-password' @@ -44,10 +45,10 @@ try { extraHTTPHeaders: { 'X-Checkgate-Request': 'true' }, }) - async function api(path, method = 'GET', data, request = context.request) { - // Respect the server's 60-request-per-minute API quota while seeding. + async function api(path, method = 'GET', data, request = context.request, headers = {}) { + // Stay well inside the server's per-client API quota while seeding. await new Promise(resolve => setTimeout(resolve, 1050)) - const response = await request.fetch(path, { method, data }) + const response = await request.fetch(path, { method, data, headers }) if (!response.ok()) throw new Error(`${method} ${path}: ${response.status()} ${await response.text()}`) return response.status() === 204 ? undefined : response.json() } @@ -56,7 +57,7 @@ try { if (!workspace.is_setup_complete) { await api('/api/setup/complete', 'POST', { workspace_name: workspaceName, project_name: projectName, ...admin, - }) + }, context.request, { 'X-Checkgate-Setup-Token': setupToken }) } else { assert.equal(workspace.workspace_name, workspaceName, 'Expected the disposable screenshot workspace') await api('/api/auth/login', 'POST', admin) diff --git a/dashboard/src/context/AuthContext.tsx b/dashboard/src/context/AuthContext.tsx index d2f773c..07e8a6b 100644 --- a/dashboard/src/context/AuthContext.tsx +++ b/dashboard/src/context/AuthContext.tsx @@ -41,6 +41,7 @@ interface AuthContextValue { name: string, email: string, password: string, + setupToken: string, ) => Promise<{ ok: boolean; error?: string }> } @@ -167,12 +168,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []) const completeSetup = useCallback( - async (workspaceName: string, projectName: string, name: string, email: string, password: string) => { + async (workspaceName: string, projectName: string, name: string, email: string, password: string, setupToken: string) => { try { const res = await fetch('/api/setup/complete', { method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json', 'X-Checkgate-Request': 'true' }, + headers: { + 'Content-Type': 'application/json', + 'X-Checkgate-Request': 'true', + 'X-Checkgate-Setup-Token': setupToken, + }, body: JSON.stringify({ workspace_name: workspaceName.trim(), project_name: projectName.trim() || 'My App', @@ -185,6 +190,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (res.status === 404) { return { ok: false, error: 'Setup already complete. Please log in.' } } + if (res.status === 401) { + return { ok: false, error: 'The setup token is not valid. Go back to the first step and re-enter it.' } + } + if (res.status === 409) { + return { ok: false, error: 'An account with this email already exists. Use a different email.' } + } if (res.status === 422) { return { ok: false, error: 'Password must be at least 8 characters.' } } diff --git a/dashboard/src/pages/Setup.test.tsx b/dashboard/src/pages/Setup.test.tsx new file mode 100644 index 0000000..b8762ce --- /dev/null +++ b/dashboard/src/pages/Setup.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import Setup from './Setup' + +const completeSetup = vi.fn() + +vi.mock('../context/AuthContext', () => ({ + useAuth: () => ({ completeSetup }), +})) + +const fetchMock = vi.fn() + +function renderSetup() { + return render( + + + , + ) +} + +function jsonResponse(status: number, body: unknown = {}) { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('Setup token step', () => { + it('sends the token in a header, never the URL, and advances when it is valid', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { key: 'sk_live_abc' })) + const user = userEvent.setup() + renderSetup() + + const start = screen.getByRole('button', { name: /get started/i }) + expect(start).toBeDisabled() + await user.type(screen.getByLabelText(/setup token/i), 'tok-0123456789abcdef') + await user.click(start) + + expect(await screen.findByText(/name your workspace/i)).toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/setup/key') + expect(url).not.toContain('tok-') + expect(init.headers).toEqual({ 'X-Checkgate-Setup-Token': 'tok-0123456789abcdef' }) + }) + + it('stays on the first step and explains an invalid token', async () => { + fetchMock.mockResolvedValue(jsonResponse(401)) + const user = userEvent.setup() + renderSetup() + + await user.type(screen.getByLabelText(/setup token/i), 'wrong') + await user.click(screen.getByRole('button', { name: /get started/i })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/not valid/i) + expect(screen.queryByText(/name your workspace/i)).not.toBeInTheDocument() + }) + + it('points to sign-in when setup is already complete', async () => { + fetchMock.mockResolvedValue(jsonResponse(404)) + const user = userEvent.setup() + renderSetup() + + await user.type(screen.getByLabelText(/setup token/i), 'whatever') + await user.click(screen.getByRole('button', { name: /get started/i })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/already complete/i) + }) +}) diff --git a/dashboard/src/pages/Setup.tsx b/dashboard/src/pages/Setup.tsx index c9897d7..ea4f8eb 100644 --- a/dashboard/src/pages/Setup.tsx +++ b/dashboard/src/pages/Setup.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { useNavigate, Link } from 'react-router-dom' -import { Flag, ArrowRight, CheckCircle2, Shield, Zap, Globe, Copy, Check, AlertCircle, Eye, EyeOff, Building2 } from 'lucide-react' +import { Flag, ArrowRight, CheckCircle2, Shield, Zap, Globe, Copy, Check, AlertCircle, Eye, EyeOff, Building2, KeyRound } from 'lucide-react' import { useAuth } from '../context/AuthContext' const STEPS = ['Welcome', 'Workspace', 'Your account', 'SDK key', 'Done'] as const @@ -16,6 +16,12 @@ export default function Setup() { const navigate = useNavigate() const [step, setStep] = useState(0) + // Step 0: one-time setup token. Kept in memory only — never stored or put + // in a URL. The server prints where to find it on first boot. + const [setupToken, setSetupToken] = useState('') + const [tokenChecking, setTokenChecking] = useState(false) + const [tokenError, setTokenError] = useState('') + // Step 1: Workspace + first project const [workspaceName, setWorkspaceName] = useState('') const [projectName, setProjectName] = useState('') @@ -38,12 +44,50 @@ export default function Setup() { const [error, setError] = useState('') const [loading, setLoading] = useState(false) - // Fetch the generated key when the user reaches step 3. + function fetchSetupKey(token: string): Promise { + return fetch('/api/setup/key', { + credentials: 'same-origin', + headers: { 'X-Checkgate-Setup-Token': token }, + }) + } + + // Verify the token up front (the SDK key endpoint requires it) so a wrong + // token is caught before the user fills in the rest of the wizard. + async function handleVerifyToken() { + const token = setupToken.trim() + if (!token) return + setTokenError('') + setTokenChecking(true) + try { + const res = await fetchSetupKey(token) + if (res.status === 401) { + setTokenError('That setup token is not valid.') + return + } + if (res.status === 404) { + setTokenError('Setup is already complete. Please sign in instead.') + return + } + if (!res.ok) { + setTokenError(`Server returned ${res.status}.`) + return + } + const data = await res.json() as { key: string } + setSdkKey(data.key) + setStep(1) + } catch { + setTokenError('Could not reach the server.') + } finally { + setTokenChecking(false) + } + } + + // Fetch the generated key when the user reaches step 3, if not already loaded. useEffect(() => { if (step !== 3 || sdkKey) return setKeyLoading(true) setKeyError('') - fetch('/api/setup/key', { credentials: 'same-origin' }) + fetchSetupKey(setupToken.trim()) .then(async res => { if (!res.ok) throw new Error(`Server returned ${res.status}`) const data = await res.json() as { key: string } @@ -51,7 +95,7 @@ export default function Setup() { }) .catch(() => setKeyError('Could not load the SDK key. Is the server running?')) .finally(() => setKeyLoading(false)) - }, [step, sdkKey]) + }, [step, sdkKey, setupToken]) async function handleCopy() { await navigator.clipboard.writeText(sdkKey) @@ -62,7 +106,7 @@ export default function Setup() { async function handleFinish() { setError('') setLoading(true) - const result = await completeSetup(workspaceName.trim(), projectName.trim() || 'My App', name.trim(), email.trim(), password) + const result = await completeSetup(workspaceName.trim(), projectName.trim() || 'My App', name.trim(), email.trim(), password, setupToken.trim()) setLoading(false) if (!result.ok) { setError(result.error ?? 'Setup failed.') @@ -142,15 +186,47 @@ export default function Setup() {

Welcome to Checkgate

-

+

Let's set up your control plane in under a minute. You'll configure your workspace, create an admin account, and get your SDK key.

- + +
+ + { setSetupToken(e.target.value); setTokenError('') }} + placeholder="Paste the one-time setup token" + className="w-full bg-white border border-gray-100 rounded-xl pl-9 pr-4 py-2.5 text-gray-900 placeholder-gray-400 text-sm font-mono focus:outline-none focus:ring-4 focus:ring-brand-500/10 focus:border-brand-500/30 transition-all shadow-premium" + /> +
+

+ The value of SETUP_TOKEN, or the token the server wrote to its{' '} + checkgate-setup-token file on first start (see the server log for the path). +

+ {tokenError && ( +

{tokenError}

+ )} + +

Already set up?{' '} diff --git a/docker-compose.screenshots.yml b/docker-compose.screenshots.yml index f846bd3..50d90b8 100644 --- a/docker-compose.screenshots.yml +++ b/docker-compose.screenshots.yml @@ -13,4 +13,5 @@ services: - "127.0.0.1:3100:3000" environment: SESSION_SECRET: checkgate-screenshot-demo-session-secret + SETUP_TOKEN: checkgate-screenshot-demo-setup-token COOKIE_SECURE: "false" diff --git a/docker-compose.yml b/docker-compose.yml index 66724ff..e8a6371 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,9 @@ services: POSTGRES_DB: checkgate SDK_KEY: ${SDK_KEY:-} SESSION_SECRET: ${SESSION_SECRET:-} + # One-time token for the setup wizard. Leave empty to have the server + # generate one: docker exec checkgate cat /var/lib/checkgate/setup-token + SETUP_TOKEN: ${SETUP_TOKEN:-} COOKIE_SECURE: ${COOKIE_SECURE:-false} volumes: - checkgate_pgdata:/var/lib/postgresql/data diff --git a/docs/api-reference.md b/docs/api-reference.md index dd92d52..cb88c98 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -135,7 +135,95 @@ Public endpoint — no auth required. Returns the workspace name for the login p **Response** `200 OK` ```json -{ "workspace_name": "Acme Corp" } +{ "workspace_name": "Acme Corp", "is_setup_complete": true } +``` + +--- + +## First-Run Setup + +These endpoints exist only until the first admin has been created; afterwards +both return `404`. They require the installation's one-time **setup token** in +a header (never a query parameter): + +```http +X-Checkgate-Setup-Token: +``` + +The token is the server's `SETUP_TOKEN` value, or — when that is unset — a +random token each instance writes to its `SETUP_TOKEN_FILE` on boot (see +[Self-Hosting → First-run setup](./self-hosting.md#first-run-setup)). A missing +or wrong token returns `401`. + +### Get the Seeded SDK Key + +```http +GET /api/setup/key +X-Checkgate-Setup-Token: +``` + +**Response** `200 OK` — the SDK key generated on first boot, in the same shape +as [Create SDK Key](#sdk-keys) (`id`, `name`, `key`, `prefix`, `environment_id`, +`environment_name`, `created_at`). + +### Complete Setup + +```http +POST /api/setup/complete +Content-Type: application/json +X-Checkgate-Request: true +X-Checkgate-Setup-Token: +``` + +```json +{ + "workspace_name": "Acme Corp", + "project_name": "My App", + "name": "Juan Dela Cruz", + "email": "admin@example.com", + "password": "at-least-8-characters" +} +``` + +**Response** `200 OK` — the new admin (same shape as [Login](#login)), with a +session cookie set. + +| Status | Meaning | +|--------|---------| +| `401` | Missing or invalid setup token | +| `404` | Setup is already complete | +| `409` | An account with this email already has a password | +| `422` | Missing field or password shorter than 8 characters | + +Setup is atomic: it runs in one transaction under a PostgreSQL advisory lock +and re-checks completion while holding it, so if several requests race — on +one instance or many — exactly one creates the admin and the others get `404`. + +--- + +## Rate Limits + +Requests are limited per client IP, with a separate budget per kind of +endpoint. Defaults (requests per minute, configurable — see +[Self-Hosting → Rate limits](./self-hosting.md#rate-limits)): + +| Class | Endpoints | Default | +|-------|-----------|---------| +| Auth | `POST /api/auth/login`, `/api/setup/*` | 20 | +| API | every other `/api/*` route | 300 | +| SDK | `GET /flags/snapshot` | 600 | +| SSE connect | opening `GET /stream` (events on an open stream are not counted) | 120 | +| Ingest | `POST /api/environments/{env_id}/impressions` and `/events` | 600 | +| — | `GET /health` | never limited | + +A refused request gets `429 Too Many Requests` with a `Retry-After` header +(seconds) and: + +```json +{ + "error": "Too many requests. Please slow down and try again shortly.", + "retry_after_seconds": 3 +} ``` --- diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 4d95517..3f8eb58 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -102,6 +102,77 @@ openssl rand -hex 32 SDK keys are managed through the dashboard Settings page. The first key is auto-generated on first boot. Additional keys can be created and revoked without restarting the server. +## First-run setup + +A fresh installation is claimed by whoever completes the setup wizard, so the +wizard requires a one-time **setup token**. This keeps an internet-reachable +server from being taken over by the first stranger to open it. + +- **Choose the token yourself (recommended for production and required for + read-only filesystems):** set `SETUP_TOKEN` to a random string of at least 16 + characters (`openssl rand -hex 32`). With several replicas, give them all the + same value. +- **Or let the server generate one:** with `SETUP_TOKEN` unset, every instance + writes a random token to `SETUP_TOKEN_FILE` (default `checkgate-setup-token` + in its working directory; `/var/lib/checkgate/setup-token` in the Docker + image) with mode `0600`, and logs the file's path — never the token. Any + instance accepts any instance's token. Read it with, for example: + + ```bash + docker exec checkgate cat /var/lib/checkgate/setup-token + ``` + +Paste the token into the first step of the wizard. It is sent in the +`X-Checkgate-Setup-Token` header, never in a URL. Completing setup invalidates +every setup token and the instance deletes its token file; other instances +delete theirs on their next start. + +**Upgrading:** installations that have already completed setup are not +affected. An installation that was never set up needs the token from now on. +No database migration is involved — generated token hashes are stored in the +existing `settings` table. + +## Rate limits + +Requests are limited per client IP, with an independent budget per class of +endpoint, so SDK traffic cannot lock operators out of the dashboard and a +password-guessing run cannot exhaust the SDK budget. `/health` is never +limited. Opening an SSE stream counts once; events delivered on an open stream +are never counted. + +| Variable | Default (per minute) | Applies to | +|----------|----------------------|------------| +| `RATE_LIMIT_AUTH_PER_MINUTE` | `20` | `POST /api/auth/login`, `/api/setup/*` | +| `RATE_LIMIT_API_PER_MINUTE` | `300` | all other `/api/*` routes | +| `RATE_LIMIT_SDK_PER_MINUTE` | `600` | `GET /flags/snapshot` | +| `RATE_LIMIT_SSE_PER_MINUTE` | `120` | opening `GET /stream` | +| `RATE_LIMIT_INGEST_PER_MINUTE` | `600` | impression and event ingestion | + +Set a variable to `0` to disable that class (for example, when a gateway in +front of Checkgate already limits it). Many SDK instances behind one NAT or +egress IP share a budget; raise the SDK, SSE and ingest limits accordingly. +Limiter state holds only recently active clients (IPv6 clients are grouped by +/64) and is pruned every minute. + +### Behind a reverse proxy or load balancer + +By default the client is the TCP peer, and `X-Forwarded-For` is **ignored** — +otherwise any client could choose its own rate-limit identity by sending the +header. If Checkgate runs behind a proxy, list the proxy's addresses so its +header is believed: + +```bash +TRUSTED_PROXIES="10.0.0.0/8, 192.168.1.10" # IPs and/or CIDR ranges +``` + +The `X-Forwarded-For` chain is read from the right: hops that are trusted +proxies are skipped and the first untrusted hop is the client. Entries a +client supplies itself are never used. If the header is malformed, the request +is attributed to the nearest trusted proxy. An invalid `TRUSTED_PROXIES` value +stops the server at startup. Only trust addresses that your proxies actually +use — a trusted range that untrusted clients can connect from lets them spoof +any address. + ## External PostgreSQL and Redis To use your own PostgreSQL and Redis instead of the bundled ones, run the image directly and pass the connection strings: diff --git a/sdks/nodejs/e2e/e2e.cjs b/sdks/nodejs/e2e/e2e.cjs index 133104f..57a6d04 100644 --- a/sdks/nodejs/e2e/e2e.cjs +++ b/sdks/nodejs/e2e/e2e.cjs @@ -12,20 +12,23 @@ // - A server is running at CHECKGATE_URL with a FRESH (un-set-up) database. // // Env: CHECKGATE_URL (default http://127.0.0.1:3000) +// CHECKGATE_SETUP_TOKEN — the server's SETUP_TOKEN, required for first-run setup const assert = require('node:assert') const { CheckgateClient } = require('../index.js') const BASE = process.env.CHECKGATE_URL || 'http://127.0.0.1:3000' +const SETUP_TOKEN = process.env.CHECKGATE_SETUP_TOKEN || '' const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) -async function api(method, path, { token, cookie, body } = {}) { +async function api(method, path, { token, cookie, body, setupToken } = {}) { const res = await fetch(`${BASE}${path}`, { method, headers: { 'Content-Type': 'application/json', 'X-Checkgate-Request': 'true', + ...(setupToken ? { 'X-Checkgate-Setup-Token': setupToken } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(cookie ? { Cookie: cookie } : {}), }, @@ -46,11 +49,12 @@ async function waitFor(label, predicate, timeoutMs = 8000) { async function main() { // 1. First-run setup: grab the seeded SDK key + environment, create the admin. - const keyRes = await api('GET', '/api/setup/key') + const keyRes = await api('GET', '/api/setup/key', { setupToken: SETUP_TOKEN }) assert.equal(keyRes.status, 200, 'GET /api/setup/key') const { key: sdkKey, environment_id: env } = await keyRes.json() const setupRes = await api('POST', '/api/setup/complete', { + setupToken: SETUP_TOKEN, body: { workspace_name: 'E2E', project_name: 'P', name: 'A', email: 'a@e.test', password: 'supersecret1' }, }) assert.equal(setupRes.status, 200, 'POST /api/setup/complete') diff --git a/server/src/api/keys.rs b/server/src/api/keys.rs index 0ceafa2..83d53b8 100644 --- a/server/src/api/keys.rs +++ b/server/src/api/keys.rs @@ -149,21 +149,20 @@ async fn can_admin_project( pub async fn get_setup_key( State(state): State, jar: PrivateCookieJar, + headers: axum::http::HeaderMap, ) -> Result, StatusCode> { if jar.get("lg_session").is_some() { return Err(StatusCode::NOT_FOUND); } - let complete = sqlx::query("SELECT value FROM settings WHERE key = 'setup_complete'") - .fetch_optional(&state.db) + if crate::setup::is_setup_complete(&state.db) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .map(|row| row.get::("value") == "true") - .unwrap_or(false); - - if complete { + { return Err(StatusCode::NOT_FOUND); } + // The key is only shown to whoever is entitled to claim the installation. + crate::setup::verify(&state, &headers).await?; let row = sqlx::query( "SELECT k.id, k.name, k.value, k.created_at, k.environment_id::text, e.name AS env_name \ diff --git a/server/src/api/session.rs b/server/src/api/session.rs index 1e329fc..fa5cfba 100644 --- a/server/src/api/session.rs +++ b/server/src/api/session.rs @@ -5,7 +5,7 @@ use argon2::{ use axum::{ Json, Router, extract::State, - http::StatusCode, + http::{HeaderMap, StatusCode}, routing::{get, post}, }; use axum_extra::extract::cookie::{Cookie, PrivateCookieJar, SameSite}; @@ -15,6 +15,7 @@ use time::Duration; use tracing::{error, info, warn}; use crate::client_ip::ClientIp; +use crate::setup; use crate::state::AppState; // --------------------------------------------------------------------------- @@ -139,13 +140,7 @@ async fn get_workspace_name(state: &AppState) -> String { } async fn get_is_setup_complete(state: &AppState) -> bool { - sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key = 'setup_complete'") - .fetch_optional(&state.db) - .await - .ok() - .flatten() - .map(|v| v == "true") - .unwrap_or(false) + setup::is_setup_complete(&state.db).await.unwrap_or(false) } // --------------------------------------------------------------------------- @@ -436,22 +431,27 @@ pub async fn me( } /// First-run setup: creates the admin user and workspace, issues a session cookie. +/// +/// Requires the one-time setup token (see [`crate::setup`]). The whole claim +/// runs in one transaction under a database-wide advisory lock, and +/// `setup_complete` is re-read while holding it, so of any number of +/// concurrent requests — on any number of server instances — exactly one +/// creates the admin; the rest see setup already complete and get 404. pub async fn setup_complete( State(state): State, jar: PrivateCookieJar, + headers: HeaderMap, Json(req): Json, ) -> Result<(PrivateCookieJar, Json), StatusCode> { - let complete = - sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key = 'setup_complete'") - .fetch_optional(&state.db) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .map(|v| v == "true") - .unwrap_or(false); - - if complete { + // Cheap pre-check so a finished installation answers 404 without a token. + // Not relied upon: the authoritative check is repeated under the lock. + if setup::is_setup_complete(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { return Err(StatusCode::NOT_FOUND); } + setup::verify(&state, &headers).await?; let workspace_name = req.workspace_name.trim().to_string(); let name = req.name.trim().to_string(); @@ -468,24 +468,51 @@ pub async fn setup_complete( let password_hash = hash_password(&req.password)?; + // Any early return drops `db_tx`, which rolls the transaction back and + // releases the advisory lock. let mut db_tx = state.db.begin().await.map_err(|e| { error!(error = %e, "Failed to begin setup transaction"); StatusCode::INTERNAL_SERVER_ERROR })?; + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(setup::SETUP_ADVISORY_LOCK) + .execute(&mut *db_tx) + .await + .map_err(|e| { + error!(error = %e, "Failed to acquire setup lock"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if setup::is_setup_complete(&mut *db_tx).await.map_err(|e| { + error!(error = %e, "Failed to re-check setup state"); + StatusCode::INTERNAL_SERVER_ERROR + })? { + info!("Setup request lost the race — setup was completed concurrently"); + return Err(StatusCode::NOT_FOUND); + } + + // A row for this email may predate password auth (migration + // 20260416000004 left such rows without a password); setup may claim it. + // An account that already has a password is never overwritten. let row = sqlx::query( "INSERT INTO users (name, email, role, password_hash) VALUES ($1, $2, 'admin', $3) \ ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, role = 'admin', \ - password_hash = EXCLUDED.password_hash RETURNING id", + password_hash = EXCLUDED.password_hash WHERE users.password_hash IS NULL \ + RETURNING id", ) .bind(&name) .bind(&email) .bind(&password_hash) - .fetch_one(&mut *db_tx) + .fetch_optional(&mut *db_tx) .await .map_err(|e| { error!(error = %e, "Failed to create admin user during setup"); StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or_else(|| { + warn!("Setup rejected: an account with this email already has a password"); + StatusCode::CONFLICT })?; sqlx::query( @@ -524,10 +551,16 @@ pub async fn setup_complete( StatusCode::INTERNAL_SERVER_ERROR })?; + setup::revoke_all(&mut *db_tx).await.map_err(|e| { + error!(error = %e, "Failed to revoke setup tokens"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + db_tx.commit().await.map_err(|e| { error!(error = %e, "Failed to commit setup transaction"); StatusCode::INTERNAL_SERVER_ERROR })?; + setup::remove_token_file(); info!(email = %email, workspace = %workspace_name, project = %project_name, "Setup complete — admin user created"); diff --git a/server/src/main.rs b/server/src/main.rs index b5ea363..aec232e 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -17,6 +17,7 @@ mod integrations; mod notify; mod rate_limit; mod scheduler; +mod setup; mod state; mod stream; mod webhook_fire; @@ -96,6 +97,19 @@ async fn main() -> Result<(), Box> { info!("Migrations complete — schema ready"); + // ── First-run setup token ───────────────────────────────────────────────── + + let setup_token = setup::configured_token().map_err(|e| { + error!(error = %e, "Invalid SETUP_TOKEN"); + e + })?; + setup::prepare(&db, setup_token.as_deref()) + .await + .map_err(|e| { + error!(error = %e, "Failed to prepare first-run setup"); + e + })?; + // ── Redis ───────────────────────────────────────────────────────────────── let redis_url = @@ -432,6 +446,7 @@ async fn main() -> Result<(), Box> { session_key, connected_clients: Arc::new(DashMap::new()), webhook_client, + setup_token, }; // ── Middleware stack ─────────────────────────────────────────────────────── diff --git a/server/src/scheduler.rs b/server/src/scheduler.rs index cdd6140..3812acc 100644 --- a/server/src/scheduler.rs +++ b/server/src/scheduler.rs @@ -164,6 +164,7 @@ mod tests { session_key: Key::generate(), connected_clients: Arc::new(DashMap::new()), webhook_client: reqwest::Client::new(), + setup_token: None, }; tokio::time::timeout(Duration::from_secs(5), execute_due(&state)) .await diff --git a/server/src/setup.rs b/server/src/setup.rs new file mode 100644 index 0000000..13cd979 --- /dev/null +++ b/server/src/setup.rs @@ -0,0 +1,198 @@ +//! First-run ownership: the one-time setup token. +//! +//! Until setup completes, `/api/setup/*` creates the first workspace admin, so +//! whoever reaches a fresh installation first would otherwise own it. Those +//! routes therefore require a setup token in the `X-Checkgate-Setup-Token` +//! header — never a query parameter, so it cannot leak into access logs. +//! +//! The token comes from one of two places: +//! +//! - `SETUP_TOKEN` — chosen by the operator. Every instance must share it. +//! - Otherwise, each instance generates a random token on boot while setup is +//! incomplete, writes it to `SETUP_TOKEN_FILE` (mode 0600) and stores only +//! its SHA-256 in `settings`, so any instance accepts any generated token. +//! The token itself is never logged. +//! +//! Completing setup deletes every stored token hash in the same transaction +//! that marks setup complete. + +use crate::state::AppState; +use axum::http::{HeaderMap, StatusCode}; +use constant_time_eq::constant_time_eq; +use rand::RngExt as _; +use std::path::PathBuf; +use std::sync::Arc; +use tracing::{error, info, warn}; + +pub const SETUP_TOKEN_HEADER: &str = "x-checkgate-setup-token"; + +/// Shortest operator-chosen token accepted. Setup grants full admin rights. +const MIN_TOKEN_LEN: usize = 16; + +/// `settings` key prefix for generated token hashes. +const TOKEN_KEY_PREFIX: &str = "setup_token:"; + +/// Key for the transaction-scoped advisory lock that serializes setup across +/// every server instance sharing the database. +pub const SETUP_ADVISORY_LOCK: i64 = 0x636b_6774_5f73_6574; // "ckgt_set" + +/// Reads `SETUP_TOKEN`. Unset or empty means "generate one per instance". +pub fn configured_token() -> Result>, String> { + match std::env::var("SETUP_TOKEN") { + Ok(token) if !token.trim().is_empty() => { + let token = token.trim(); + if token.len() < MIN_TOKEN_LEN { + return Err(format!( + "SETUP_TOKEN must be at least {MIN_TOKEN_LEN} characters" + )); + } + Ok(Some(Arc::from(token))) + } + _ => Ok(None), + } +} + +fn token_file() -> PathBuf { + std::env::var_os("SETUP_TOKEN_FILE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("checkgate-setup-token")) +} + +pub(crate) async fn is_setup_complete<'e>( + db: impl sqlx::PgExecutor<'e>, +) -> Result { + Ok( + sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key = 'setup_complete'") + .fetch_optional(db) + .await? + .is_some_and(|v| v == "true"), + ) +} + +/// Startup step: make sure a fresh installation can be claimed by its +/// operator, and only by its operator. +pub async fn prepare(db: &sqlx::PgPool, configured: Option<&str>) -> Result<(), String> { + let complete = is_setup_complete(db) + .await + .map_err(|e| format!("failed to read setup state: {e}"))?; + let path = token_file(); + + if complete { + // Nothing to claim: a token file left from before setup is just a + // stale secret on disk. + if path.exists() { + match std::fs::remove_file(&path) { + Ok(()) => info!(path = %path.display(), "Removed stale setup token file"), + Err(e) => { + warn!(path = %path.display(), error = %e, "Could not remove stale setup token file") + } + } + } + return Ok(()); + } + + if configured.is_some() { + info!("Setup is not complete — the setup wizard requires the SETUP_TOKEN value"); + return Ok(()); + } + + let mut bytes = [0u8; 32]; + rand::rng().fill(&mut bytes); + let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + + write_token_file(&path, &token).map_err(|e| { + format!( + "setup is not complete and the setup token could not be written to {} ({e}); \ + set SETUP_TOKEN, or SETUP_TOKEN_FILE to a writable path", + path.display() + ) + })?; + + sqlx::query("INSERT INTO settings (key, value) VALUES ($1, NOW()::text)") + .bind(format!( + "{TOKEN_KEY_PREFIX}{}", + crate::auth::hash_token(&token) + )) + .execute(db) + .await + .map_err(|e| format!("failed to store setup token: {e}"))?; + + info!( + path = %path.display(), + "Setup is not complete — a one-time setup token was written to this file; \ + enter it in the setup wizard" + ); + Ok(()) +} + +fn write_token_file(path: &std::path::Path, token: &str) -> std::io::Result<()> { + use std::io::Write as _; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(path)?; + // `mode` only applies on creation; tighten a file that already existed. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + file.write_all(token.as_bytes())?; + file.write_all(b"\n") +} + +/// Accepts the request only if it carries a valid setup token. +pub(crate) async fn verify(state: &AppState, headers: &HeaderMap) -> Result<(), StatusCode> { + let presented = headers + .get(SETUP_TOKEN_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let valid = match &state.setup_token { + Some(expected) => constant_time_eq(presented.as_bytes(), expected.as_bytes()), + // Looked up by hash, so the comparison leaks nothing about the token. + None => { + sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM settings WHERE key = $1)") + .bind(format!( + "{TOKEN_KEY_PREFIX}{}", + crate::auth::hash_token(presented) + )) + .fetch_one(&state.db) + .await + .map_err(|e| { + error!(error = %e, "DB error verifying setup token"); + StatusCode::INTERNAL_SERVER_ERROR + })? + } + }; + if valid { + Ok(()) + } else { + warn!("Rejected setup request: invalid setup token"); + Err(StatusCode::UNAUTHORIZED) + } +} + +/// Deletes every generated token hash. Runs inside the setup transaction. +pub(crate) async fn revoke_all<'e>(db: impl sqlx::PgExecutor<'e>) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM settings WHERE starts_with(key, 'setup_token:')") + .execute(db) + .await + .map(|_| ()) +} + +/// Best-effort removal of this instance's token file once setup is done. +pub(crate) fn remove_token_file() { + let path = token_file(); + if path.exists() + && let Err(e) = std::fs::remove_file(&path) + { + warn!(path = %path.display(), error = %e, "Could not remove setup token file"); + } +} diff --git a/server/src/state.rs b/server/src/state.rs index f66224f..1580212 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -56,6 +56,9 @@ pub struct AppState { pub connected_clients: Arc>, /// Shared HTTP client for outbound webhook delivery. pub webhook_client: reqwest::Client, + /// Operator-configured `SETUP_TOKEN`; `None` means per-instance generated + /// tokens, verified by hash against `settings` (see `crate::setup`). + pub setup_token: Option>, } /// Allows `PrivateCookieJar` extractors to pull the key out of `AppState` automatically. diff --git a/server/tests/e2e_sse.rs b/server/tests/e2e_sse.rs index bdd3d41..45c99ed 100644 --- a/server/tests/e2e_sse.rs +++ b/server/tests/e2e_sse.rs @@ -63,6 +63,9 @@ impl Drop for ServerGuard { } } +/// First-run setup token the spawned server is configured with. +const SETUP_TOKEN: &str = "e2e-setup-token-0123456789"; + fn free_port() -> u16 { std::net::TcpListener::bind("127.0.0.1:0") .unwrap() @@ -254,6 +257,7 @@ async fn sse_live_push_and_local_eval() { "e2e-secret-0123456789-0123456789-0123456789-0123456789", ) .env("COOKIE_SECURE", "false") + .env("SETUP_TOKEN", SETUP_TOKEN) .env("PUBLIC_DIR", public_dir.to_str().unwrap()) .env("RUST_LOG", "warn") .stdout(Stdio::null()) @@ -272,6 +276,7 @@ async fn sse_live_push_and_local_eval() { // Grab the seeded SDK key + env, complete setup. let key_body: Value = anon .get(format!("{base}/api/setup/key")) + .header("X-Checkgate-Setup-Token", SETUP_TOKEN) .send() .await .unwrap() @@ -282,6 +287,7 @@ async fn sse_live_push_and_local_eval() { let env = key_body["environment_id"].as_str().unwrap().to_string(); session.post(format!("{base}/api/setup/complete")) .header("X-Checkgate-Request", "true") + .header("X-Checkgate-Setup-Token", SETUP_TOKEN) .json(&json!({ "workspace_name": "E2E", "project_name": "P", "name": "A", "email": "a@e.test", "password": "supersecret1" })) .send().await.unwrap(); // SDK keys can't manage flags, so the "dashboard" side uses an admin PAT. diff --git a/server/tests/integration.rs b/server/tests/integration.rs index 75ede1c..7ed6c4d 100644 --- a/server/tests/integration.rs +++ b/server/tests/integration.rs @@ -64,6 +64,9 @@ impl Drop for ServerGuard { } } +/// First-run setup token the spawned server is configured with. +const SETUP_TOKEN: &str = "integration-setup-token-0123456789"; + fn free_port() -> u16 { std::net::TcpListener::bind("127.0.0.1:0") .unwrap() @@ -138,6 +141,7 @@ async fn full_api_flow() { "test-secret-0123456789-0123456789-0123456789-0123456789-0123456789", ) .env("COOKIE_SECURE", "false") + .env("SETUP_TOKEN", SETUP_TOKEN) // The test client stands in for a reverse proxy: some scenarios send // X-Forwarded-For to get a rate-limit budget of their own, which the // server honours only from a trusted peer. @@ -188,6 +192,7 @@ async fn setup_and_keys(ctx: &Ctx) -> (String, String, String) { let r = ctx .anon .get(ctx.url("/api/setup/key")) + .header("X-Checkgate-Setup-Token", SETUP_TOKEN) .send() .await .unwrap(); @@ -202,6 +207,7 @@ async fn setup_and_keys(ctx: &Ctx) -> (String, String, String) { .sess .post(ctx.url("/api/setup/complete")) .header("X-Checkgate-Request", "true") + .header("X-Checkgate-Setup-Token", SETUP_TOKEN) .json(&json!({ "workspace_name": "Acme", "project_name": "Web", @@ -227,6 +233,7 @@ async fn setup_and_keys(ctx: &Ctx) -> (String, String, String) { .sess .post(ctx.url("/api/setup/complete")) .header("X-Checkgate-Request", "true") + .header("X-Checkgate-Setup-Token", SETUP_TOKEN) .json(&json!({ "workspace_name": "x", "name": "x", "email": "x@x.t", "password": "supersecret1" })) .send() .await diff --git a/server/tests/setup_ownership.rs b/server/tests/setup_ownership.rs new file mode 100644 index 0000000..addc6b7 --- /dev/null +++ b/server/tests/setup_ownership.rs @@ -0,0 +1,371 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::arithmetic_side_effects, + reason = "test code: a panic here is an assertion failure, not a DoS" +)] +//! First-run setup: exactly one authorized request may claim an installation, +//! however many arrive at once and on however many instances. + +mod common; + +use common::*; +use serde_json::{Value, json}; + +async fn post_concurrently(servers: &[&Server], attempts_per_server: usize) -> Vec<(String, u16)> { + let mut tasks = Vec::new(); + for (s, server) in servers.iter().enumerate() { + for i in 0..attempts_per_server { + let base = server.base.clone(); + let email = format!("racer-{s}-{i}@acme.test"); + tasks.push(tokio::spawn(async move { + let r = post_setup( + &session_client(), + &base, + Some(SETUP_TOKEN), + &setup_body(&email, &format!("password-{s}-{i}")), + ) + .await; + (email, r.status().as_u16()) + })); + } + } + let mut results = Vec::new(); + for task in tasks { + results.push(task.await.unwrap()); + } + results +} + +/// Asserts exactly one winner and that the database holds only its identity. +async fn assert_single_winner(db_url: &str, server: &Server, results: &[(String, u16)]) { + let winners: Vec<&(String, u16)> = results.iter().filter(|(_, s)| *s == 200).collect(); + assert_eq!(winners.len(), 1, "exactly one setup wins: {results:?}"); + for (email, status) in results { + assert!( + *status == 200 || *status == 404, + "{email}: losers see setup already complete, got {status}" + ); + } + let winner = &winners[0].0; + + let pool = sqlx::PgPool::connect(db_url).await.unwrap(); + let users: Vec<(String, String)> = sqlx::query_as("SELECT email, role FROM users") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(users, vec![(winner.clone(), "admin".to_string())]); + pool.close().await; + + // The winner's credentials work; a loser's were never stored. + let (s, i) = winner + .trim_start_matches("racer-") + .trim_end_matches("@acme.test") + .split_once('-') + .unwrap(); + login(server, winner, &format!("password-{s}-{i}")).await; + if let Some((loser, _)) = results.iter().find(|(_, s)| *s == 404) { + request( + &session_client(), + "POST", + &server.url("/api/auth/login"), + Some(json!({ "email": loser, "password": "password-0-0" })), + 401, + ) + .await; + } +} + +#[tokio::test] +async fn two_concurrent_setups_have_one_winner() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_setup_two").await; + let server = Server::spawn(&db, &env.redis_url, &[]).await; + let results = post_concurrently(&[&server], 2).await; + assert_single_winner(&db, &server, &results).await; +} + +#[tokio::test] +async fn many_concurrent_setups_have_one_winner() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_setup_many").await; + let server = Server::spawn(&db, &env.redis_url, &[]).await; + let results = post_concurrently(&[&server], 12).await; + assert_single_winner(&db, &server, &results).await; +} + +#[tokio::test] +async fn concurrent_setups_across_instances_have_one_winner() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_setup_multi").await; + let a = Server::spawn(&db, &env.redis_url, &[]).await; + let b = Server::spawn(&db, &env.redis_url, &[]).await; + let results = post_concurrently(&[&a, &b], 6).await; + assert_single_winner(&db, &b, &results).await; +} + +#[tokio::test] +async fn setup_requires_a_valid_token_and_is_one_time() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_setup_token").await; + let server = Server::spawn(&db, &env.redis_url, &[]).await; + let anon = reqwest::Client::new(); + let body = setup_body(ADMIN_EMAIL, ADMIN_PASSWORD); + + // Missing, empty and wrong tokens are refused, for the key and the claim. + for token in [None, Some(""), Some("wrong-token-0123456789abcdef")] { + let r = post_setup(&anon, &server.base, token, &body).await; + assert_eq!(r.status(), 401, "setup with token {token:?}"); + let mut req = anon.get(server.url("/api/setup/key")); + if let Some(token) = token { + req = req.header("X-Checkgate-Setup-Token", token); + } + assert_eq!( + req.send().await.unwrap().status(), + 401, + "key with {token:?}" + ); + } + // The token is a header, never a query parameter. + let r = anon + .get(server.url(&format!("/api/setup/key?setup_token={SETUP_TOKEN}"))) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 401, "query-string token is ignored"); + // A refused attempt changed nothing. + let workspace: Value = anon + .get(server.url("/api/auth/workspace")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(workspace["is_setup_complete"], false); + + let (admin, _, _) = complete_setup(&server).await; + request(&admin, "GET", &server.url("/api/auth/me"), None, 200).await; + login(&server, ADMIN_EMAIL, ADMIN_PASSWORD).await; + + // Repeated setup — even with the valid token and the same email — is a 404 + // and does not touch the established account. + let takeover = setup_body(ADMIN_EMAIL, "attacker-password"); + let r = post_setup( + &session_client(), + &server.base, + Some(SETUP_TOKEN), + &takeover, + ) + .await; + assert_eq!(r.status(), 404, "setup after completion"); + let r = anon + .get(server.url("/api/setup/key")) + .header("X-Checkgate-Setup-Token", SETUP_TOKEN) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 404, "setup key after completion"); + login(&server, ADMIN_EMAIL, ADMIN_PASSWORD).await; + request( + &session_client(), + "POST", + &server.url("/api/auth/login"), + Some(json!({ "email": ADMIN_EMAIL, "password": "attacker-password" })), + 401, + ) + .await; +} + +#[tokio::test] +async fn generated_tokens_work_on_every_instance_and_never_reach_the_log() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_setup_generated").await; + let generated = [("SETUP_TOKEN", None)]; + let a = Server::spawn(&db, &env.redis_url, &generated).await; + let b = Server::spawn(&db, &env.redis_url, &generated).await; + + let read_token = |server: &Server| { + let path = server.dir.join("checkgate-setup-token"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "token file is owner-only"); + } + std::fs::read_to_string(path).unwrap().trim().to_string() + }; + let token_a = read_token(&a); + let token_b = read_token(&b); + assert_eq!(token_a.len(), 64); + assert_ne!(token_a, token_b, "each instance generates its own token"); + + // Instance B accepts instance A's token: hashes live in the shared database. + let r = reqwest::Client::new() + .get(b.url("/api/setup/key")) + .header("X-Checkgate-Setup-Token", &token_a) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200, "cross-instance token"); + + let r = post_setup( + &session_client(), + &a.base, + Some(&token_b), + &setup_body(ADMIN_EMAIL, ADMIN_PASSWORD), + ) + .await; + assert_eq!(r.status(), 200, "setup with generated token"); + + let pool = sqlx::PgPool::connect(&db).await.unwrap(); + let remaining: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM settings WHERE starts_with(key, 'setup_token:')") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(remaining, 0, "setup revokes every generated token"); + pool.close().await; + assert!( + !a.dir.join("checkgate-setup-token").exists(), + "the instance that ran setup removes its token file" + ); + + // B's file is stale now; B removes it on its next start. + assert!(b.dir.join("checkgate-setup-token").exists()); + let b = b.restart(&db, &env.redis_url, &generated).await; + assert!(!b.dir.join("checkgate-setup-token").exists()); + + for (server, token) in [(&a, &token_a), (&b, &token_b)] { + let log = server.log(); + assert!(log.contains("setup token"), "log mentions the token file"); + assert!(!log.contains(token.as_str()), "token value is never logged"); + } +} + +#[tokio::test] +async fn failed_setup_rolls_back_completely() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_setup_rollback").await; + let generated = [("SETUP_TOKEN", None)]; + let server = Server::spawn(&db, &env.redis_url, &generated).await; + let token = std::fs::read_to_string(server.dir.join("checkgate-setup-token")) + .unwrap() + .trim() + .to_string(); + + // Fail the last write setup makes before marking itself complete. + let pool = sqlx::PgPool::connect(&db).await.unwrap(); + sqlx::query( + "CREATE FUNCTION fail_setup() RETURNS trigger LANGUAGE plpgsql AS $$ \ + BEGIN RAISE EXCEPTION 'injected setup failure'; END $$", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query("CREATE TRIGGER fail_setup BEFORE UPDATE ON projects FOR EACH ROW EXECUTE FUNCTION fail_setup()") + .execute(&pool) + .await + .unwrap(); + + let body = setup_body(ADMIN_EMAIL, ADMIN_PASSWORD); + let r = post_setup(&session_client(), &server.base, Some(&token), &body).await; + assert_eq!(r.status(), 500, "injected failure surfaces"); + + let users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(users, 0, "admin insert rolled back"); + let complete: Option = + sqlx::query_scalar("SELECT value FROM settings WHERE key = 'setup_complete'") + .fetch_optional(&pool) + .await + .unwrap(); + assert_ne!( + complete.as_deref(), + Some("true"), + "setup not marked complete" + ); + let workspace: String = + sqlx::query_scalar("SELECT value FROM settings WHERE key = 'workspace_name'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(workspace, "", "workspace name rolled back"); + let tokens: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM settings WHERE starts_with(key, 'setup_token:')") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(tokens, 1, "token revocation rolled back too"); + + // Once the fault is gone, the same token completes setup. + sqlx::query("DROP TRIGGER fail_setup ON projects") + .execute(&pool) + .await + .unwrap(); + let r = post_setup(&session_client(), &server.base, Some(&token), &body).await; + assert_eq!(r.status(), 200, "retry after rollback"); + login(&server, ADMIN_EMAIL, ADMIN_PASSWORD).await; + pool.close().await; +} + +#[tokio::test] +async fn setup_never_overwrites_an_account_with_a_password() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_setup_existing").await; + let server = Server::spawn(&db, &env.redis_url, &[]).await; + let pool = sqlx::PgPool::connect(&db).await.unwrap(); + // A pre-password-auth row may be claimed; an account with a password not. + sqlx::query( + "INSERT INTO users (name, email, role, password_hash) VALUES \ + ('Legacy', 'legacy@acme.test', 'viewer', NULL), \ + ('Owner', 'owner@acme.test', 'viewer', 'not-a-real-hash')", + ) + .execute(&pool) + .await + .unwrap(); + + let r = post_setup( + &session_client(), + &server.base, + Some(SETUP_TOKEN), + &setup_body("owner@acme.test", ADMIN_PASSWORD), + ) + .await; + assert_eq!(r.status(), 409); + let (role, hash): (String, String) = + sqlx::query_as("SELECT role, password_hash FROM users WHERE email = 'owner@acme.test'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + (role.as_str(), hash.as_str()), + ("viewer", "not-a-real-hash") + ); + + let r = post_setup( + &session_client(), + &server.base, + Some(SETUP_TOKEN), + &setup_body("legacy@acme.test", ADMIN_PASSWORD), + ) + .await; + assert_eq!(r.status(), 200, "passwordless legacy row is claimable"); + login(&server, "legacy@acme.test", ADMIN_PASSWORD).await; + pool.close().await; +} + +#[tokio::test] +async fn short_setup_token_is_refused_at_startup() { + let Some(env) = test_env() else { return }; + let db = env.fresh_database("cg_setup_short").await; + let log = expect_startup_failure(&db, &env.redis_url, &[("SETUP_TOKEN", Some("short"))]).await; + assert!( + log.contains("SETUP_TOKEN"), + "error names the setting: {log}" + ); +} From 8eace219253534cebf28258163c60a0ce309fbf9 Mon Sep 17 00:00:00 2001 From: Dennis Paler Date: Thu, 29 Oct 2026 13:44:08 +0800 Subject: [PATCH 05/12] fix(server): scope SDK health connections to the caller's projects GET /api/health/connections returned every SSE connection on the instance, with full client IPs, to any authenticated user or token. - Workspace admins keep installation-wide visibility and full addresses. - Everyone else (sessions and PATs of either scope, acting as their user) sees only connections of environments in projects they belong to, with client_ip reduced to its /24 (IPv4) or /48 (IPv6) network. - Connections without an environment (dashboard sessions, legacy SDK_KEY) are admin-only. SDK keys stay refused. - Connection records keep the resolved client IpAddr; the module docs, the API reference and the dashboard page state that tracking is per instance. Tests: tests/sdk_health.rs opens simultaneous streams in three environments across two projects plus an admin session, then checks the admin, a viewer of one project, its read-only PAT, an editor of the other project, a user with no membership, SDK keys and anonymous callers; also stream close and membership removal. --- .github/workflows/ci.yml | 1 + dashboard/src/pages/SdkHealth.tsx | 7 +- docs/api-reference.md | 36 +++++ server/src/api/health.rs | 112 ++++++++++++-- server/src/state.rs | 7 +- server/src/stream.rs | 2 +- server/tests/sdk_health.rs | 241 ++++++++++++++++++++++++++++++ 7 files changed, 387 insertions(+), 19 deletions(-) create mode 100644 server/tests/sdk_health.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8261e0..6baa3ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,6 +146,7 @@ jobs: cargo test -p server --test e2e_sse -- --nocapture cargo test -p server --test rate_limit -- --nocapture cargo test -p server --test setup_ownership -- --nocapture + cargo test -p server --test sdk_health -- --nocapture # --------------------------------------------------------------------------- # Native SDK e2e — build the real @checkgate/node addon and drive it over a diff --git a/dashboard/src/pages/SdkHealth.tsx b/dashboard/src/pages/SdkHealth.tsx index 456ef9a..191b14a 100644 --- a/dashboard/src/pages/SdkHealth.tsx +++ b/dashboard/src/pages/SdkHealth.tsx @@ -45,7 +45,12 @@ export default function SdkHealth() {

SDK Health

- Live SSE connections to this server instance. Refreshes every 10 s. + Live SSE connections to this server instance, for the projects you can access. + Refreshes every 10 s. +

+

+ Each instance tracks its own connections. Client addresses are shown as a network + prefix unless you are a workspace admin.