diff --git a/docs/config/env.md b/docs/config/env.md index d9b4c385..9e7df501 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -355,13 +355,16 @@ Today's shipped locales: `ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru Example: `OXICLOUD_TRUST_PROXY_CIDR=127.0.0.1/32,10.0.0.0/8,172.16.0.0/12` -## Message bus WebSocket +## Message bus | Variable | Default | Description | |---|---|---| -| `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` | `30` | Server-initiated protocol Ping interval on `/api/rt/ws`. Prevents intermediate proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping the TCP session as idle. Read at each WS connect — a change takes effect on new connections, no restart needed. Set `0` or any non-positive value to fall back to the default. | +| `OXICLOUD_MESSAGEBUS_ENABLE` | `true` | Master switch for the message bus. When `false`, the routes `/api/rt/ws` and `POST /api/rt/ticket` are **not registered** at boot — Axum returns `404 Not Found` for both, keeping monitoring dashboards free of 5xx noise. Publish sites in the services stay unchanged (the in-process bus still runs, publishes to nobody are cheap no-ops), so no service code path branches on this flag — the toggle is purely at the API surface. Clients discover this via `GET /api/config.features.message_bus` and skip WS setup entirely (no reconnect flood, no wasted round-trips). **Why an operator might turn it off**: each logged-in browser holds a persistent WebSocket connection while a folder view is open. `N` users × `M` tabs = `N × M` sustained TCP + TLS + WS sessions on the server, each consuming an fd, ~a few KB of tokio task state, and any tuple your L4/L7 load balancer keeps for the flow. On tightly-provisioned VPS deployments (low fd ulimit, tight memory), behind WebSocket-hostile reverse proxies that can't be reconfigured, or during an operational triage where you want to shed WS load, set this to `false` — the SPA transparently falls back to its pre-message-bus behavior (updates land on the next navigation / refresh instead of live). | +| `OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS` | `30` | Server-initiated protocol Ping interval on `/api/rt/ws`. Prevents intermediate proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping the TCP session as idle. Read at each WS connect — a change takes effect on new connections, no restart needed. Set `0` or any non-positive value to fall back to the default. | -Tuning: 30 s is comfortably under nginx's 60 s `proxy_read_timeout` default and Cloudflare's 100 s hard limit. Behind Traefik with `respondingTimeouts.idleTimeout` bumped to `3600s` (as documented in the reverse-proxy setup), you can leave this at 30 s or raise it — the interval should sit at most half the smallest hop's idle timeout so a single missed Ping doesn't reap the connection. +Tuning the keepalive interval: 30 s is comfortably under nginx's 60 s `proxy_read_timeout` default and Cloudflare's 100 s hard limit. Behind Traefik with `respondingTimeouts.idleTimeout` bumped to `3600s` (as documented in the reverse-proxy setup), you can leave this at 30 s or raise it — the interval should sit at most half the smallest hop's idle timeout so a single missed Ping doesn't reap the connection. + +**Rename note (feat/message-bus branch)**: `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` was renamed to `OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS` — hard cutover, no fallback. Update any `.env` file that set the old name. ## Allocator Tuning diff --git a/example.env b/example.env index d67ecc9f..bffef96d 100644 --- a/example.env +++ b/example.env @@ -1112,7 +1112,28 @@ OXICLOUD_WOPI_ENABLED=false # * nginx `proxy_read_timeout` default 60s → ping ≤ 30s # * Cloudflare hard limit 100s → ping ≤ 45s # * Traefik with idleTimeout bumped to 3600s → 30s is safely under -#OXICLOUD_RT_WS_KEEPALIVE_SECONDS=30 +#OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS=30 + +# Message bus master switch. When `false`, /api/rt/ws and +# POST /api/rt/ticket are NOT registered at boot — Axum returns 404 +# for both, keeping monitoring dashboards free of 5xx noise. Clients +# discover this via GET /api/config.features.message_bus and skip WS +# setup entirely. Publish sites in the services stay unchanged (bus +# still runs internally; publishes to no subscribers are cheap no-ops). +# +# Why an operator might turn this off: each logged-in browser holds a +# persistent WebSocket connection while a folder view is open. Total +# sustained sessions on the server = users × open tabs, each consuming +# an fd, a few KB of tokio task state, and whatever tuple your L4/L7 +# load balancer keeps for the flow. On tightly-provisioned VPS +# deployments (low fd ulimit, tight memory), behind WebSocket-hostile +# reverse proxies that can't be reconfigured, or during an operational +# triage where you want to shed WS load fast, set this to false — the +# SPA falls back to its pre-message-bus behavior transparently +# (updates land on the next nav / refresh instead of live). +# +# Default: true. +#OXICLOUD_MESSAGEBUS_ENABLE=true # ----------------------------------------------------------------------------- # MEMORY ALLOCATOR TUNING (IMPORTANT FOR RAM USAGE) diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index c8c35418..e98c55df 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -6,6 +6,7 @@ import log from 'loglevel'; import { setSessionExpiredHandler } from '$lib/api/client'; import { initI18n } from '$lib/i18n/index.svelte'; +import { serverConfig } from '$lib/stores/serverConfig.svelte'; import { session } from '$lib/stores/session.svelte'; import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; @@ -14,7 +15,8 @@ import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; // needing to import anything. // // Log levels — namespaces used today: `oxi:upload` (delta + direct -// upload pipeline), `oxi:message-bus` (WebSocket client + `useTopic`). +// upload pipeline), `oxi:message-bus` (WebSocket client + `useTopic`), +// `oxi:config` (server-config boot fetch). // Levels: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent'. // Choices persist to `localStorage['loglevel:']` via loglevel. // @@ -119,5 +121,12 @@ export async function init(): Promise { // bound request and eat a `use_dpop_nonce` 401 → retry cycle. seedNonceFromCookie(); - await initI18n(); + // Boot in parallel: translations and server-config discovery are + // independent of each other, and both must resolve before any route + // mounts. `serverConfig.load()` primes the reactive feature-flag + // store; `useTopic` / `useFolderTopic` / `useReconnect` read from + // it to decide whether to open a WebSocket at all. See + // `stores/serverConfig.svelte.ts` for the failure semantics + // (defaults preserved on fetch error). + await Promise.all([initI18n(), serverConfig.load()]); } diff --git a/frontend/src/lib/api/endpoints/config.ts b/frontend/src/lib/api/endpoints/config.ts new file mode 100644 index 00000000..e71fe024 --- /dev/null +++ b/frontend/src/lib/api/endpoints/config.ts @@ -0,0 +1,19 @@ +/** + * `GET /api/config` — public server-configuration discovery. + * + * Called once at SPA boot from `hooks.client.ts` to hydrate the + * `serverConfig` reactive store. Feature flags and server-status live + * side-by-side on the response so a single round-trip primes the FE + * for the whole session. Subsequent live status changes propagate + * through the `X-Server-Status` response header (same shape). + * + * Unauthenticated — no session cookie required. Nothing on this + * endpoint is per-user or privacy-sensitive. + */ + +import { apiJson } from '$lib/api/client'; +import type { ServerConfig } from '$lib/api/types'; + +export function fetchServerConfig(): Promise { + return apiJson('/api/config'); +} diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 4758bf7c..9432e3df 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -878,3 +878,56 @@ export interface AdminSessionsPage { * but any in-flight JWT stays valid until its `exp`. */ access_token_expiry_secs: number; } + +// ── /api/config — public server-configuration discovery ──────────────────── + +/** Boolean matrix of enabled optional subsystems. Mirrors the server's + * `FeaturesConfig`; adding a field is additive (clients ignore unknown + * fields, no field is ever repurposed — same discipline as JSON-RPC + * error codes on the message bus). */ +export interface ServerFeatures { + /** Message bus over WebSocket. When `false`, `/api/rt/ws` and + * `/api/rt/ticket` are unmounted server-side — clients skip WS setup + * entirely (see `$lib/message-bus/client.svelte.ts`). */ + message_bus: boolean; + trash: boolean; + search: boolean; + sharing: boolean; + quotas: boolean; + music: boolean; + places: boolean; + faces: boolean; + video_thumbnails: boolean; + external_mounts: boolean; +} + +/** One row in `ServerStatus.migration` / `ServerStatus.rotation` — a + * server-side long-running operation surfacing its progress to the SPA + * banner. Same JSON shape both fields share. */ +export interface ServerStatusProgress { + /** Short target name (e.g. `"backend_migration"`, `"rotation_v2"`). */ + target: string; + migrated: number; + total: number; + /** Integer 0-100. */ + percent: number; +} + +/** Live server-status snapshot. Same shape and field names as the + * `X-Server-Status` header stamped on every response — the boot fetch + * from `/api/config` and the per-request header both share this wire + * vocabulary. Field-level absence means "nothing running"; the client + * can safely assume `readonly === false && !migration && !rotation` is + * the normal case. */ +export interface ServerStatus { + readonly: boolean; + migration?: ServerStatusProgress; + rotation?: ServerStatusProgress; +} + +/** Response of `GET /api/config`. Public, unauthenticated. */ +export interface ServerConfig { + version: string; + features: ServerFeatures; + server_status: ServerStatus; +} diff --git a/frontend/src/lib/composables/useReconnect.svelte.ts b/frontend/src/lib/composables/useReconnect.svelte.ts index 476b761a..2d0eb200 100644 --- a/frontend/src/lib/composables/useReconnect.svelte.ts +++ b/frontend/src/lib/composables/useReconnect.svelte.ts @@ -11,16 +11,20 @@ // `project_message_bus_reconnect_gap` memory for the gap it closes. import { messageBus } from '$lib/message-bus/client.svelte'; +import { serverConfig } from '$lib/stores/serverConfig.svelte'; /** * Register `cb` as a reconnect handler for the lifetime of the * calling component. Auto-unregisters on destroy via `$effect` * cleanup. Passing `null`/`undefined` is a no-op — convenient for * conditional wiring (`useReconnect(handlers.onReconnect)`). + * + * Also a no-op when the server has the message bus disabled — the + * WS never opens, so a reconnect callback can never fire. */ export function useReconnect(cb: (() => void) | null | undefined): void { $effect(() => { - if (!cb) return; + if (!cb || !serverConfig.features.message_bus) return; const release = messageBus.onReconnect(cb); return () => release(); }); diff --git a/frontend/src/lib/composables/useTopic.svelte.ts b/frontend/src/lib/composables/useTopic.svelte.ts index cf5fe4a9..cf0167ef 100644 --- a/frontend/src/lib/composables/useTopic.svelte.ts +++ b/frontend/src/lib/composables/useTopic.svelte.ts @@ -10,6 +10,7 @@ // re-subscribes when it changes. Static `topic`: pass a plain string. import { messageBus } from '$lib/message-bus/client.svelte'; +import { serverConfig } from '$lib/stores/serverConfig.svelte'; import type RtEventParams from '$lib/generated/message-bus/RtEventParams'; import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams'; @@ -32,6 +33,13 @@ export function useTopic( onRevoked?: (params: RtRevokedParams) => void ): void { $effect(() => { + // Server may have the message bus disabled (`/api/rt/ws` route + // unmounted → 404). Skip the subscribe entirely to avoid a + // pointless connect + circuit-breaker cycle. `serverConfig` is + // loaded before any route mounts (`hooks.client.ts` awaits it), + // so this read reflects the real server value, not the + // pre-load default. + if (!serverConfig.features.message_bus) return; const resolved = typeof topic === 'function' ? topic() : topic; if (!resolved) return; const release = messageBus.subscribe(resolved, onEvent, onRevoked); diff --git a/frontend/src/lib/stores/serverConfig.svelte.ts b/frontend/src/lib/stores/serverConfig.svelte.ts new file mode 100644 index 00000000..9c71791a --- /dev/null +++ b/frontend/src/lib/stores/serverConfig.svelte.ts @@ -0,0 +1,92 @@ +/** + * Server-configuration store — hydrated once at SPA boot from + * `GET /api/config`. + * + * Exposes feature-flag and server-status snapshots that the rest of + * the app reads reactively to enable/disable optional UI. The most + * consequential consumer today is the message bus: `useTopic`, + * `useFolderTopic`, and `useReconnect` all return early when + * `serverConfig.features.message_bus === false`, so a deployment + * with the bus disabled produces zero WS traffic from the client. + * + * Boot order (see `hooks.client.ts`): this store's `load()` runs + * alongside `initI18n()` before any route mounts, guaranteeing every + * composable reads a real value (never the pre-load defaults). + * + * Failure to load `/api/config` (network error, 5xx) leaves the + * defaults in place — every feature `true`, `readonly: false`. That's + * the pre-flag behavior; downstream WS setup then hits its own + * failure paths (503 for the endpoint if truly disabled, circuit + * breaker after 20 retries) instead of crashing boot. A warn line is + * logged either way so operators can spot the failure. + */ + +import log from 'loglevel'; + +import { fetchServerConfig } from '$lib/api/endpoints/config'; +import type { ServerConfig, ServerFeatures, ServerStatus } from '$lib/api/types'; + +/** Sensible defaults for every field. Used before `load()` resolves + * and as the fallback if the fetch fails — every feature enabled, + * server status nominal. Matches the pre-`OXICLOUD_MESSAGEBUS_ENABLE` + * behavior so an SPA that can't reach the endpoint still tries the + * same code paths it always did. */ +const DEFAULT_FEATURES: ServerFeatures = { + message_bus: true, + trash: true, + search: true, + sharing: true, + quotas: false, + music: true, + places: true, + faces: false, + video_thumbnails: true, + external_mounts: false +}; + +const DEFAULT_STATUS: ServerStatus = { + readonly: false +}; + +const cfgLog = log.getLogger('oxi:config'); + +class ServerConfigStore { + /** Server version — populated after `load()`. `null` before. */ + version = $state(null); + /** Feature flags. Defaults are all-enabled so pre-load code paths + * don't accidentally hide UI while the fetch is in flight. */ + features = $state({ ...DEFAULT_FEATURES }); + /** Server-status snapshot. Live changes after `load()` propagate + * through the `X-Server-Status` header (see + * `stores/serverStatus.svelte.ts` — separate store, updated by + * `apiFetch`). This store's `server_status` reflects only the + * boot snapshot; consumers that need live status should read + * the other store. */ + serverStatus = $state({ ...DEFAULT_STATUS }); + /** `true` once `load()` has resolved (success OR failure). Guards + * callers that want to skip work until the boot snapshot is in. */ + loaded = $state(false); + + async load(): Promise { + try { + const cfg: ServerConfig = await fetchServerConfig(); + this.version = cfg.version; + this.features = cfg.features; + this.serverStatus = cfg.server_status; + cfgLog.debug('server config loaded', { + version: cfg.version, + message_bus: cfg.features.message_bus + }); + } catch (err) { + // Fall through to defaults — SPA still boots. Any feature + // actually disabled server-side will surface as a 404 at + // call time (which is fine — that's how the guards are + // designed to be observable). + cfgLog.warn('server config fetch failed — using defaults', { error: err }); + } finally { + this.loaded = true; + } + } +} + +export const serverConfig = new ServerConfigStore(); diff --git a/src/bin/generate-asyncapi.rs b/src/bin/generate-asyncapi.rs index 45c5f75d..3df67207 100644 --- a/src/bin/generate-asyncapi.rs +++ b/src/bin/generate-asyncapi.rs @@ -186,7 +186,7 @@ fn operations() -> Value { ] }, // Application-layer keepalive. Separate from the RFC 6455 Ping - // control frame the server sends on `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` + // control frame the server sends on `OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS` // (which is transport-level and not modelled in AsyncAPI). This // operation lets a client actively confirm the socket is // end-to-end alive when transport-level Pings alone can't rule diff --git a/src/common/config.rs b/src/common/config.rs index f006fb99..6d5c9105 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -2284,6 +2284,21 @@ pub struct FeaturesConfig { /// Env: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`. pub webdav_drive_listing_prefix: String, + /// Message-bus master switch. When `false`, the WS route + /// `/api/rt/ws` and the ticket endpoint `POST /api/rt/ticket` + /// are **not registered** at boot — Axum returns 404 for both, + /// no 5xx alerts, no ambiguity. Publish sites in the services + /// stay unchanged (the in-process bus still runs, publishes to + /// nobody are cheap no-ops), so no service code paths branch on + /// this flag — the toggle is purely at the API surface. + /// + /// Clients discover this via `GET /api/config.features.message_bus` + /// and skip WS setup entirely when false — no reconnect flood, + /// no wasted round-trips. + /// + /// Env: `OXICLOUD_MESSAGEBUS_ENABLE` (default `true`). + pub enable_message_bus: bool, + /// Background purge of expired `storage.role_grants` rows. /// /// The AuthZ engine already filters expired grants out of every @@ -2483,6 +2498,7 @@ impl Default for FeaturesConfig { // maps to the caller's default drive; drive listing is // reachable at `/webdav/@drive/`. webdav_drive_listing_prefix: "@drive".to_string(), + enable_message_bus: true, // Message bus (WS + ticket) on by default grant_cleanup: GrantCleanupConfig::default(), } } @@ -3357,6 +3373,19 @@ impl AppConfig { config.features.enable_trash = val; } + // Message bus (WS + ticket endpoints). Follows the + // `OXICLOUD_MESSAGEBUS_*` naming rather than + // `OXICLOUD_ENABLE_MESSAGEBUS` — the `MESSAGEBUS` prefix groups + // this with `OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS` at the env + // level. Internal struct field keeps the codebase-wide + // `enable_*` convention. + if let Ok(enable_message_bus) = + env::var("OXICLOUD_MESSAGEBUS_ENABLE").map(|v| v.parse::()) + && let Ok(val) = enable_message_bus + { + config.features.enable_message_bus = val; + } + if let Ok(enable_search) = env::var("OXICLOUD_ENABLE_SEARCH").map(|v| v.parse::()) && let Ok(val) = enable_search { diff --git a/src/interfaces/api/handlers/config_handler.rs b/src/interfaces/api/handlers/config_handler.rs new file mode 100644 index 00000000..56eafc6b --- /dev/null +++ b/src/interfaces/api/handlers/config_handler.rs @@ -0,0 +1,134 @@ +//! `GET /api/config` — public server-configuration discovery. +//! +//! Advertises the subset of `AppState` a client needs to know at boot: +//! feature flags (which optional systems are enabled), server version, +//! and the current server-status snapshot (matches whatever the +//! `X-Server-Status` header carries live). Everything auth-related +//! stays under `GET /api/auth/oidc/providers` — the two endpoints are +//! sibling capability advertisements, not one canonical thing. +//! +//! # Scope +//! +//! Only fields with **no privacy implications**: +//! +//! - `features.*` — boolean matrix of enabled subsystems (message bus, +//! trash, search, sharing, quotas, plugins, WOPI). Same information +//! any logged-in caller could infer from probing endpoints; giving +//! it up front is a UX win. +//! - `version` — same string the `/api/version` endpoint returns +//! (CARGO_PKG_VERSION + git SHA). Public build metadata. +//! - `server_status` — a snapshot of the mutable server-status state +//! (maintenance mode, degraded mode, etc.). Same shape the +//! `X-Server-Status` header stamps on every response; this endpoint +//! just lets the FE hydrate the store at boot without waiting for +//! the first authenticated response. +//! +//! Anything requiring auth (per-user preferences, admin-visible +//! deployment secrets, session state) does NOT go here — those live +//! on `/api/auth/me` or `/api/admin/*`. + +use std::sync::Arc; + +use axum::{Json, extract::State}; +use serde::Serialize; + +use crate::common::di::AppState; +use crate::interfaces::middleware::server_status::{HeaderPayload, build_header_payload}; + +/// Server-configuration DTO. Additive over time — clients ignore +/// unknown fields, and no field is ever repurposed (same discipline +/// as JSON-RPC error codes on the message bus). +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ServerConfigDto { + /// Server version — `CARGO_PKG_VERSION` from `Cargo.toml`. Matches + /// what `GET /api/version` returns. + pub version: &'static str, + + /// Feature flags — which subsystems the server has enabled. + /// Clients gate optional UI on these (e.g. hide the notification + /// bell if `features.message_bus` is false, since the bell would + /// have no delivery channel). + pub features: FeaturesDto, + + /// Live server-status snapshot — exact same shape and field + /// names as the `X-Server-Status` response header. Clients use + /// this to hydrate their reactive store at boot; subsequent live + /// changes propagate through the header on every other request + /// (the middleware and this endpoint share `build_header_payload` + /// so drift is impossible). Non-optional so the client always + /// has a definite value; `readonly: false` with no `migration` + /// or `rotation` is the "everything nominal" case. + pub server_status: HeaderPayload, +} + +/// Feature-flag block within [`ServerConfigDto`]. One boolean per +/// optional subsystem. Adding a new feature: append a field with a +/// default that matches the server-side default; NEVER remove a field +/// (client code may depend on the absence of a `false` value to mean +/// "unknown"). +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct FeaturesDto { + /// Message bus over WebSocket. When `false`, `/api/rt/ws` and + /// `/api/rt/ticket` are not registered — clients skip WS setup + /// entirely. See `FeaturesConfig::enable_message_bus`. + pub message_bus: bool, + /// Recycle bin / soft-delete flow. When `false`, deletes are + /// permanent — no `/api/trash` endpoint. See + /// `FeaturesConfig::enable_trash`. + pub trash: bool, + /// Full-text and metadata search (`/api/search/*`). See + /// `FeaturesConfig::enable_search`. + pub search: bool, + /// File sharing (public share links + user-to-user grants). See + /// `FeaturesConfig::enable_file_sharing`. + pub sharing: bool, + /// Per-user storage-quota enforcement on the upload path. See + /// `FeaturesConfig::enable_user_storage_quotas`. + pub quotas: bool, + /// Music player + playlists. See `FeaturesConfig::enable_music`. + pub music: bool, + /// Photo-map ("Places") tab. See `FeaturesConfig::enable_places`. + pub places: bool, + /// Face detection + identity clustering ("People"). Biometric — + /// OFF by default. See `FeaturesConfig::enable_faces`. + pub faces: bool, + /// Server-side video-thumbnail generation via ffmpeg. See + /// `FeaturesConfig::enable_video_thumbnails`. + pub video_thumbnails: bool, + /// Admin-configured external filesystem mounts. See + /// `FeaturesConfig::enable_external_mounts`. + pub external_mounts: bool, +} + +/// `GET /api/config` — return the public server-configuration +/// snapshot. Unauthenticated. No cache header — values change on +/// server-restart / feature-toggle / status flip, and the endpoint +/// is called at most once per SPA boot per client. Adding a short +/// `Cache-Control` TTL later is safe if load ever becomes a concern. +#[utoipa::path( + get, + path = "/api/config", + tag = "config", + responses( + (status = 200, description = "Public server configuration", body = ServerConfigDto), + ), +)] +pub async fn get_config(State(state): State>) -> Json { + let f = &state.core.config.features; + Json(ServerConfigDto { + version: env!("CARGO_PKG_VERSION"), + features: FeaturesDto { + message_bus: f.enable_message_bus, + trash: f.enable_trash, + search: f.enable_search, + sharing: f.enable_file_sharing, + quotas: f.enable_user_storage_quotas, + music: f.enable_music, + places: f.enable_places, + faces: f.enable_faces, + video_thumbnails: f.enable_video_thumbnails, + external_mounts: f.enable_external_mounts, + }, + server_status: build_header_payload(&state), + }) +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 19e33984..c3b3e7b0 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -7,6 +7,7 @@ pub mod caldav_handler; pub mod caller_flags; pub mod carddav_handler; pub mod chunked_upload_handler; +pub mod config_handler; pub mod contacts_handler; pub mod dedup_handler; pub mod delta_upload_handler; diff --git a/src/interfaces/api/handlers/rt_ws.rs b/src/interfaces/api/handlers/rt_ws.rs index 2b1944e8..57524013 100644 --- a/src/interfaces/api/handlers/rt_ws.rs +++ b/src/interfaces/api/handlers/rt_ws.rs @@ -90,7 +90,7 @@ const OUTBOUND_CHANNEL_CAPACITY: usize = 512; /// default and Cloudflare's 100 s hard limit; behind Traefik we /// document a much longer `idleTimeout` anyway. /// -/// Overridable at server start via `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` +/// Overridable at server start via `OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS` /// — test suites drop it to a low value to exercise the keepalive path /// within a bounded wall-clock. const DEFAULT_KEEPALIVE_SECONDS: u64 = 30; @@ -101,7 +101,7 @@ const DEFAULT_KEEPALIVE_SECONDS: u64 = 30; /// useful for smoke tests that toggle the value on the fly. fn keepalive_interval() -> Duration { Duration::from_secs( - std::env::var("OXICLOUD_RT_WS_KEEPALIVE_SECONDS") + std::env::var("OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS") .ok() .and_then(|s| s.parse().ok()) .filter(|&n: &u64| n > 0) diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 9bc4cfcc..4376d02e 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -55,12 +55,14 @@ use crate::interfaces::api::handlers::auth_handler::SystemStatus; use crate::interfaces::api::handlers::chunked_upload_handler::{ CompleteUploadResponse, CreateUploadRequest, }; +use crate::interfaces::api::handlers::config_handler::{FeaturesDto, ServerConfigDto}; use crate::interfaces::api::handlers::contacts_handler::{ AddMemberRequest, AddressBookResponse, CreateAddressBookRequest, CreateContactRequest, GroupNameRequest, UpdateAddressBookRequest, UpdateContactRequest, }; use crate::interfaces::api::handlers::dedup_handler::{HashCheckResponse, StatsResponse}; use crate::interfaces::api::handlers::file_handler::MoveFilePayload; +use crate::interfaces::middleware::server_status::{HeaderPayload, ProgressHeader}; #[derive(OpenApi)] #[openapi( @@ -332,6 +334,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::subject_group_handler::remove_user_member, handlers::subject_group_handler::remove_group_member, handlers::subject_group_handler::list_effective_members, + // Public server-config discovery. + handlers::config_handler::get_config, ), components( schemas( @@ -375,6 +379,11 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; ChangePasswordDto, RefreshTokenDto, SystemStatus, + // Public server-config discovery — `GET /api/config`. + ServerConfigDto, + FeaturesDto, + HeaderPayload, + ProgressHeader, OidcProviderInfoDto, OidcExchangeDto, // Admin sessions panel — wire shape for `/api/admin/sessions`. diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 5d9aba3b..fc092204 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -167,6 +167,16 @@ pub fn create_public_api_routes(app_state: &Arc) -> Router) -> Router> { // the protected router (auth + DPoP), so the caller proves session // + DPoP-key possession before a ticket is minted. See // `handlers/rt_ticket_handler.rs` and `docs/plan/message-bus.md § F`. - router = router.route( - "/rt/ticket", - post(crate::interfaces::api::handlers::rt_ticket_handler::issue_rt_ticket) - .with_state(app_state.clone()), - ); + // + // Gated by `enable_message_bus`: when disabled, the route is NOT + // registered — Axum returns 404 (no 5xx alerts, no ambiguous 403). + // The paired WS route in `main.rs` uses the same guard. + if app_state.core.config.features.enable_message_bus { + router = router.route( + "/rt/ticket", + post(crate::interfaces::api::handlers::rt_ticket_handler::issue_rt_ticket) + .with_state(app_state.clone()), + ); + } // The WS upgrade (`GET /api/rt/ws`) is registered OUTSIDE the // protected-api middleware stack — a browser cannot attach a diff --git a/src/interfaces/middleware/server_status.rs b/src/interfaces/middleware/server_status.rs index 80926376..49057a8e 100644 --- a/src/interfaces/middleware/server_status.rs +++ b/src/interfaces/middleware/server_status.rs @@ -45,34 +45,37 @@ pub const SERVER_STATUS_HEADER: &str = "x-server-status"; /// Compact JSON shape written into the header. Fields are documented /// in `common::migration_progress::MigrationProgress`. /// -/// Kept internal so the wire format can evolve. Frontend treats the -/// header as opaque JSON and pattern-matches on the fields it -/// currently understands. -#[derive(serde::Serialize)] -struct HeaderPayload { - readonly: bool, +/// Public because `GET /api/config` returns the same shape as the +/// initial hydration snapshot for FE stores — the endpoint mirrors +/// whatever the header carries so the client has a single wire +/// vocabulary to render. Frontend treats the value as opaque JSON +/// and pattern-matches on the fields it currently understands; +/// adding a field is additive. +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct HeaderPayload { + pub readonly: bool, #[serde(skip_serializing_if = "Option::is_none")] - migration: Option, + pub migration: Option, /// K3: independent of `readonly` — rotation does NOT engage the /// app-wide read-only flag, so the frontend needs a distinct /// signal to know "rotation is running, show the rotation /// banner instead of migration banner". #[serde(skip_serializing_if = "Option::is_none")] - rotation: Option, + pub rotation: Option, } /// Shared progress shape used by both `migration` and `rotation` /// header fields — same struct name, same JSON field names. Frontend /// treats them identically at the render layer. -#[derive(serde::Serialize)] -struct ProgressHeader { +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct ProgressHeader { // `target` is owned here — the RwLock guard is released before // serialisation, so a borrowed slice wouldn't survive. Names // are small (`[a-z0-9_-]{1,32}`) so the copy is trivial. - target: String, - migrated: u64, - total: u64, - percent: u8, + pub target: String, + pub migrated: u64, + pub total: u64, + pub percent: u8, } impl ProgressHeader { @@ -86,6 +89,42 @@ impl ProgressHeader { } } +/// Build the same [`HeaderPayload`] the middleware stamps into the +/// `X-Server-Status` header, without touching a response. Used by +/// `GET /api/config` so the client sees the exact shape the header +/// would carry at that moment — no drift, no dual serialisers. +/// +/// Cost model matches the middleware: +/// - Hot path (nothing active) returns `readonly: false` with no +/// allocations for the progress sub-objects. +/// - Cold path allocates the progress rows exactly once each. +pub fn build_header_payload(state: &AppState) -> HeaderPayload { + let readonly = state.migration_readonly.load(Ordering::Relaxed); + + let migration = if readonly { + state + .migration_progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(ProgressHeader::from_snapshot) + } else { + None + }; + let rotation = state + .rotation_progress + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(ProgressHeader::from_snapshot); + + HeaderPayload { + readonly, + migration, + rotation, + } +} + pub async fn server_status_middleware( State(state): State>, request: Request, diff --git a/src/main.rs b/src/main.rs index c10105c9..d5f4b041 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1042,22 +1042,6 @@ async fn run() -> Result<(), Box> { ) // Public API routes (share access, i18n) — no auth required .nest("/api", public_api_routes.layer(access_log!("http::api"))) - // Message-bus WebSocket. Registered OUTSIDE `protected_api` - // because a browser cannot attach a `DPoP:` header to - // `new WebSocket()` (RFC 6455 only lets us set - // `Sec-WebSocket-Protocol`), so the standard auth + DPoP - // stack would 401 every DPoP-bound session. The handler - // self-authenticates from either a ticket subprotocol - // (minted by `POST /api/rt/ticket` under the full chain) - // or a bearer token (`rt-hurl-helper` test path). - // See `handlers/rt_ws.rs` module doc and - // `docs/plan/message-bus.md § F`. - .route( - "/api/rt/ws", - axum::routing::get(oxicloud::interfaces::api::handlers::rt_ws::rt_ws_handler) - .with_state(app_state.clone()) - .layer(access_log!("http::api")), - ) // All other API routes are protected by auth middleware .nest("/api", protected_api.layer(access_log!("http::api"))) // RFC 6764 well-known discovery (public, no auth — just redirects) @@ -1072,6 +1056,37 @@ async fn run() -> Result<(), Box> { // the static surface is split into its own router. .merge(web_routes.layer(access_log!("http::web"))); + // Message-bus WebSocket. Registered OUTSIDE `protected_api` + // because a browser cannot attach a `DPoP:` header to + // `new WebSocket()` (RFC 6455 only lets us set + // `Sec-WebSocket-Protocol`), so the standard auth + DPoP + // stack would 401 every DPoP-bound session. The handler + // self-authenticates from either a ticket subprotocol + // (minted by `POST /api/rt/ticket` under the full chain) + // or a bearer token (`rt-hurl-helper` test path). + // See `handlers/rt_ws.rs` module doc and + // `docs/plan/message-bus.md § F`. + // + // Guarded by `enable_message_bus`: when false, the route is + // NOT registered → Axum returns 404 for `/api/rt/ws` and the + // ticket endpoint (already gated inside `create_api_routes`). + // Clients discover this via `/api/config` and skip WS setup. + if app_state.core.config.features.enable_message_bus { + app = app.route( + "/api/rt/ws", + axum::routing::get(oxicloud::interfaces::api::handlers::rt_ws::rt_ws_handler) + .with_state(app_state.clone()) + .layer(access_log!("http::api")), + ); + } else { + tracing::info!( + target: "audit", + event = "config.feature_disabled", + feature = "message_bus", + "message bus disabled — /api/rt/ws and /api/rt/ticket not registered (404)", + ); + } + // Mount Nextcloud routes (uses its own Basic Auth middleware). // **Merged BEFORE the trace + request-id layers** so NC requests // get the same `request_id` / `user_id` / `client_ip` span diff --git a/tests/api/rt_bus_check.sh b/tests/api/rt_bus_check.sh index 58ca2d55..a3a5880d 100755 --- a/tests/api/rt_bus_check.sh +++ b/tests/api/rt_bus_check.sh @@ -297,7 +297,7 @@ log "S4 OK" # ── Scenario 5 — Server-initiated keepalive ───────────────────────────────── # Verifies the WS handler sends RFC 6455 Ping control frames on the -# `OXICLOUD_RT_WS_KEEPALIVE_SECONDS` cadence (1 s in tests/common/server.env). +# `OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS` cadence (1 s in tests/common/server.env). # Two invariants: # (a) idling on a live subscription surfaces multiple Ping frames — the # keepalive interval genuinely fires, not just at connect and never again. diff --git a/tests/common/server.env b/tests/common/server.env index 3adc50ac..b2bf16f7 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -40,7 +40,7 @@ OXICLOUD_NEXTCLOUD_ENABLED=true # in `tests/api/rt_bus_check.sh` can observe multiple keepalive frames # arriving within a bounded (few-seconds) wall-clock. Only observed # by `rt_ws_handler`, which reads it at each WS connect time. -OXICLOUD_RT_WS_KEEPALIVE_SECONDS=1 +OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS=1 # Multi-entry storage config — see docs/plan/storage-multi-entry.md. # `local_main` is FIRST so the boot fallback picks it when no active