Skip to content

Commit 58c8618

Browse files
committed
feat: add WsContainerClient for WebSocket broker access
Provides a reusable WebSocket client that challenges can import to communicate with the container broker. This eliminates the need for challenges to duplicate protocol types.
1 parent fb63e63 commit 58c8618

File tree

2 files changed

+275
-0
lines changed

2 files changed

+275
-0
lines changed

crates/secure-container-runtime/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ pub mod client;
5757
pub mod policy;
5858
pub mod protocol;
5959
pub mod types;
60+
pub mod ws_client;
6061
pub mod ws_transport;
6162

6263
pub use broker::ContainerBroker;
@@ -67,4 +68,5 @@ pub use client::{
6768
pub use policy::SecurityPolicy;
6869
pub use protocol::{Request, Response};
6970
pub use types::*;
71+
pub use ws_client::WsContainerClient;
7072
pub use ws_transport::{generate_token, run_ws_server, WsClaims, WsConfig};
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
//! WebSocket client for communicating with the container broker
2+
//!
3+
//! This client connects to the broker via WebSocket with JWT authentication.
4+
//! Use this for challenges running in containers without Unix socket access.
5+
6+
use crate::protocol::{Request, Response};
7+
use crate::types::*;
8+
use anyhow::{bail, Result};
9+
use futures::{SinkExt, StreamExt};
10+
use std::collections::HashMap;
11+
use tokio_tungstenite::{connect_async, tungstenite::Message};
12+
13+
/// WebSocket client for the secure container runtime broker
14+
pub struct WsContainerClient {
15+
ws_url: String,
16+
jwt_token: String,
17+
challenge_id: String,
18+
owner_id: String,
19+
}
20+
21+
impl WsContainerClient {
22+
/// Create a new WebSocket client
23+
pub fn new(ws_url: &str, jwt_token: &str, challenge_id: &str, owner_id: &str) -> Self {
24+
Self {
25+
ws_url: ws_url.to_string(),
26+
jwt_token: jwt_token.to_string(),
27+
challenge_id: challenge_id.to_string(),
28+
owner_id: owner_id.to_string(),
29+
}
30+
}
31+
32+
/// Create client from environment variables
33+
/// Requires: CONTAINER_BROKER_WS_URL, CONTAINER_BROKER_JWT
34+
/// Optional: CHALLENGE_ID, VALIDATOR_HOTKEY (for owner_id)
35+
pub fn from_env() -> Option<Self> {
36+
let ws_url = std::env::var("CONTAINER_BROKER_WS_URL").ok()?;
37+
let jwt_token = std::env::var("CONTAINER_BROKER_JWT").ok()?;
38+
let challenge_id =
39+
std::env::var("CHALLENGE_ID").unwrap_or_else(|_| "unknown-challenge".to_string());
40+
let owner_id =
41+
std::env::var("VALIDATOR_HOTKEY").unwrap_or_else(|_| "unknown-owner".to_string());
42+
Some(Self::new(&ws_url, &jwt_token, &challenge_id, &owner_id))
43+
}
44+
45+
/// Send a request and get response
46+
async fn send_request(&self, request: Request) -> Result<Response> {
47+
// Connect to WebSocket
48+
let (ws_stream, _) = connect_async(&self.ws_url).await.map_err(|e| {
49+
anyhow::anyhow!("Failed to connect to broker at {}: {}", self.ws_url, e)
50+
})?;
51+
52+
let (mut write, mut read) = ws_stream.split();
53+
54+
// Send auth message with JWT
55+
let auth_msg = serde_json::json!({ "token": self.jwt_token });
56+
write
57+
.send(Message::Text(auth_msg.to_string()))
58+
.await
59+
.map_err(|e| anyhow::anyhow!("Failed to send auth: {}", e))?;
60+
61+
// Wait for auth response (should be Pong or Error)
62+
if let Some(Ok(Message::Text(text))) = read.next().await {
63+
let response: Response = serde_json::from_str(&text)
64+
.map_err(|e| anyhow::anyhow!("Failed to parse auth response: {} - {}", e, text))?;
65+
if let Response::Error { error, .. } = response {
66+
bail!("Auth failed: {}", error);
67+
}
68+
// Auth succeeded (got Pong), continue
69+
} else {
70+
bail!("No auth response from broker");
71+
}
72+
73+
// Send actual request
74+
let request_json = serde_json::to_string(&request)
75+
.map_err(|e| anyhow::anyhow!("Failed to serialize request: {}", e))?;
76+
write
77+
.send(Message::Text(request_json))
78+
.await
79+
.map_err(|e| anyhow::anyhow!("Failed to send request: {}", e))?;
80+
81+
// Read response
82+
if let Some(Ok(Message::Text(text))) = read.next().await {
83+
let response: Response = serde_json::from_str(&text)
84+
.map_err(|e| anyhow::anyhow!("Failed to parse response: {} - {}", e, text))?;
85+
return Ok(response);
86+
}
87+
88+
bail!("No response from broker")
89+
}
90+
91+
/// Generate a unique request ID
92+
fn request_id() -> String {
93+
uuid::Uuid::new_v4().to_string()
94+
}
95+
96+
/// Ping the broker
97+
pub async fn ping(&self) -> Result<String> {
98+
let request = Request::Ping {
99+
request_id: Self::request_id(),
100+
};
101+
102+
match self.send_request(request).await? {
103+
Response::Pong { version, .. } => Ok(version),
104+
Response::Error { error, .. } => bail!("Ping failed: {}", error),
105+
other => bail!("Unexpected response: {:?}", other),
106+
}
107+
}
108+
109+
/// Create a container
110+
pub async fn create_container(&self, config: ContainerConfig) -> Result<(String, String)> {
111+
let request = Request::Create {
112+
config,
113+
request_id: Self::request_id(),
114+
};
115+
116+
match self.send_request(request).await? {
117+
Response::Created {
118+
container_id,
119+
container_name,
120+
..
121+
} => Ok((container_id, container_name)),
122+
Response::Error { error, .. } => bail!("Create failed: {}", error),
123+
other => bail!("Unexpected response: {:?}", other),
124+
}
125+
}
126+
127+
/// Start a container
128+
pub async fn start_container(&self, container_id: &str) -> Result<ContainerStartResult> {
129+
let request = Request::Start {
130+
container_id: container_id.to_string(),
131+
request_id: Self::request_id(),
132+
};
133+
134+
match self.send_request(request).await? {
135+
Response::Started {
136+
ports, endpoint, ..
137+
} => Ok(ContainerStartResult { ports, endpoint }),
138+
Response::Error { error, .. } => bail!("Start failed: {}", error),
139+
other => bail!("Unexpected response: {:?}", other),
140+
}
141+
}
142+
143+
/// Stop a container
144+
pub async fn stop_container(&self, container_id: &str, timeout_secs: u32) -> Result<()> {
145+
let request = Request::Stop {
146+
container_id: container_id.to_string(),
147+
timeout_secs,
148+
request_id: Self::request_id(),
149+
};
150+
151+
match self.send_request(request).await? {
152+
Response::Stopped { .. } => Ok(()),
153+
Response::Error { error, .. } => bail!("Stop failed: {}", error),
154+
other => bail!("Unexpected response: {:?}", other),
155+
}
156+
}
157+
158+
/// Remove a container
159+
pub async fn remove_container(&self, container_id: &str, force: bool) -> Result<()> {
160+
let request = Request::Remove {
161+
container_id: container_id.to_string(),
162+
force,
163+
request_id: Self::request_id(),
164+
};
165+
166+
match self.send_request(request).await? {
167+
Response::Removed { .. } => Ok(()),
168+
Response::Error { error, .. } => bail!("Remove failed: {}", error),
169+
other => bail!("Unexpected response: {:?}", other),
170+
}
171+
}
172+
173+
/// Execute a command in a container
174+
pub async fn exec(
175+
&self,
176+
container_id: &str,
177+
command: Vec<String>,
178+
working_dir: Option<String>,
179+
timeout_secs: u32,
180+
) -> Result<ExecResult> {
181+
let request = Request::Exec {
182+
container_id: container_id.to_string(),
183+
command,
184+
working_dir,
185+
timeout_secs,
186+
request_id: Self::request_id(),
187+
};
188+
189+
match self.send_request(request).await? {
190+
Response::ExecResult { result, .. } => Ok(result),
191+
Response::Error { error, .. } => bail!("Exec failed: {}", error),
192+
other => bail!("Unexpected response: {:?}", other),
193+
}
194+
}
195+
196+
/// Get container logs
197+
pub async fn logs(&self, container_id: &str, tail: usize) -> Result<String> {
198+
let request = Request::Logs {
199+
container_id: container_id.to_string(),
200+
tail,
201+
request_id: Self::request_id(),
202+
};
203+
204+
match self.send_request(request).await? {
205+
Response::LogsResult { logs, .. } => Ok(logs),
206+
Response::Error { error, .. } => bail!("Logs failed: {}", error),
207+
other => bail!("Unexpected response: {:?}", other),
208+
}
209+
}
210+
211+
/// List containers
212+
pub async fn list_containers(
213+
&self,
214+
challenge_id: Option<&str>,
215+
owner_id: Option<&str>,
216+
) -> Result<Vec<ContainerInfo>> {
217+
let request = Request::List {
218+
challenge_id: challenge_id.map(|s| s.to_string()),
219+
owner_id: owner_id.map(|s| s.to_string()),
220+
request_id: Self::request_id(),
221+
};
222+
223+
match self.send_request(request).await? {
224+
Response::ContainerList { containers, .. } => Ok(containers),
225+
Response::Error { error, .. } => bail!("List failed: {}", error),
226+
other => bail!("Unexpected response: {:?}", other),
227+
}
228+
}
229+
230+
/// Pull an image
231+
pub async fn pull_image(&self, image: &str) -> Result<()> {
232+
let request = Request::Pull {
233+
image: image.to_string(),
234+
request_id: Self::request_id(),
235+
};
236+
237+
match self.send_request(request).await? {
238+
Response::Pulled { .. } => Ok(()),
239+
Response::Error { error, .. } => bail!("Pull failed: {}", error),
240+
other => bail!("Unexpected response: {:?}", other),
241+
}
242+
}
243+
244+
/// Get challenge_id
245+
pub fn challenge_id(&self) -> &str {
246+
&self.challenge_id
247+
}
248+
249+
/// Get owner_id
250+
pub fn owner_id(&self) -> &str {
251+
&self.owner_id
252+
}
253+
}
254+
255+
/// Result of starting a container
256+
#[derive(Debug, Clone)]
257+
pub struct ContainerStartResult {
258+
pub ports: HashMap<u16, u16>,
259+
pub endpoint: Option<String>,
260+
}
261+
262+
#[cfg(test)]
263+
mod tests {
264+
use super::*;
265+
266+
#[test]
267+
fn test_from_env_missing() {
268+
// Should return None when env vars are not set
269+
std::env::remove_var("CONTAINER_BROKER_WS_URL");
270+
std::env::remove_var("CONTAINER_BROKER_JWT");
271+
assert!(WsContainerClient::from_env().is_none());
272+
}
273+
}

0 commit comments

Comments
 (0)