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]