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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user