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:
+6
-3
@@ -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
|
||||
|
||||
|
||||
+22
-1
@@ -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)
|
||||
|
||||
@@ -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:<namespace>']` via loglevel.
|
||||
//
|
||||
@@ -119,5 +121,12 @@ export async function init(): Promise<void> {
|
||||
// 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()]);
|
||||
}
|
||||
|
||||
@@ -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<ServerConfig> {
|
||||
return apiJson<ServerConfig>('/api/config');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string | null>(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<ServerFeatures>({ ...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<ServerStatus>({ ...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<void> {
|
||||
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();
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
//
|
||||
// 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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user