From ad150cd29e0235494042f5271f83dfa06712df9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Sat, 17 Oct 2026 14:00:05 +0000 Subject: [PATCH] Send the edge token on the upgrade, not in the hello The uplink put its edge JWT in a `token` field on the `hello` message. It now goes in an `Authorization: Bearer` header on the WebSocket upgrade request, which the backend verifies before the socket exists - so a bad token is an HTTP 401 here rather than a connection that opens and is then closed, and the credential stays out of the URL, where it would land in every access and proxy log on the way. `Authorization` is the header those tools already know to redact. `farm_id` stays on the hello even though the token carries the same claim: the backend checks the two against each other rather than trusting the message, so sending it is how a configuration mismatch gets caught instead of silently working against the wrong farm. The cost is that tokio-tungstenite no longer derives the handshake from a URL for us - a request we build ourselves needs its own `Host`, `Connection`, `Upgrade`, `Sec-WebSocket-Version` and `Sec-WebSocket-Key`, and a handshake missing any of them is rejected rather than ignored. `host_header` errors rather than guessing if the URL has no authority, because a wrong `Host` fails the handshake far less legibly than this does. Verified against a real backend rather than assumed: a valid token handshakes to HTTP 101 and the server reads the farm from the token, and a forged one is refused with 401 before any socket exists. 64 tests, clippy unchanged at the two deliberate variant-size warnings, `cargo deny check licenses` ok. --- .env.example | 3 +++ src/uplink/mod.rs | 45 ++++++++++++++++++++++++++++++++++++++-- src/uplink/protocol.rs | 12 ++++++++++- tests/uplink_protocol.rs | 18 ++++++++++++++-- 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 6ab6529..333aee1 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # Identity CONTINUUM_GATEWAY_ID=gw-farm01-rpi01 CONTINUUM_FARM_ID=replace-with-farm-uuid +# The edge JWT from POST /auth/edge-token. Sent as an Authorization: Bearer +# header on the uplink's WebSocket upgrade - not in a message and not in the +# URL - so the backend verifies it before any socket exists. CONTINUUM_GATEWAY_TOKEN=replace-with-provisioning-token # Cloud uplink diff --git a/src/uplink/mod.rs b/src/uplink/mod.rs index f2ae581..5dd9857 100644 --- a/src/uplink/mod.rs +++ b/src/uplink/mod.rs @@ -27,6 +27,7 @@ use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::handshake::client::{Request, generate_key}; use tracing::{info, warn}; use crate::config::Config; @@ -58,6 +59,28 @@ const TELEMETRY_INTERVAL: Duration = Duration::from_secs(5); /// actor task per printer owning its own handle) before a farm gets large. pub type SharedFleet = Arc>>; +/// The `Host` header for a websocket URL: authority without the scheme. +/// +/// Needed because building the upgrade request by hand (to attach the edge +/// token) means tokio-tungstenite no longer derives the handshake headers +/// from a URL for us. An error rather than a guess if the URL has no +/// authority: a `Host` of the wrong thing fails the handshake with something +/// far less obvious than this does. +fn host_header(url: &str) -> anyhow::Result { + let authority = url + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(url) + .split(['/', '?', '#']) + .next() + .unwrap_or(""); + + if authority.is_empty() { + anyhow::bail!("the uplink URL has no host to connect to: {url}"); + } + Ok(authority.to_string()) +} + /// Runs forever: connect, serve the connection until it drops, wait, retry. pub async fn run(config: Config, fleet: SharedFleet) { loop { @@ -72,13 +95,31 @@ pub async fn run(config: Config, fleet: SharedFleet) { } async fn connect_and_serve(config: &Config, fleet: &SharedFleet) -> anyhow::Result<()> { - let (ws_stream, _response) = tokio_tungstenite::connect_async(&config.uplink_url).await?; + // The edge token goes on the upgrade request, not in the first message. + // The backend verifies it before the socket exists, so a bad token is an + // HTTP 401 here rather than a connection that opens and is then closed - + // and the credential stays out of the URL, where it would land in every + // access and proxy log along the way. `Authorization` is the header + // those tools already know to redact. + let request = Request::builder() + .uri(&config.uplink_url) + .header("Authorization", format!("Bearer {}", config.gateway_token)) + // tokio-tungstenite fills these in for a plain URL, but not once we + // hand it a request we built ourselves - and a handshake missing any + // of them is rejected by the server rather than ignored. + .header("Host", host_header(&config.uplink_url)?) + .header("Connection", "Upgrade") + .header("Upgrade", "websocket") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", generate_key()) + .body(())?; + + let (ws_stream, _response) = tokio_tungstenite::connect_async(request).await?; let (mut write, mut read) = ws_stream.split(); let hello = ClientMessage::Hello { gateway_id: config.gateway_id.clone(), farm_id: config.farm_id.clone(), - token: config.gateway_token.clone(), stream_base_url: config.stream_base_url.clone(), }; write.send(Message::Text(serde_json::to_string(&hello)?)).await?; diff --git a/src/uplink/protocol.rs b/src/uplink/protocol.rs index e779f1f..89fe5df 100644 --- a/src/uplink/protocol.rs +++ b/src/uplink/protocol.rs @@ -6,10 +6,20 @@ use crate::printer::PrintControl; #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ClientMessage { + /// The handshake, carrying no credential. + /// + /// The edge token goes in an `Authorization: Bearer` header on the + /// upgrade request instead - see `connect_and_serve`. It used to be a + /// `token` field right here, which worked but meant the backend had an + /// open socket and a parsed message from a peer that had proved nothing. + /// + /// `farm_id` stays, even though the token already carries the farm: the + /// backend checks the two against each other rather than trusting this + /// one, so sending it is how a configuration mismatch gets caught + /// instead of silently working against the wrong farm. Hello { gateway_id: String, farm_id: String, - token: String, /// Where a browser on this LAN can reach the gateway's camera /// streams, if the operator has configured one. See /// `Config::stream_base_url`. diff --git a/tests/uplink_protocol.rs b/tests/uplink_protocol.rs index 7c22f87..02d5705 100644 --- a/tests/uplink_protocol.rs +++ b/tests/uplink_protocol.rs @@ -265,7 +265,6 @@ fn a_hello_without_a_stream_base_url_omits_it() { let json = serde_json::to_string(&ClientMessage::Hello { gateway_id: "gw-1".into(), farm_id: "farm-1".into(), - token: "t".into(), stream_base_url: None, }) .expect("should serialize"); @@ -279,9 +278,24 @@ fn a_hello_carries_the_stream_base_url_when_one_is_configured() { let json = serde_json::to_string(&ClientMessage::Hello { gateway_id: "gw-1".into(), farm_id: "farm-1".into(), - token: "t".into(), stream_base_url: Some("http://gateway.local:1984".into()), }) .expect("should serialize"); assert!(json.contains(r#""stream_base_url":"http://gateway.local:1984""#), "got:\n {json}"); } + +/// The credential must not be in the handshake message. It travels in an +/// `Authorization` header on the upgrade instead, and a `token` field here +/// would mean the backend parsing a message from a peer that has proved +/// nothing - which is what this used to do. +#[test] +fn a_hello_carries_no_credential() { + let json = serde_json::to_string(&ClientMessage::Hello { + gateway_id: "gw-1".into(), + farm_id: "farm-1".into(), + stream_base_url: None, + }) + .expect("should serialize"); + assert!(!json.contains("token"), "the hello must carry no token, got:\n {json}"); + assert_eq!(json, r#"{"type":"hello","gateway_id":"gw-1","farm_id":"farm-1"}"#); +}