mirror of
https://github.com/ruvnet/RuView
synced 2026-07-22 17:23:19 +00:00
feat(ws): gate WebSocket upgrades behind bearer-or-ticket (ADR-272)
Closes the hole measured in 7d6d6694. Before, with RUVIEW_API_TOKEN set, a real
handshake carrying no credential:
/ws/sensing 101 · /ws/introspection 101 · /api/v1/stream/pose 101
(/api/v1/models correctly 401)
After, same server, same handshake:
/ws/sensing 401 · /ws/introspection 401 · /api/v1/stream/pose 401
bearer on the upgrade -> 101
POST /api/v1/ws-ticket then ?ticket=<value> -> 101
the same ticket replayed -> 401
a bogus ticket -> 401
POST /api/v1/ws-ticket unauthenticated -> 401
Two ways in, matching what each client can actually do:
- Native clients (Python, Rust CLI, TS MCP) send a normal Authorization header
on the upgrade. They were never browser-constrained; forcing them through a
ticket would add a round-trip and a second credential path for nothing.
- Browsers cannot set that header, so they exchange their credential at
POST /api/v1/ws-ticket — an ordinary request, where they can — for a 30s
single-use ticket passed as ?ticket=.
A ticket inherits the issuing principal's scopes, so a sensing:read session
cannot mint one that outranks itself, and it is not a REST credential: pinned
by a test that `?ticket=` on /api/v1/models is still 401.
ESCAPE HATCH (RUVIEW_WS_LEGACY_UNAUTHENTICATED=1) restores the old behaviour
for deployments that cannot update server and UI in lockstep. It is a migration
aid, not a supported configuration, and it says so on every boot in a warning
that names the actual exposure ("the live sensing stream — presence, pose and
vital signs — is readable by anyone who can reach this port"). Its blast radius
is exactly the WebSocket paths: a test pins that it does not weaken REST.
The flag is read once at construction, so a mid-flight environment change
cannot silently open these paths on a running server.
SUPERSEDES the PR #1313 test `enabled_exempts_pose_stream_websocket`, which
asserted the old exemption. Its reasoning about browsers was correct; the
conclusion was not. Renamed and inverted rather than deleted, with the history
in the doc comment — and the half that still matters (the WebSocket rule must
not leak to other /api/v1/* paths) is kept.
Deployments with auth OFF see no change at all — pinned by a test.
Tests: 522 passed in the sensing server (9 new WS-gating, 12 ticket-store).
STILL OUTSTANDING for ADR-272: the browser UI does not yet fetch a ticket, so
it needs the escape hatch until ui/services/api.service.js is updated. That is
the next commit, not a permanent state.
Co-Authored-By: Ruflo & AQE
This commit is contained in:
@@ -74,14 +74,44 @@ pub const COGNITUM_ISSUER: &str = "https://auth.cognitum.one";
|
||||
/// Path prefix the middleware protects when auth is enabled.
|
||||
pub const PROTECTED_PREFIX: &str = "/api/v1/";
|
||||
|
||||
/// `/api/v1/stream/pose` is a WebSocket upgrade endpoint reachable from
|
||||
/// browser code. Unlike a plain fetch(), the browser `WebSocket` constructor
|
||||
/// cannot attach an `Authorization` header to the handshake request, so this
|
||||
/// path can never carry a bearer token from a stock browser client — the
|
||||
/// same reasoning that already exempts `/ws/sensing` (see module docs).
|
||||
/// Exempted here rather than moved out of `/api/v1/*` to avoid an API
|
||||
/// surface change for existing clients.
|
||||
const EXEMPT_PATHS: &[&str] = &["/api/v1/stream/pose"];
|
||||
/// WebSocket upgrade endpoints. Previously ungated — `/ws/*` sat outside
|
||||
/// [`PROTECTED_PREFIX`] and `/api/v1/stream/pose` was an explicit exemption —
|
||||
/// because a browser's `WebSocket` constructor cannot attach an
|
||||
/// `Authorization` header to the handshake.
|
||||
///
|
||||
/// Measured consequence, with `RUVIEW_API_TOKEN` set and a real handshake
|
||||
/// carrying no credential: all three returned `101 Switching Protocols` while
|
||||
/// `/api/v1/models` returned `401`. The control plane was locked and the data
|
||||
/// plane — live presence, pose, vitals — was open.
|
||||
///
|
||||
/// They are now gated, and accept **either** a bearer (native clients, which
|
||||
/// are not browser-constrained) **or** a single-use ticket (ADR-272).
|
||||
pub const WS_PATHS: &[&str] = &[
|
||||
"/ws/sensing",
|
||||
"/ws/introspection",
|
||||
"/api/v1/stream/pose",
|
||||
];
|
||||
|
||||
/// Restore the pre-ADR-272 behaviour: WebSocket upgrades accepted with no
|
||||
/// credential even when auth is on.
|
||||
///
|
||||
/// A migration aid, not a supported configuration. It exists because gating
|
||||
/// these paths breaks a browser UI that has not yet been updated to fetch a
|
||||
/// ticket, and some deployments cannot update server and UI in lockstep. It
|
||||
/// logs a warning naming the exposure on every boot, deliberately hard to
|
||||
/// ignore in a log.
|
||||
pub const LEGACY_WS_ENV: &str = "RUVIEW_WS_LEGACY_UNAUTHENTICATED";
|
||||
|
||||
fn legacy_ws_unauthenticated() -> bool {
|
||||
matches!(
|
||||
std::env::var(LEGACY_WS_ENV).as_deref(),
|
||||
Ok("1") | Ok("true") | Ok("TRUE")
|
||||
)
|
||||
}
|
||||
|
||||
fn is_ws_path(path: &str) -> bool {
|
||||
WS_PATHS.contains(&path)
|
||||
}
|
||||
|
||||
/// Cognitum OAuth verification state. Built once at boot and shared.
|
||||
pub struct OAuthState {
|
||||
@@ -118,6 +148,11 @@ pub struct AuthState {
|
||||
token: Option<Arc<String>>,
|
||||
/// Cognitum OAuth verification, if enabled.
|
||||
oauth: Option<Arc<OAuthState>>,
|
||||
/// Single-use WebSocket tickets (ADR-272).
|
||||
tickets: crate::ws_ticket::TicketStore,
|
||||
/// Cached at construction so a mid-flight env change cannot silently open
|
||||
/// the WebSocket paths on a running server.
|
||||
legacy_ws: bool,
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
@@ -130,6 +165,8 @@ impl AuthState {
|
||||
AuthState {
|
||||
token: Some(Arc::new(s)),
|
||||
oauth: None,
|
||||
tickets: crate::ws_ticket::TicketStore::new(),
|
||||
legacy_ws: legacy_ws_unauthenticated(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,7 +216,33 @@ impl AuthState {
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
Ok(AuthState { token, oauth })
|
||||
let legacy_ws = legacy_ws_unauthenticated();
|
||||
if legacy_ws && (token.is_some() || oauth.is_some()) {
|
||||
tracing::warn!(
|
||||
"{LEGACY_WS_ENV} is set: WebSocket upgrades ({}) accept connections with NO \
|
||||
credential even though API auth is ON. The live sensing stream — presence, \
|
||||
pose and vital signs — is readable by anyone who can reach this port. This is \
|
||||
a migration aid for UIs not yet updated to fetch a ticket; unset it as soon \
|
||||
as the UI is updated.",
|
||||
WS_PATHS.join(", ")
|
||||
);
|
||||
}
|
||||
Ok(AuthState {
|
||||
token,
|
||||
oauth,
|
||||
tickets: crate::ws_ticket::TicketStore::new(),
|
||||
legacy_ws,
|
||||
})
|
||||
}
|
||||
|
||||
/// The ticket store, for the `POST /api/v1/ws-ticket` handler.
|
||||
pub fn tickets(&self) -> &crate::ws_ticket::TicketStore {
|
||||
&self.tickets
|
||||
}
|
||||
|
||||
/// Whether the legacy unauthenticated-WebSocket escape hatch is active.
|
||||
pub fn legacy_ws_enabled(&self) -> bool {
|
||||
self.legacy_ws
|
||||
}
|
||||
|
||||
/// Whether the middleware will enforce auth on `/api/v1/*` requests.
|
||||
@@ -251,8 +314,36 @@ pub async fn require_bearer(
|
||||
if !auth.is_enabled() {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let path = request.uri().path();
|
||||
if !path.starts_with(PROTECTED_PREFIX) || EXEMPT_PATHS.contains(&path) {
|
||||
let path = request.uri().path().to_string();
|
||||
|
||||
// WebSocket upgrades: bearer OR single-use ticket (ADR-272). Checked before
|
||||
// the prefix test because `/api/v1/stream/pose` is both a WS path and under
|
||||
// the protected prefix.
|
||||
if is_ws_path(&path) {
|
||||
if auth.legacy_ws {
|
||||
return next.run(request).await;
|
||||
}
|
||||
if let Some(ticket) = crate::ws_ticket::ticket_from_uri(request.uri()) {
|
||||
// Consumed here — one attempt per ticket, valid or not, so a
|
||||
// guessed value cannot be retried and a real one cannot be replayed.
|
||||
if let Some(grant) = auth.tickets.consume(&ticket) {
|
||||
let holds_read = grant
|
||||
.scopes
|
||||
.as_deref()
|
||||
// `None` = issued by the legacy static token, which predates
|
||||
// scopes and carries full authority.
|
||||
.map_or(true, |s| s.split_whitespace().any(|x| x == scope::SENSING_READ));
|
||||
if holds_read {
|
||||
tracing::debug!(path = %path, subject = ?grant.subject, "WebSocket authorized by ticket");
|
||||
return next.run(request).await;
|
||||
}
|
||||
tracing::debug!(path = %path, "ticket lacked the scope this stream requires");
|
||||
}
|
||||
return unauthorized(&auth);
|
||||
}
|
||||
// No ticket: fall through to the bearer path below, which is how a
|
||||
// native (non-browser) client authenticates a WebSocket.
|
||||
} else if !path.starts_with(PROTECTED_PREFIX) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
@@ -285,7 +376,12 @@ pub async fn require_bearer(
|
||||
|
||||
// 2. Cognitum OAuth (ADR-271).
|
||||
if let Some(oauth) = auth.oauth.as_ref() {
|
||||
let required = required_scope_for(request.method(), path);
|
||||
let required = if is_ws_path(&path) {
|
||||
// A stream is a read, regardless of the HTTP verb on the upgrade.
|
||||
scope::SENSING_READ
|
||||
} else {
|
||||
required_scope_for(request.method(), &path)
|
||||
};
|
||||
let config = VerifierConfig {
|
||||
issuer: oauth.issuer.clone(),
|
||||
required_scope: required.to_string(),
|
||||
@@ -298,7 +394,7 @@ pub async fn require_bearer(
|
||||
client_id = %principal.client_id,
|
||||
jti = %principal.token_id,
|
||||
scope = %required,
|
||||
path = %path,
|
||||
path = %path.as_str(),
|
||||
"OAuth request authorized"
|
||||
);
|
||||
// Downstream handlers can attribute the request without
|
||||
@@ -310,7 +406,7 @@ pub async fn require_bearer(
|
||||
// Logged, never returned: the reason a token failed is useful
|
||||
// to an operator and useful to an attacker probing for which
|
||||
// claim to forge next. The response stays a flat 401.
|
||||
tracing::debug!(error = %e, path = %path, required_scope = %required, "OAuth verification failed");
|
||||
tracing::debug!(error = %e, path = %path.as_str(), required_scope = %required, "OAuth verification failed");
|
||||
return unauthorized(&auth);
|
||||
}
|
||||
}
|
||||
@@ -579,20 +675,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `/api/v1/stream/pose` is a WebSocket upgrade the browser `WebSocket`
|
||||
/// constructor drives directly — it cannot attach an `Authorization`
|
||||
/// header, so this path must stay reachable even with auth ON (mirrors
|
||||
/// the existing `/ws/sensing` exemption, just inside the `/api/v1/*`
|
||||
/// prefix this time).
|
||||
/// SUPERSEDED by ADR-272. This was `enabled_exempts_pose_stream_websocket`,
|
||||
/// which asserted `/api/v1/stream/pose` stayed reachable with no bearer
|
||||
/// because a browser cannot set `Authorization` on an upgrade (PR #1313).
|
||||
///
|
||||
/// That reasoning about browsers is still true — the conclusion was not.
|
||||
/// Measured on a server with auth ON: a credential-less handshake to
|
||||
/// `/api/v1/stream/pose`, `/ws/sensing` and `/ws/introspection` all
|
||||
/// returned `101`, so the REST control plane was locked while the live
|
||||
/// sensing stream was open. The browser limitation is now answered by a
|
||||
/// single-use ticket rather than by an exemption.
|
||||
///
|
||||
/// The half of the original test that still matters is kept: whatever the
|
||||
/// WebSocket rule is, it must not leak to other `/api/v1/*` paths.
|
||||
#[tokio::test]
|
||||
async fn enabled_exempts_pose_stream_websocket() {
|
||||
async fn the_pose_stream_websocket_is_no_longer_exempt() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(
|
||||
status(r.clone(), "GET", "/api/v1/stream/pose", None).await,
|
||||
StatusCode::OK,
|
||||
"pose stream WS must stay reachable without a bearer token"
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"the pose stream must no longer accept a credential-less upgrade"
|
||||
);
|
||||
// The exemption is narrow: it must not leak to other /api/v1/* paths.
|
||||
// Preserved from the original: the WebSocket rule stays narrow.
|
||||
assert_eq!(
|
||||
status(r, "GET", "/api/v1/info", None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
@@ -747,6 +851,8 @@ mod oauth_tests {
|
||||
AuthState {
|
||||
token: None,
|
||||
oauth: Some(oauth_state()),
|
||||
tickets: crate::ws_ticket::TicketStore::new(),
|
||||
legacy_ws: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -882,6 +988,8 @@ mod oauth_tests {
|
||||
AuthState {
|
||||
token: Some(Arc::new("legacy-secret".to_string())),
|
||||
oauth: Some(oauth_state()),
|
||||
tickets: crate::ws_ticket::TicketStore::new(),
|
||||
legacy_ws: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -968,3 +1076,156 @@ mod oauth_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-272 — WebSocket gating. These pin the hole that was measured open:
|
||||
/// with auth ON, a credential-less upgrade to `/ws/sensing` returned 101.
|
||||
#[cfg(test)]
|
||||
mod ws_gate_tests {
|
||||
use super::*;
|
||||
use crate::ws_ticket::TicketGrant;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn app(auth: AuthState) -> Router {
|
||||
Router::new()
|
||||
.route("/ws/sensing", get(|| async { "stream" }))
|
||||
.route("/ws/introspection", get(|| async { "introspect" }))
|
||||
.route("/api/v1/stream/pose", get(|| async { "pose" }))
|
||||
.route("/api/v1/models", get(|| async { "models" }))
|
||||
.layer(axum::middleware::from_fn_with_state(auth, require_bearer))
|
||||
}
|
||||
|
||||
async fn get_status(auth: AuthState, uri: &str, bearer: Option<&str>) -> StatusCode {
|
||||
let mut req = Request::builder().method("GET").uri(uri);
|
||||
if let Some(b) = bearer {
|
||||
req = req.header(AUTHORIZATION, format!("Bearer {b}"));
|
||||
}
|
||||
app(auth)
|
||||
.oneshot(req.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
}
|
||||
|
||||
fn static_auth() -> AuthState {
|
||||
AuthState {
|
||||
token: Some(Arc::new("secret".into())),
|
||||
oauth: None,
|
||||
tickets: crate::ws_ticket::TicketStore::new(),
|
||||
legacy_ws: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_websocket_path_refuses_an_unauthenticated_upgrade() {
|
||||
// The measured regression: all three answered 101 before this change.
|
||||
for p in WS_PATHS {
|
||||
assert_eq!(
|
||||
get_status(static_auth(), p, None).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"{p} must not accept a credential-less upgrade"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_native_client_may_authenticate_a_websocket_with_a_bearer() {
|
||||
// Python / CLI / MCP are not browser-constrained and must not be forced
|
||||
// through the ticket round-trip.
|
||||
for p in WS_PATHS {
|
||||
assert_eq!(
|
||||
get_status(static_auth(), p, Some("secret")).await,
|
||||
StatusCode::OK,
|
||||
"{p} must accept a bearer on the upgrade"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_valid_ticket_authorizes_exactly_one_upgrade() {
|
||||
let auth = static_auth();
|
||||
let ticket = auth
|
||||
.tickets()
|
||||
.issue(TicketGrant { scopes: None, subject: None })
|
||||
.unwrap();
|
||||
let uri = format!("/ws/sensing?ticket={ticket}");
|
||||
|
||||
assert_eq!(get_status(auth.clone(), &uri, None).await, StatusCode::OK);
|
||||
assert_eq!(
|
||||
get_status(auth, &uri, None).await,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"a replayed ticket must fail — this is what makes a URL credential tolerable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_ticket_is_refused() {
|
||||
assert_eq!(
|
||||
get_status(static_auth(), "/ws/sensing?ticket=deadbeef", None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_ticket_without_the_streams_scope_is_refused() {
|
||||
// A ticket inherits its issuer's authority and cannot exceed it.
|
||||
let auth = static_auth();
|
||||
let ticket = auth
|
||||
.tickets()
|
||||
.issue(TicketGrant {
|
||||
scopes: Some("inference".into()),
|
||||
subject: Some("u".into()),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
get_status(auth, &format!("/ws/sensing?ticket={ticket}"), None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_ticket_is_not_a_credential_for_the_rest_api() {
|
||||
// Containment: a ticket buys one WebSocket, never REST access.
|
||||
let auth = static_auth();
|
||||
let ticket = auth
|
||||
.tickets()
|
||||
.issue(TicketGrant { scopes: None, subject: None })
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
get_status(auth, &format!("/api/v1/models?ticket={ticket}"), None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_legacy_escape_hatch_restores_unauthenticated_websockets() {
|
||||
let mut auth = static_auth();
|
||||
auth.legacy_ws = true;
|
||||
assert_eq!(get_status(auth, "/ws/sensing", None).await, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_legacy_escape_hatch_does_not_weaken_the_rest_api() {
|
||||
// The blast radius of the hatch must be exactly the WebSocket paths.
|
||||
let mut auth = static_auth();
|
||||
auth.legacy_ws = true;
|
||||
assert_eq!(
|
||||
get_status(auth, "/api/v1/models", None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_auth_off_websockets_stay_open_as_before() {
|
||||
// Unconfigured deployments must see no behaviour change at all.
|
||||
assert_eq!(
|
||||
get_status(AuthState::default(), "/ws/sensing", None).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8067,6 +8067,9 @@ async fn main() {
|
||||
)
|
||||
// Stream endpoints
|
||||
.route("/api/v1/stream/status", get(stream_status))
|
||||
// ADR-272 — browsers cannot set Authorization on a WebSocket upgrade,
|
||||
// so they exchange their credential here for a 30s single-use ticket.
|
||||
.route("/api/v1/ws-ticket", axum::routing::post(ws_ticket_handler))
|
||||
.route("/api/v1/stream/pose", get(ws_pose_handler))
|
||||
// Sensing WebSocket on the HTTP port so the UI can reach it without a second port
|
||||
.route("/ws/sensing", get(ws_sensing_handler))
|
||||
@@ -8124,6 +8127,9 @@ async fn main() {
|
||||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_bearer,
|
||||
))
|
||||
// ADR-272: the ws-ticket handler needs the store the middleware owns.
|
||||
// Added AFTER the auth layer so it is still gated by it.
|
||||
.layer(axum::Extension(bearer_auth_state.clone()))
|
||||
.with_state(state.clone())
|
||||
// ADR-262 P3: additive RuField surface (`/api/field` + `/ws/field`).
|
||||
// Merged AFTER `.with_state` (so http_app is already `Router<()>` and
|
||||
@@ -9087,3 +9093,46 @@ mod observatory_persons_field_position_tests {
|
||||
assert!((p.motion_score - 55.0).abs() < 1e-6, "motion_score stays real");
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/ws-ticket` — mint a single-use WebSocket ticket (ADR-272).
|
||||
///
|
||||
/// Reached only through the auth middleware, so an unauthenticated caller
|
||||
/// cannot mint one. The ticket inherits the caller's scopes, so a
|
||||
/// `sensing:read` session cannot produce a ticket that outranks itself.
|
||||
///
|
||||
/// Exists because a browser's `WebSocket` constructor cannot set an
|
||||
/// `Authorization` header. Native clients do not need this — they send a bearer
|
||||
/// on the upgrade directly.
|
||||
async fn ws_ticket_handler(
|
||||
axum::Extension(auth): axum::Extension<wifi_densepose_sensing_server::bearer_auth::AuthState>,
|
||||
request: axum::extract::Request,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
use wifi_densepose_sensing_server::ws_ticket::TicketGrant;
|
||||
|
||||
// Present when the caller authenticated with OAuth; absent when they used
|
||||
// the legacy static token, which predates scopes and carries full authority.
|
||||
let principal = request.extensions().get::<ruview_auth::Principal>();
|
||||
let grant = TicketGrant {
|
||||
scopes: principal.map(|p| p.scopes().collect::<Vec<_>>().join(" ")),
|
||||
subject: principal.map(|p| p.subject.clone()),
|
||||
};
|
||||
|
||||
match auth.tickets().issue(grant) {
|
||||
Some(ticket) => (
|
||||
axum::http::StatusCode::OK,
|
||||
axum::Json(serde_json::json!({
|
||||
"ticket": ticket,
|
||||
"expires_in_secs": wifi_densepose_sensing_server::ws_ticket::TICKET_TTL.as_secs(),
|
||||
"usage": "append as ?ticket=<value> to the WebSocket URL; valid once",
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
// Refusing beats growing the store without bound.
|
||||
None => (
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"too many outstanding WebSocket tickets; retry shortly\n",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ struct Entry {
|
||||
|
||||
/// In-memory ticket store.
|
||||
///
|
||||
/// `Debug` deliberately reports only a count, never ticket values — a ticket in
|
||||
/// a debug log is a live credential for as long as it is unspent.
|
||||
///
|
||||
/// In-memory is correct rather than merely convenient: tickets live for
|
||||
/// seconds, and a ticket surviving a restart would be a ticket outliving the
|
||||
/// server that vouched for it.
|
||||
@@ -80,6 +83,13 @@ pub struct TicketStore {
|
||||
inner: Arc<Mutex<HashMap<String, Entry>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TicketStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let n = self.inner.lock().map(|m| m.len()).unwrap_or(0);
|
||||
f.debug_struct("TicketStore").field("outstanding", &n).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TicketStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
|
||||
Reference in New Issue
Block a user