Sidecar: auth always-on, protocol version, rich health
Auth is now impossible to turn off by accident. A blank auth_token is never allowed even on loopback: config load generates a token, writes it back into sidecar.toml (preserving the rest of the file), logs it, and continues -- so a forgotten or cleared token self-heals into a working, authenticated setup instead of silently disabling auth. No auth token configured. Generated new token: cb99... Saved to sidecar.toml. Authentication is on. Protocol versioning (PROTOCOL_VERSION = 1) lets the website and sidecar detect a mismatch immediately when a message shape changes. Every response carries an X-UOLink-Version header; /health and ws.hello include "protocol"; a request that declares a different X-UOLink-Version is rejected 409 with both versions so the mismatch is unambiguous. Bump the constant when a contract changes. /health is now a real troubleshooting panel: status (ok/degraded), protocol, plugin_connected (is the shard link up), database (SELECT 1), uptime, and last_event (the timestamp of the last line from the shard). Unauthenticated so monitoring can reach it. Verified: a blank token generates + persists + enforces (401 without, 200 with); X-UOLink-Version header on every response; 409 on a declared mismatch; /health reports degraded/plugin_connected:false with no shard, then flips to ok/true and a populated last_event once the shard connects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,12 +6,14 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::{
|
||||
extract::ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
extract::{Path, Query, Request, State},
|
||||
http::StatusCode,
|
||||
http::{HeaderValue, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
@@ -25,6 +27,7 @@ use tracing::{debug, info, warn};
|
||||
use crate::rpc::{Rpc, RpcError};
|
||||
use crate::shard::ShardHandle;
|
||||
use crate::store::Store;
|
||||
use crate::PROTOCOL_VERSION;
|
||||
|
||||
/// Shared state handed to each request handler.
|
||||
#[derive(Clone)]
|
||||
@@ -33,8 +36,11 @@ pub struct AppState {
|
||||
pub shard: ShardHandle,
|
||||
pub rpc: Rpc,
|
||||
pub store: Store,
|
||||
/// Shared secret the website must present. Empty disables auth (loopback dev only).
|
||||
/// Shared secret the website must present. Always set (config guarantees non-empty).
|
||||
pub token: Arc<String>,
|
||||
pub started: Instant,
|
||||
/// Epoch ms of the last line received from the shard, 0 if none yet.
|
||||
pub last_event: Arc<AtomicI64>,
|
||||
}
|
||||
|
||||
pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
@@ -54,11 +60,14 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
// History, read from SQLite rather than the shard.
|
||||
.route("/history", get(history))
|
||||
.route("/economy", get(economy))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.merge(protected)
|
||||
// Every response advertises the sidecar's protocol version, so a client can notice a
|
||||
// mismatch even on /health or an error response.
|
||||
.layer(middleware::from_fn(version_header))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
@@ -67,19 +76,79 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health() -> impl IntoResponse {
|
||||
"ok"
|
||||
// ---- health ----
|
||||
|
||||
/// Rich health: protocol version, whether the shard plugin is connected, database reachability,
|
||||
/// uptime, and when the shard last sent anything. Unauthenticated, so monitoring can reach it.
|
||||
async fn health(State(st): State<AppState>) -> impl IntoResponse {
|
||||
let plugin = st.shard.is_connected().await;
|
||||
let db_ok = st.store.ping().await.is_ok();
|
||||
let last_ms = st.last_event.load(Ordering::Relaxed);
|
||||
|
||||
let status = if plugin && db_ok { "ok" } else { "degraded" };
|
||||
|
||||
Json(json!({
|
||||
"status": status,
|
||||
"protocol": PROTOCOL_VERSION,
|
||||
"plugin_connected": plugin,
|
||||
"database": if db_ok { "ok" } else { "error" },
|
||||
"uptime": format_uptime(st.started.elapsed()),
|
||||
"last_event": iso_ms(last_ms),
|
||||
}))
|
||||
}
|
||||
|
||||
// ---- auth ----
|
||||
fn format_uptime(d: std::time::Duration) -> String {
|
||||
let secs = d.as_secs();
|
||||
let (days, hours, mins) = (secs / 86400, (secs % 86400) / 3600, (secs % 3600) / 60);
|
||||
if days > 0 {
|
||||
format!("{days}d {hours}h")
|
||||
} else if hours > 0 {
|
||||
format!("{hours}h {mins}m")
|
||||
} else {
|
||||
format!("{mins}m")
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejects any request that does not present the configured token. Skipped entirely when no token
|
||||
/// is configured (loopback dev). The token may arrive as `Authorization: Bearer <t>`, `X-Api-Key:
|
||||
/// <t>`, or `?token=<t>` (the last so browser WebSocket clients, which can't set headers, still
|
||||
/// authenticate).
|
||||
async fn auth(State(st): State<AppState>, req: Request, next: Next) -> Response {
|
||||
if st.token.is_empty() {
|
||||
return next.run(req).await; // auth disabled
|
||||
fn iso_ms(ms: i64) -> Option<String> {
|
||||
if ms <= 0 {
|
||||
return None;
|
||||
}
|
||||
chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
}
|
||||
|
||||
// ---- gate: protocol check + auth ----
|
||||
|
||||
/// Adds `X-UOLink-Version` to every response.
|
||||
async fn version_header(req: Request, next: Next) -> Response {
|
||||
let mut resp = next.run(req).await;
|
||||
if let Ok(v) = HeaderValue::from_str(&PROTOCOL_VERSION.to_string()) {
|
||||
resp.headers_mut().insert("X-UOLink-Version", v);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// Guards every non-health route: first a protocol-version check (if the client declares one), then
|
||||
/// authentication. The token may arrive as `Authorization: Bearer <t>`, `X-Api-Key: <t>`, or
|
||||
/// `?token=<t>` (the last so browser WebSocket clients, which can't set handshake headers, still
|
||||
/// authenticate). The token compare is constant-time.
|
||||
async fn gate(State(st): State<AppState>, req: Request, next: Next) -> Response {
|
||||
// Protocol version: if the client states one and it disagrees, fail loudly and specifically.
|
||||
if let Some(v) = req
|
||||
.headers()
|
||||
.get("x-uolink-version")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
if v.trim() != PROTOCOL_VERSION.to_string() {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({
|
||||
"error": "protocol version mismatch",
|
||||
"sidecar_protocol": PROTOCOL_VERSION,
|
||||
"client_protocol": v.trim(),
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let provided = extract_token(&req);
|
||||
@@ -340,11 +409,8 @@ async fn ws_client(mut socket: WebSocket, state: AppState) {
|
||||
let mut rx = state.events.subscribe();
|
||||
info!("ws client connected");
|
||||
|
||||
if socket
|
||||
.send(Message::Text(r#"{"kind":"ws.hello"}"#.to_string()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let hello = json!({"kind": "ws.hello", "protocol": PROTOCOL_VERSION}).to_string();
|
||||
if socket.send(Message::Text(hello)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user