From c0c01a38d6189f015ef1304fd4b1ad2ffaf049d3 Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 17:09:35 -0500 Subject: [PATCH] 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 --- sidecar/Cargo.lock | 166 +++++++++++++++++++++++++++++++++++ sidecar/Cargo.toml | 1 + sidecar/README.md | 37 +++++++- sidecar/sidecar.toml.example | 4 +- sidecar/src/config.rs | 93 ++++++++++++++------ sidecar/src/main.rs | 17 ++++ sidecar/src/store.rs | 6 ++ sidecar/src/web.rs | 102 +++++++++++++++++---- 8 files changed, 379 insertions(+), 47 deletions(-) diff --git a/sidecar/Cargo.lock b/sidecar/Cargo.lock index 557a61b..fc6eb27 100644 --- a/sidecar/Cargo.lock +++ b/sidecar/Cargo.lock @@ -17,6 +17,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "anyhow" version = "1.0.103" @@ -143,6 +152,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -171,6 +186,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "concurrent-queue" version = "2.5.0" @@ -186,6 +212,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -597,6 +629,30 @@ dependencies = [ "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]] name = "icu_collections" version = "2.2.0" @@ -716,6 +772,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "lazy_static" version = "1.5.0" @@ -1838,6 +1905,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "chrono", "getrandom", "serde", "serde_json", @@ -1902,6 +1970,51 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "whoami" version = "1.6.1" @@ -1912,12 +2025,65 @@ dependencies = [ "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]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows-sys" version = "0.48.0" diff --git a/sidecar/Cargo.toml b/sidecar/Cargo.toml index 41029bc..45ed6ab 100644 --- a/sidecar/Cargo.toml +++ b/sidecar/Cargo.toml @@ -15,6 +15,7 @@ axum = { version = "0.7", features = ["ws"] } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } toml = "0.8" getrandom = "0.2" +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } [profile.release] opt-level = 2 diff --git a/sidecar/README.md b/sidecar/README.md index d0d35fb..26e1933 100644 --- a/sidecar/README.md +++ b/sidecar/README.md @@ -27,7 +27,42 @@ The website authenticates to the sidecar with a shared token, presented as: - REST — `Authorization: Bearer ` or `X-Api-Key: ` - WebSocket — `?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 diff --git a/sidecar/sidecar.toml.example b/sidecar/sidecar.toml.example index 36983c3..e687db9 100644 --- a/sidecar/sidecar.toml.example +++ b/sidecar/sidecar.toml.example @@ -22,8 +22,8 @@ bind = "127.0.0.1:8080" # Shared secret the website must present on every request: # REST: Authorization: Bearer (or X-Api-Key: ) # WebSocket: add ?token= to the connect URL -# Empty disables auth, which is only allowed on a loopback bind (a warning is logged). -# Rotate by changing this value and restarting. +# Authentication is ALWAYS on. If this is left blank, the sidecar generates a token +# here on startup and logs it. Rotate by changing this value and restarting. auth_token = "replace-with-a-long-random-secret" [store] diff --git a/sidecar/src/config.rs b/sidecar/src/config.rs index ea82b35..984f640 100644 --- a/sidecar/src/config.rs +++ b/sidecar/src/config.rs @@ -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 { 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::>() + .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::>() + .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 (or X-Api-Key: ) # WebSocket: add ?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] diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 6745019..5bf2e41 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -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 } diff --git a/sidecar/src/store.rs b/sidecar/src/store.rs index f55cbd5..945f166 100644 --- a/sidecar/src/store.rs +++ b/sidecar/src/store.rs @@ -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<()> { diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index e7c1dfb..261c350 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -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, + pub started: Instant, + /// Epoch ms of the last line received from the shard, 0 if none yet. + pub last_event: Arc, } 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) -> 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 `, `X-Api-Key: -/// `, or `?token=` (the last so browser WebSocket clients, which can't set headers, still -/// authenticate). -async fn auth(State(st): State, req: Request, next: Next) -> Response { - if st.token.is_empty() { - return next.run(req).await; // auth disabled +fn iso_ms(ms: i64) -> Option { + 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 `, `X-Api-Key: `, or +/// `?token=` (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, 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; }