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:
+43
-2
@@ -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<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.
|
||||
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?;
|
||||
|
||||
Reference in New Issue
Block a user