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.
This commit is contained in:
2026-10-17 14:00:05 +00:00
parent 3b93a1b4e1
commit ad150cd29e
4 changed files with 73 additions and 5 deletions
+3
View File
@@ -1,6 +1,9 @@
# Identity # Identity
CONTINUUM_GATEWAY_ID=gw-farm01-rpi01 CONTINUUM_GATEWAY_ID=gw-farm01-rpi01
CONTINUUM_FARM_ID=replace-with-farm-uuid 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 CONTINUUM_GATEWAY_TOKEN=replace-with-provisioning-token
# Cloud uplink # Cloud uplink
+43 -2
View File
@@ -27,6 +27,7 @@ use std::time::Duration;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::client::{Request, generate_key};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::config::Config; 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. /// actor task per printer owning its own handle) before a farm gets large.
pub type SharedFleet = Arc<Mutex<Vec<PrinterHandle>>>; pub type SharedFleet = Arc<Mutex<Vec<PrinterHandle>>>;
/// 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<String> {
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. /// Runs forever: connect, serve the connection until it drops, wait, retry.
pub async fn run(config: Config, fleet: SharedFleet) { pub async fn run(config: Config, fleet: SharedFleet) {
loop { loop {
@@ -72,13 +95,31 @@ pub async fn run(config: Config, fleet: SharedFleet) {
} }
async fn connect_and_serve(config: &Config, fleet: &SharedFleet) -> anyhow::Result<()> { 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 (mut write, mut read) = ws_stream.split();
let hello = ClientMessage::Hello { let hello = ClientMessage::Hello {
gateway_id: config.gateway_id.clone(), gateway_id: config.gateway_id.clone(),
farm_id: config.farm_id.clone(), farm_id: config.farm_id.clone(),
token: config.gateway_token.clone(),
stream_base_url: config.stream_base_url.clone(), stream_base_url: config.stream_base_url.clone(),
}; };
write.send(Message::Text(serde_json::to_string(&hello)?)).await?; write.send(Message::Text(serde_json::to_string(&hello)?)).await?;
+11 -1
View File
@@ -6,10 +6,20 @@ use crate::printer::PrintControl;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage { 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 { Hello {
gateway_id: String, gateway_id: String,
farm_id: String, farm_id: String,
token: String,
/// Where a browser on this LAN can reach the gateway's camera /// Where a browser on this LAN can reach the gateway's camera
/// streams, if the operator has configured one. See /// streams, if the operator has configured one. See
/// `Config::stream_base_url`. /// `Config::stream_base_url`.
+16 -2
View File
@@ -265,7 +265,6 @@ fn a_hello_without_a_stream_base_url_omits_it() {
let json = serde_json::to_string(&ClientMessage::Hello { let json = serde_json::to_string(&ClientMessage::Hello {
gateway_id: "gw-1".into(), gateway_id: "gw-1".into(),
farm_id: "farm-1".into(), farm_id: "farm-1".into(),
token: "t".into(),
stream_base_url: None, stream_base_url: None,
}) })
.expect("should serialize"); .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 { let json = serde_json::to_string(&ClientMessage::Hello {
gateway_id: "gw-1".into(), gateway_id: "gw-1".into(),
farm_id: "farm-1".into(), farm_id: "farm-1".into(),
token: "t".into(),
stream_base_url: Some("http://gateway.local:1984".into()), stream_base_url: Some("http://gateway.local:1984".into()),
}) })
.expect("should serialize"); .expect("should serialize");
assert!(json.contains(r#""stream_base_url":"http://gateway.local:1984""#), "got:\n {json}"); 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"}"#);
}