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:
2026-07-10 17:09:35 -05:00
parent c4de5fa8ad
commit c0c01a38d6
8 changed files with 379 additions and 47 deletions

View File

@@ -9,12 +9,19 @@ mod shard;
mod store;
mod web;
use std::sync::atomic::AtomicI64;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{broadcast, mpsc};
use tracing::info;
use tracing_subscriber::EnvFilter;
/// Wire-protocol version between the website and the sidecar. Bump this whenever an event or
/// endpoint's shape changes so a mismatched client is detected immediately (409 / health) instead
/// of failing in confusing ways.
pub const PROTOCOL_VERSION: u32 = 1;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_tracing();
@@ -41,6 +48,10 @@ async fn main() -> anyhow::Result<()> {
// Durable store: event history, economy series, cached profiles, link map.
let store = store::Store::open(&cfg.store.path).await?;
// Health/observability state.
let started = Instant::now();
let last_event = Arc::new(AtomicI64::new(0));
// Website-facing HTTP server.
let web_state = web::AppState {
events: bcast_tx.clone(),
@@ -48,6 +59,8 @@ async fn main() -> anyhow::Result<()> {
rpc: rpc.clone(),
store: store.clone(),
token: Arc::new(cfg.web.auth_token.clone()),
started,
last_event: last_event.clone(),
};
let web_bind = cfg.web.bind.clone();
tokio::spawn(async move {
@@ -61,9 +74,13 @@ async fn main() -> anyhow::Result<()> {
let feed_tx = bcast_tx.clone();
let route_rpc = rpc.clone();
let event_store = store.clone();
let last_event_ts = last_event.clone();
let mut total: u64 = 0;
tokio::spawn(async move {
while let Some(ev) = event_rx.recv().await {
// Any line from the shard — including pong heartbeats — is a sign of life.
last_event_ts.store(now_ms(), std::sync::atomic::Ordering::Relaxed);
if route_rpc.try_route(&ev.value).await {
continue; // consumed as a reply
}