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

@@ -11,9 +11,9 @@ use std::fs;
use std::path::Path;
use serde::Deserialize;
use tracing::{info, warn};
use tracing::info;
#[derive(Debug, Deserialize)]
#[derive(Debug, Default, Deserialize)]
pub struct Config {
#[serde(default)]
pub shard: ShardCfg,
@@ -81,19 +81,36 @@ impl Default for StoreCfg {
impl Config {
pub fn load() -> anyhow::Result<Self> {
let path = env::var("UOLINK_CONFIG").unwrap_or_else(|_| "sidecar.toml".into());
let existed = Path::new(&path).exists();
let mut cfg: Config = if Path::new(&path).exists() {
let mut cfg: Config = if existed {
let text = fs::read_to_string(&path)?;
toml::from_str(&text)?
} else {
let token = generate_token();
fs::write(&path, default_file(&token))?;
info!(path = %path, "no config found; wrote a default with a generated auth token");
toml::from_str(&default_file(&token))?
Config::default()
};
cfg.apply_env();
cfg.validate();
// Authentication is always on. A blank token is never allowed — if none is set (fresh
// install, or someone cleared it), generate one, save it, and continue. This keeps setup
// effortless while making it impossible to accidentally run with auth off.
if cfg.web.auth_token.trim().is_empty() {
let token = generate_token();
if existed {
persist_token(&path, &token)?;
} else {
fs::write(&path, default_file(&token))?;
}
cfg.web.auth_token = token.clone();
info!("No auth token configured.");
info!("Generated new token: {}", token);
info!("Saved to {}. Authentication is on.", path);
}
Ok(cfg)
}
@@ -113,28 +130,51 @@ impl Config {
}
}
fn validate(&self) {
let loopback = self.web.bind.starts_with("127.")
|| self.web.bind.starts_with("localhost")
|| self.web.bind.starts_with("[::1]");
if self.web.auth_token.is_empty() {
if loopback {
warn!("web auth_token is empty; the web API is UNAUTHENTICATED (loopback bind, so local only)");
} else {
warn!(
bind = %self.web.bind,
"web auth_token is empty but bind is NOT loopback — the web API is exposed with no auth. Set auth_token."
);
}
}
}
pub fn auth_required(&self) -> bool {
// Always true now — load() guarantees a non-empty token.
!self.web.auth_token.is_empty()
}
}
/// Rewrites the `auth_token` line in an existing config file, preserving everything else. Falls
/// back to inserting it under `[web]`, or appending a `[web]` section, if the key is absent.
fn persist_token(path: &str, token: &str) -> anyhow::Result<()> {
let text = fs::read_to_string(path)?;
let line = format!("auth_token = \"{token}\"");
if text.lines().any(|l| l.trim_start().starts_with("auth_token")) {
let out: String = text
.lines()
.map(|l| {
if l.trim_start().starts_with("auth_token") {
line.clone()
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
fs::write(path, out + "\n")?;
} else if text.lines().any(|l| l.trim() == "[web]") {
let out: String = text
.lines()
.flat_map(|l| {
if l.trim() == "[web]" {
vec![l.to_string(), line.clone()]
} else {
vec![l.to_string()]
}
})
.collect::<Vec<_>>()
.join("\n");
fs::write(path, out + "\n")?;
} else {
fs::write(path, format!("{text}\n[web]\n{line}\n"))?;
}
Ok(())
}
fn generate_token() -> String {
let mut buf = [0u8; 24];
// OS randomness; falls back to a time-seeded token only if the OS RNG is unavailable.
@@ -168,7 +208,8 @@ bind = "127.0.0.1:8080"
# Shared secret the website must present on every request:
# REST: Authorization: Bearer <token> (or X-Api-Key: <token>)
# WebSocket: add ?token=<token> to the connect URL
# Rotate by changing this and restarting. Generated randomly on first run.
# Authentication is always on: if this is left blank, the sidecar generates a new
# token here on startup. Rotate by changing this value and restarting.
auth_token = "{token}"
[store]

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
}

View File

@@ -34,6 +34,12 @@ impl Store {
Ok(Self { pool })
}
/// Cheap liveness check for the health endpoint.
pub async fn ping(&self) -> anyhow::Result<()> {
sqlx::query("SELECT 1").execute(&self.pool).await?;
Ok(())
}
/// Appends one live event. Failures are logged by the caller; persistence must never block the
/// live feed.
pub async fn insert_event(&self, t: i64, kind: &str, json: &str) -> anyhow::Result<()> {

View File

@@ -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;
}