huskies: merge 1179 story Token-authenticated sled→gateway WS uplink (remote-gateway ready)
This commit is contained in:
+135
-2
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user