huskies: merge 1179 story Token-authenticated sled→gateway WS uplink (remote-gateway ready)

This commit is contained in:
Huskies Agent
2026-07-16 17:20:20 +00:00
parent 0738005863
commit aebe75cd04
3 changed files with 390 additions and 33 deletions
+135 -2
View File
@@ -38,7 +38,8 @@ use tokio_tungstenite::tungstenite::Message as WsMessage;
/// without proliferating positional arguments.
pub struct UplinkConfig {
/// WebSocket URL of the upstream gateway's `/api/sled-uplink` endpoint.
/// Includes the `?token=` query parameter for auth.
/// May include a legacy `?token=` query parameter for auth; see
/// [`build_uplink_url`] for the preferred derivation from `HUSKIES_GATEWAY_URL`.
pub upstream_url: String,
/// Project name this sled identifies as. Sent in the `identity` frame
/// after WS connect (story 899 AC 5).
@@ -47,6 +48,34 @@ pub struct UplinkConfig {
/// `http://127.0.0.1:3001/mcp`). Used to replay `mcp_request` frames
/// received from the gateway against the local MCP handler.
pub local_mcp_url: String,
/// Shared-secret token sent as `Authorization: Bearer <token>` on the WS
/// upgrade request (story 1179 AC 1). Read from `HUSKIES_SLED_TOKEN`.
/// When `None`, no `Authorization` header is sent (open/unauthenticated
/// gateway configurations continue to work unchanged).
pub auth_token: Option<String>,
}
/// Convert an `http://` or `https://` base URL to its `ws://` / `wss://`
/// equivalent (story 1179 AC 4). Returns the input unchanged if it does not
/// start with `http`.
pub fn to_ws_url(base: &str) -> String {
if let Some(rest) = base.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = base.strip_prefix("http://") {
format!("ws://{rest}")
} else {
base.to_string()
}
}
/// Derive the sled uplink WebSocket URL from a `HUSKIES_GATEWAY_URL`-style
/// base URL and this sled's project name (story 1179 AC 4).
///
/// The token itself is no longer embedded in the URL — it travels as an
/// `Authorization: Bearer` header instead (see [`UplinkConfig::auth_token`]).
pub fn build_uplink_url(gateway_url: &str, project_name: &str) -> String {
let ws_base = to_ws_url(gateway_url.trim_end_matches('/'));
format!("{ws_base}/api/sled-uplink?project={project_name}")
}
// ── Back-off constants ────────────────────────────────────────────────────────
@@ -98,6 +127,7 @@ pub fn spawn_uplink_task(config: UplinkConfig, services: Arc<Services>) {
upstream_url,
project_name,
local_mcp_url,
auth_token,
} = config;
slog!("[uplink] Spawning sled uplink task (gateway={upstream_url}, project={project_name})");
tokio::spawn(async move {
@@ -114,6 +144,7 @@ pub fn spawn_uplink_task(config: UplinkConfig, services: Arc<Services>) {
loop {
match run_uplink_session(
&upstream_url,
auth_token.as_deref(),
&project_name,
&local_mcp_url,
&http,
@@ -138,17 +169,44 @@ pub fn spawn_uplink_task(config: UplinkConfig, services: Arc<Services>) {
// ── Private helpers ───────────────────────────────────────────────────────────
/// Build the WebSocket upgrade request for `url`, attaching an
/// `Authorization: Bearer <token>` header when `auth_token` is set (story
/// 1179 AC 1). With no token, this is equivalent to connecting with the bare
/// URL string.
fn build_connect_request(
url: &str,
auth_token: Option<&str>,
) -> Result<tokio_tungstenite::tungstenite::http::Request<()>, String> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let mut request = url
.into_client_request()
.map_err(|e| format!("build request for {url}: {e}"))?;
if let Some(token) = auth_token.filter(|t| !t.is_empty()) {
let value =
tokio_tungstenite::tungstenite::http::HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|e| format!("invalid auth token header value: {e}"))?;
request.headers_mut().insert(
tokio_tungstenite::tungstenite::http::header::AUTHORIZATION,
value,
);
}
Ok(request)
}
/// Run a single uplink session: connect, send identity frame, pump messages
/// bidirectionally until disconnect or channel close, then fail-close any
/// in-flight requests.
async fn run_uplink_session(
url: &str,
auth_token: Option<&str>,
project_name: &str,
local_mcp_url: &str,
http: &reqwest::Client,
perm_rx: &mut tokio::sync::mpsc::Receiver<PermissionForward>,
) -> Result<(), String> {
let (ws_stream, _) = tokio_tungstenite::connect_async(url)
let request = build_connect_request(url, auth_token)?;
let (ws_stream, _) = tokio_tungstenite::connect_async(request)
.await
.map_err(|e| format!("WS connect to {url}: {e}"))?;
slog!("[uplink] Connected to gateway uplink endpoint");
@@ -512,6 +570,78 @@ mod tests {
assert_eq!(rx2.blocking_recv().unwrap(), PermissionDecision::Deny);
}
// ── AC 4: URL scheme mapping ──────────────────────────────────────
#[test]
fn to_ws_url_converts_https_to_wss() {
assert_eq!(to_ws_url("https://gateway:3000"), "wss://gateway:3000");
}
#[test]
fn to_ws_url_converts_http_to_ws() {
assert_eq!(to_ws_url("http://gateway:3000"), "ws://gateway:3000");
}
#[test]
fn to_ws_url_passes_through_non_http_scheme() {
assert_eq!(to_ws_url("ws://already:3000"), "ws://already:3000");
}
#[test]
fn build_uplink_url_from_https_gateway() {
assert_eq!(
build_uplink_url("https://gateway.example.com", "myproj"),
"wss://gateway.example.com/api/sled-uplink?project=myproj"
);
}
#[test]
fn build_uplink_url_from_http_gateway_trims_trailing_slash() {
assert_eq!(
build_uplink_url("http://gateway:3000/", "myproj"),
"ws://gateway:3000/api/sled-uplink?project=myproj"
);
}
// ── AC 1: Authorization header on connect ─────────────────────────
#[test]
fn build_connect_request_attaches_bearer_header_when_token_set() {
let request = build_connect_request("ws://gateway:3001/api/sled-uplink", Some("secret"))
.expect("must build request");
assert_eq!(
request
.headers()
.get(tokio_tungstenite::tungstenite::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok()),
Some("Bearer secret")
);
}
#[test]
fn build_connect_request_omits_header_when_token_absent() {
let request = build_connect_request("ws://gateway:3001/api/sled-uplink", None)
.expect("must build request");
assert!(
request
.headers()
.get(tokio_tungstenite::tungstenite::http::header::AUTHORIZATION)
.is_none()
);
}
#[test]
fn build_connect_request_omits_header_when_token_empty() {
let request = build_connect_request("ws://gateway:3001/api/sled-uplink", Some(""))
.expect("must build request");
assert!(
request
.headers()
.get(tokio_tungstenite::tungstenite::http::header::AUTHORIZATION)
.is_none()
);
}
#[test]
fn on_gateway_text_ignores_unknown_type() {
let mut in_flight: HashMap<String, oneshot::Sender<PermissionDecision>> = HashMap::new();
@@ -549,6 +679,7 @@ mod tests {
upstream_url: String::new(),
project_name: "test".to_string(),
local_mcp_url: "http://127.0.0.1:0/mcp".to_string(),
auth_token: None,
},
services,
);
@@ -608,6 +739,7 @@ mod tests {
upstream_url: url,
project_name: "test-proj".to_string(),
local_mcp_url: "http://127.0.0.1:0/mcp".to_string(),
auth_token: None,
},
Arc::clone(&services),
);
@@ -691,6 +823,7 @@ mod tests {
upstream_url: url,
project_name: "test-proj".to_string(),
local_mcp_url: "http://127.0.0.1:0/mcp".to_string(),
auth_token: None,
},
Arc::clone(&services),
);