feat(config): add server config + can disable message-bus

- server now provide it's config via /api/config (possibility to feature flag)
- client use /api/config to enable / disable some features
- capability to disable the message bus, somme OPS may not want this feature and
  consume persistent connections from server (websocket):
  OXICLOUD_MESSAGEBUS_ENABLE (true by default)
This commit is contained in:
Edouard Vanbelle
2026-09-11 12:10:56 +02:00
parent 758b1e0d6e
commit 5083eaeaba
19 changed files with 499 additions and 47 deletions
+1 -1
View File
@@ -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
+29
View File
@@ -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::<bool>())
&& 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::<bool>())
&& let Ok(val) = enable_search
{
@@ -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<Arc<AppState>>) -> Json<ServerConfigDto> {
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),
})
}
+1
View File
@@ -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;
+2 -2
View File
@@ -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)
+9
View File
@@ -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`.
+21 -5
View File
@@ -167,6 +167,16 @@ pub fn create_public_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppStat
router = router.route("/version", get(get_version));
router = router.route("/openapi.json", get(get_openapi_spec));
// Server-configuration discovery endpoint — public, unauthenticated.
// Returns feature flags, version, and a snapshot of the server-status
// header for one-shot boot hydration by the SPA. See
// `handlers/config_handler.rs` for the DTO shape and rationale.
router = router.route(
"/config",
get(crate::interfaces::api::handlers::config_handler::get_config)
.with_state(app_state.clone()),
);
router
}
@@ -678,11 +688,17 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// 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
+53 -14
View File
@@ -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<ProgressHeader>,
pub migration: Option<ProgressHeader>,
/// 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<ProgressHeader>,
pub rotation: Option<ProgressHeader>,
}
/// 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<Arc<AppState>>,
request: Request,
+31 -16
View File
@@ -1042,22 +1042,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
)
// 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<dyn std::error::Error>> {
// 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