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:
166
sidecar/Cargo.lock
generated
166
sidecar/Cargo.lock
generated
@@ -17,6 +17,15 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.103"
|
version = "1.0.103"
|
||||||
@@ -143,6 +152,12 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -171,6 +186,17 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"num-traits",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -186,6 +212,12 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -597,6 +629,30 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@@ -716,6 +772,17 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.103"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"futures-util",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1838,6 +1905,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
"chrono",
|
||||||
"getrandom",
|
"getrandom",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1902,6 +1970,51 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.126"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whoami"
|
name = "whoami"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
@@ -1912,12 +2025,65 @@ dependencies = [
|
|||||||
"wasite",
|
"wasite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.48.0"
|
version = "0.48.0"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ axum = { version = "0.7", features = ["ws"] }
|
|||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
getrandom = "0.2"
|
getrandom = "0.2"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|||||||
@@ -27,7 +27,42 @@ The website authenticates to the sidecar with a shared token, presented as:
|
|||||||
- REST — `Authorization: Bearer <token>` or `X-Api-Key: <token>`
|
- REST — `Authorization: Bearer <token>` or `X-Api-Key: <token>`
|
||||||
- WebSocket — `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake)
|
- WebSocket — `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake)
|
||||||
|
|
||||||
`/health` is the only unauthenticated route. The token is compared in constant time. It is generated randomly on first run; rotate it by editing `sidecar.toml` (or setting `UOLINK_WEB_TOKEN`) and restarting. An empty token disables auth and is only tolerated on a loopback bind — binding to `0.0.0.0` with no token logs a warning that the API is exposed. `sidecar.toml` is gitignored because it holds the secret.
|
`/health` is the only unauthenticated route. The token is compared in constant time.
|
||||||
|
|
||||||
|
**Authentication is always on.** If `auth_token` is blank (fresh install, or someone cleared it), the sidecar generates one, writes it back to `sidecar.toml`, logs it, and continues:
|
||||||
|
|
||||||
|
```
|
||||||
|
No auth token configured.
|
||||||
|
Generated new token: cb998929b2201e44914dcf077bbf115583bfbe80dcf93073
|
||||||
|
Saved to sidecar.toml. Authentication is on.
|
||||||
|
```
|
||||||
|
|
||||||
|
So you can never accidentally run without auth. Rotate by editing the token and restarting. `sidecar.toml` is gitignored because it holds the secret.
|
||||||
|
|
||||||
|
## Protocol version
|
||||||
|
|
||||||
|
The wire protocol has a version (`PROTOCOL_VERSION`, currently **1**), so the website and sidecar detect a mismatch immediately instead of failing in strange ways when a message shape changes.
|
||||||
|
|
||||||
|
- Every response carries an `X-UOLink-Version: 1` header.
|
||||||
|
- `/health` and the WebSocket `ws.hello` include `"protocol": 1`.
|
||||||
|
- If a request sends `X-UOLink-Version` and it disagrees with the sidecar, the request is rejected **409 Conflict** with `{sidecar_protocol, client_protocol}` so the mismatch is obvious.
|
||||||
|
|
||||||
|
Bump `PROTOCOL_VERSION` in `main.rs` whenever an event or endpoint's shape changes.
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
`GET /health` (unauthenticated) returns an at-a-glance status for troubleshooting:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok", // "ok" when plugin connected and DB reachable, else "degraded"
|
||||||
|
"protocol": 1,
|
||||||
|
"plugin_connected": true, // is the shard link up?
|
||||||
|
"database": "ok",
|
||||||
|
"uptime": "3d 12h",
|
||||||
|
"last_event": "2026-07-10T22:08:27Z" // last line received from the shard, null if none
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ bind = "127.0.0.1:8080"
|
|||||||
# Shared secret the website must present on every request:
|
# Shared secret the website must present on every request:
|
||||||
# REST: Authorization: Bearer <token> (or X-Api-Key: <token>)
|
# REST: Authorization: Bearer <token> (or X-Api-Key: <token>)
|
||||||
# WebSocket: add ?token=<token> to the connect URL
|
# WebSocket: add ?token=<token> to the connect URL
|
||||||
# Empty disables auth, which is only allowed on a loopback bind (a warning is logged).
|
# Authentication is ALWAYS on. If this is left blank, the sidecar generates a token
|
||||||
# Rotate by changing this value and restarting.
|
# here on startup and logs it. Rotate by changing this value and restarting.
|
||||||
auth_token = "replace-with-a-long-random-secret"
|
auth_token = "replace-with-a-long-random-secret"
|
||||||
|
|
||||||
[store]
|
[store]
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ use std::fs;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::{info, warn};
|
use tracing::info;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub shard: ShardCfg,
|
pub shard: ShardCfg,
|
||||||
@@ -81,19 +81,36 @@ impl Default for StoreCfg {
|
|||||||
impl Config {
|
impl Config {
|
||||||
pub fn load() -> anyhow::Result<Self> {
|
pub fn load() -> anyhow::Result<Self> {
|
||||||
let path = env::var("UOLINK_CONFIG").unwrap_or_else(|_| "sidecar.toml".into());
|
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)?;
|
let text = fs::read_to_string(&path)?;
|
||||||
toml::from_str(&text)?
|
toml::from_str(&text)?
|
||||||
} else {
|
} else {
|
||||||
let token = generate_token();
|
Config::default()
|
||||||
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))?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
cfg.apply_env();
|
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)
|
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 {
|
pub fn auth_required(&self) -> bool {
|
||||||
|
// Always true now — load() guarantees a non-empty token.
|
||||||
!self.web.auth_token.is_empty()
|
!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 {
|
fn generate_token() -> String {
|
||||||
let mut buf = [0u8; 24];
|
let mut buf = [0u8; 24];
|
||||||
// OS randomness; falls back to a time-seeded token only if the OS RNG is unavailable.
|
// 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:
|
# Shared secret the website must present on every request:
|
||||||
# REST: Authorization: Bearer <token> (or X-Api-Key: <token>)
|
# REST: Authorization: Bearer <token> (or X-Api-Key: <token>)
|
||||||
# WebSocket: add ?token=<token> to the connect URL
|
# 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}"
|
auth_token = "{token}"
|
||||||
|
|
||||||
[store]
|
[store]
|
||||||
|
|||||||
@@ -9,12 +9,19 @@ mod shard;
|
|||||||
mod store;
|
mod store;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
|
use std::sync::atomic::AtomicI64;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::EnvFilter;
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
init_tracing();
|
init_tracing();
|
||||||
@@ -41,6 +48,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Durable store: event history, economy series, cached profiles, link map.
|
// Durable store: event history, economy series, cached profiles, link map.
|
||||||
let store = store::Store::open(&cfg.store.path).await?;
|
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.
|
// Website-facing HTTP server.
|
||||||
let web_state = web::AppState {
|
let web_state = web::AppState {
|
||||||
events: bcast_tx.clone(),
|
events: bcast_tx.clone(),
|
||||||
@@ -48,6 +59,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
rpc: rpc.clone(),
|
rpc: rpc.clone(),
|
||||||
store: store.clone(),
|
store: store.clone(),
|
||||||
token: Arc::new(cfg.web.auth_token.clone()),
|
token: Arc::new(cfg.web.auth_token.clone()),
|
||||||
|
started,
|
||||||
|
last_event: last_event.clone(),
|
||||||
};
|
};
|
||||||
let web_bind = cfg.web.bind.clone();
|
let web_bind = cfg.web.bind.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -61,9 +74,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let feed_tx = bcast_tx.clone();
|
let feed_tx = bcast_tx.clone();
|
||||||
let route_rpc = rpc.clone();
|
let route_rpc = rpc.clone();
|
||||||
let event_store = store.clone();
|
let event_store = store.clone();
|
||||||
|
let last_event_ts = last_event.clone();
|
||||||
let mut total: u64 = 0;
|
let mut total: u64 = 0;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(ev) = event_rx.recv().await {
|
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 {
|
if route_rpc.try_route(&ev.value).await {
|
||||||
continue; // consumed as a reply
|
continue; // consumed as a reply
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ impl Store {
|
|||||||
Ok(Self { pool })
|
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
|
/// Appends one live event. Failures are logged by the caller; persistence must never block the
|
||||||
/// live feed.
|
/// live feed.
|
||||||
pub async fn insert_event(&self, t: i64, kind: &str, json: &str) -> anyhow::Result<()> {
|
pub async fn insert_event(&self, t: i64, kind: &str, json: &str) -> anyhow::Result<()> {
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::ws::{Message, WebSocket, WebSocketUpgrade},
|
extract::ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
extract::{Path, Query, Request, State},
|
extract::{Path, Query, Request, State},
|
||||||
http::StatusCode,
|
http::{HeaderValue, StatusCode},
|
||||||
middleware::{self, Next},
|
middleware::{self, Next},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
@@ -25,6 +27,7 @@ use tracing::{debug, info, warn};
|
|||||||
use crate::rpc::{Rpc, RpcError};
|
use crate::rpc::{Rpc, RpcError};
|
||||||
use crate::shard::ShardHandle;
|
use crate::shard::ShardHandle;
|
||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
|
use crate::PROTOCOL_VERSION;
|
||||||
|
|
||||||
/// Shared state handed to each request handler.
|
/// Shared state handed to each request handler.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -33,8 +36,11 @@ pub struct AppState {
|
|||||||
pub shard: ShardHandle,
|
pub shard: ShardHandle,
|
||||||
pub rpc: Rpc,
|
pub rpc: Rpc,
|
||||||
pub store: Store,
|
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 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<()> {
|
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.
|
// History, read from SQLite rather than the shard.
|
||||||
.route("/history", get(history))
|
.route("/history", get(history))
|
||||||
.route("/economy", get(economy))
|
.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()
|
let app = Router::new()
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.merge(protected)
|
.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);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
@@ -67,19 +76,79 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health() -> impl IntoResponse {
|
// ---- health ----
|
||||||
"ok"
|
|
||||||
|
/// 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
|
fn iso_ms(ms: i64) -> Option<String> {
|
||||||
/// is configured (loopback dev). The token may arrive as `Authorization: Bearer <t>`, `X-Api-Key:
|
if ms <= 0 {
|
||||||
/// <t>`, or `?token=<t>` (the last so browser WebSocket clients, which can't set headers, still
|
return None;
|
||||||
/// authenticate).
|
}
|
||||||
async fn auth(State(st): State<AppState>, req: Request, next: Next) -> Response {
|
chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||||
if st.token.is_empty() {
|
}
|
||||||
return next.run(req).await; // auth disabled
|
|
||||||
|
// ---- 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);
|
let provided = extract_token(&req);
|
||||||
@@ -340,11 +409,8 @@ async fn ws_client(mut socket: WebSocket, state: AppState) {
|
|||||||
let mut rx = state.events.subscribe();
|
let mut rx = state.events.subscribe();
|
||||||
info!("ws client connected");
|
info!("ws client connected");
|
||||||
|
|
||||||
if socket
|
let hello = json!({"kind": "ws.hello", "protocol": PROTOCOL_VERSION}).to_string();
|
||||||
.send(Message::Text(r#"{"kind":"ws.hello"}"#.to_string()))
|
if socket.send(Message::Text(hello)).await.is_err() {
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user