1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use axum::{
6 body::{
7 to_bytes,
8 Body,
9 Bytes,
10 },
11 extract::{
12 ws::{
13 Message,
14 WebSocket,
15 WebSocketUpgrade,
16 },
17 State,
18 },
19 http::{
20 header::{
21 AUTHORIZATION,
22 CONTENT_TYPE,
23 },
24 Request,
25 },
26 response::IntoResponse,
27 Router,
28};
29use base64::{
30 engine::general_purpose::STANDARD,
31 Engine,
32};
33use futures_util::{
34 stream::SplitSink,
35 SinkExt,
36 StreamExt,
37};
38use pipedash_core::domain::pairing::validate_device;
39use pipedash_core::event::{
40 CoreEvent,
41 EventBus,
42};
43use serde::{
44 Deserialize,
45 Serialize,
46};
47use serde_json::json;
48use tokio::sync::{
49 broadcast,
50 mpsc,
51 watch,
52 Semaphore,
53};
54use tokio::task::JoinSet;
55use tower::ServiceExt;
56
57use crate::state::AppState;
58
59const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
60const PING_INTERVAL: Duration = Duration::from_secs(20);
61const WRITE_TIMEOUT: Duration = Duration::from_secs(30);
62const MAX_IN_FLIGHT: usize = 16;
63const MAX_ADMITTED: usize = 64;
64const OUTBOX_CAPACITY: usize = 64;
65const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
66
67const AUTH_FAILED_MESSAGE: &str = "This device isn't paired with this Pipedash.";
68const RESPONSE_TOO_LARGE: &str = r#"{"code":"RESPONSE_TOO_LARGE","message":"The response is too large to send to this device."}"#;
69const INVALID_PATH: &str = r#"{"code":"BAD_REQUEST","message":"Invalid request path."}"#;
70const TOO_MANY_REQUESTS: &str = r#"{"code":"TOO_MANY_REQUESTS","message":"This device sent too many requests at once. Try again."}"#;
71
72#[derive(Deserialize)]
73#[serde(tag = "type", rename_all = "kebab-case")]
74enum ClientFrame {
75 Auth {
76 token: String,
77 },
78 Claim {
79 code: String,
80 name: String,
81 platform: String,
82 },
83 Request {
84 id: u64,
85 method: String,
86 path: String,
87 #[serde(default)]
88 body: Option<String>,
89 },
90}
91
92#[derive(Serialize)]
93struct ResponseFrame<'a> {
94 #[serde(rename = "type")]
95 kind: &'static str,
96 id: u64,
97 status: u16,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 body: Option<&'a str>,
100 #[serde(rename = "bodyBase64", skip_serializing_if = "Option::is_none")]
101 body_base64: Option<String>,
102}
103
104enum Identity {
105 Anonymous,
106 Operator(String),
107 Device { id: String, token: String },
108}
109
110pub struct WebSocketEventBus {
111 tx: broadcast::Sender<CoreEvent>,
112}
113
114impl WebSocketEventBus {
115 pub fn new() -> Self {
116 let (tx, _) = broadcast::channel(1024);
117 Self { tx }
118 }
119
120 pub fn from_sender(tx: broadcast::Sender<CoreEvent>) -> Self {
121 Self { tx }
122 }
123
124 pub fn subscribe(&self) -> broadcast::Receiver<CoreEvent> {
125 self.tx.subscribe()
126 }
127}
128
129impl Default for WebSocketEventBus {
130 fn default() -> Self {
131 Self::new()
132 }
133}
134
135#[async_trait]
136impl EventBus for WebSocketEventBus {
137 async fn emit(&self, event: CoreEvent) {
138 let _ = self.tx.send(event);
139 }
140
141 async fn emit_to(&self, _target: &str, event: CoreEvent) {
142 let _ = self.tx.send(event);
143 }
144}
145
146fn event_frame(event: &CoreEvent) -> Option<String> {
147 serde_json::to_string(&json!({
148 "type": event.event_name(),
149 "payload": event.to_json_payload(),
150 }))
151 .ok()
152}
153
154fn text(value: serde_json::Value) -> Message {
155 Message::Text(value.to_string().into())
156}
157
158fn response_frame(id: u64, status: u16, body: &[u8]) -> Message {
159 let (body, body_base64) = match std::str::from_utf8(body) {
160 Ok(text) => (Some(text), None),
161 Err(_) => (None, Some(STANDARD.encode(body))),
162 };
163 let frame = ResponseFrame {
164 kind: "response",
165 id,
166 status,
167 body,
168 body_base64,
169 };
170
171 Message::Text(serde_json::to_string(&frame).unwrap_or_default().into())
172}
173
174async fn dispatch(
175 router: Router, method: &str, path: &str, body: Option<String>, token: Option<&str>,
176) -> (u16, Bytes) {
177 let invalid = || (400, Bytes::from_static(INVALID_PATH.as_bytes()));
178 if !path.starts_with('/') || path.starts_with("//") {
179 return invalid();
180 }
181
182 let mut request = Request::builder()
183 .method(method)
184 .uri(format!("/api/v1{path}"));
185 if let Some(token) = token {
186 request = request.header(AUTHORIZATION, format!("Bearer {token}"));
187 }
188 if body.is_some() {
189 request = request.header(CONTENT_TYPE, "application/json");
190 }
191 let Ok(request) = request.body(body.map_or_else(Body::empty, Body::from)) else {
192 return invalid();
193 };
194
195 let Ok(response) = router.oneshot(request).await;
196 let (parts, body) = response.into_parts();
197 match to_bytes(body, MAX_RESPONSE_BYTES).await {
198 Ok(bytes) => (parts.status.as_u16(), bytes),
199 Err(_) => (500, Bytes::from_static(RESPONSE_TOO_LARGE.as_bytes())),
200 }
201}
202
203async fn write_loop(
204 mut sender: SplitSink<WebSocket, Message>, mut outbox: mpsc::Receiver<Message>,
205) {
206 let mut ping =
207 tokio::time::interval_at(tokio::time::Instant::now() + PING_INTERVAL, PING_INTERVAL);
208 loop {
209 let message = tokio::select! {
210 message = outbox.recv() => match message {
211 Some(message) => message,
212 None => break,
213 },
214 _ = ping.tick() => Message::Ping(Bytes::new()),
215 };
216 let closing = matches!(message, Message::Close(_));
217 if !matches!(
218 tokio::time::timeout(WRITE_TIMEOUT, sender.send(message)).await,
219 Ok(Ok(()))
220 ) || closing
221 {
222 break;
223 }
224 }
225}
226
227async fn next_event(
228 events: &mut Option<broadcast::Receiver<CoreEvent>>,
229) -> Result<CoreEvent, broadcast::error::RecvError> {
230 match events {
231 Some(events) => events.recv().await,
232 None => std::future::pending().await,
233 }
234}
235
236fn touch_device(state: &AppState, id: String) {
237 let state = state.clone();
238 tokio::spawn(async move {
239 let Ok(core) = state.core().await else {
240 return;
241 };
242 if let Err(error) = core.devices().touch(&id).await {
243 tracing::warn!(%error, "Failed to record device activity");
244 }
245 });
246}
247
248struct Connection {
249 state: AppState,
250 out: mpsc::Sender<Message>,
251 identity: Option<Identity>,
252 events: Option<broadcast::Receiver<CoreEvent>>,
253 router: Option<Router>,
254 permits: Arc<Semaphore>,
255 admitted: Arc<Semaphore>,
256 requests: JoinSet<()>,
257}
258
259impl Connection {
260 fn new(state: AppState, out: mpsc::Sender<Message>) -> Self {
261 let identity = (!state.requires_auth()).then_some(Identity::Anonymous);
262 let events = identity.as_ref().map(|_| state.ws_event_bus.subscribe());
263
264 Self {
265 state,
266 out,
267 identity,
268 events,
269 router: None,
270 permits: Arc::new(Semaphore::new(MAX_IN_FLIGHT)),
271 admitted: Arc::new(Semaphore::new(MAX_ADMITTED)),
272 requests: JoinSet::new(),
273 }
274 }
275
276 async fn reject(&self, code: &str, message: &str) {
277 let _ = self
278 .out
279 .send(text(json!({
280 "type": "auth-error",
281 "payload": { "code": code, "message": message },
282 })))
283 .await;
284 let _ = self.out.send(Message::Close(None)).await;
285 }
286
287 async fn authenticated(&mut self, identity: Identity, issued_token: Option<&str>) -> bool {
288 let device_id = match &identity {
289 Identity::Device { id, .. } => Some(id.as_str()),
290 _ => None,
291 };
292 let mut payload = json!({
293 "host": if self.state.desktop_host.is_some() { "desktop" } else { "server" },
294 "name": self.state.desktop_host.as_deref(),
295 "deviceId": device_id,
296 });
297 if let Some(token) = issued_token {
298 payload["token"] = token.into();
299 }
300
301 if self.events.is_none() {
302 self.events = Some(self.state.ws_event_bus.subscribe());
303 }
304 self.identity = Some(identity);
305 self.router = None;
306
307 self.out
308 .send(text(json!({ "type": "auth-ok", "payload": payload })))
309 .await
310 .is_ok()
311 }
312
313 async fn on_text(&mut self, raw: &str) -> bool {
314 let Ok(frame) = serde_json::from_str::<ClientFrame>(raw) else {
315 if self.identity.is_none() {
316 self.reject("AUTH_FAILED", AUTH_FAILED_MESSAGE).await;
317 return false;
318 }
319 tracing::trace!("Ignoring an unrecognized WebSocket frame");
320 return true;
321 };
322
323 match frame {
324 ClientFrame::Auth { token } => self.on_auth(&token).await,
325 ClientFrame::Claim {
326 code,
327 name,
328 platform,
329 } => self.on_claim(&code, &name, &platform).await,
330 ClientFrame::Request {
331 id,
332 method,
333 path,
334 body,
335 } => self.on_request(id, method, path, body).await,
336 }
337 }
338
339 async fn on_auth(&mut self, token: &str) -> bool {
340 if self.events.is_none() {
341 self.events = Some(self.state.ws_event_bus.subscribe());
342 }
343 let anonymous = matches!(self.identity, Some(Identity::Anonymous));
344
345 let identity = if self
346 .state
347 .api_token()
348 .is_some_and(|expected| crate::token_matches(token, &expected))
349 {
350 Identity::Operator(token.to_string())
351 } else {
352 match self.state.paired_device(token).await {
353 Ok(Some(device)) => {
354 touch_device(&self.state, device.id.clone());
355 Identity::Device {
356 id: device.id,
357 token: token.to_string(),
358 }
359 }
360 Ok(None) if anonymous => Identity::Anonymous,
361 Ok(None) => {
362 let config_error = self.state.inner.read().await.config_error.is_some();
363 let no_core = self.state.core().await.is_err();
364 if no_core && self.state.desktop_host.is_some() {
365 self.reject("HOST_LOCKED", crate::HOST_LOCKED_MESSAGE).await;
366 } else if no_core && config_error {
367 self.reject("SERVER_NOT_READY", crate::SERVER_NOT_READY_MESSAGE)
368 .await;
369 } else {
370 self.reject("AUTH_FAILED", AUTH_FAILED_MESSAGE).await;
371 }
372 return false;
373 }
374 Err(_) => {
375 self.reject("SERVER_NOT_READY", crate::SERVER_NOT_READY_MESSAGE)
376 .await;
377 return false;
378 }
379 }
380 };
381
382 self.authenticated(identity, None).await
383 }
384
385 async fn on_claim(&mut self, code: &str, name: &str, platform: &str) -> bool {
386 let Ok(core) = self.state.core().await else {
387 if self.state.desktop_host.is_some() {
388 self.reject("HOST_LOCKED", crate::HOST_LOCKED_MESSAGE).await;
389 } else {
390 self.reject("SERVER_NOT_READY", crate::SERVER_NOT_READY_MESSAGE)
391 .await;
392 }
393 return false;
394 };
395 let name = match validate_device(name, platform) {
396 Ok(_) if crate::demo::enabled() => crate::demo::device_name(platform),
397 Ok(name) => name,
398 Err((code, message)) => {
399 self.reject(code, message).await;
400 return false;
401 }
402 };
403 if !self.state.pairing_codes.take(code) {
404 self.reject(
405 "PAIRING_CODE_INVALID",
406 "This code expired or was already used. Create a new code on the server.",
407 )
408 .await;
409 return false;
410 }
411
412 match core.pair_device(&name, platform).await {
413 Ok((device, token)) => {
414 let identity = Identity::Device {
415 id: device.id,
416 token: token.clone(),
417 };
418 self.authenticated(identity, Some(&token)).await
419 }
420 Err(error) => {
421 tracing::warn!(%error, "Failed to pair a device");
422 self.reject(
423 "PAIRING_FAILED",
424 "Pipedash couldn't pair this device. Try again.",
425 )
426 .await;
427 false
428 }
429 }
430 }
431
432 async fn on_request(
433 &mut self, id: u64, method: String, path: String, body: Option<String>,
434 ) -> bool {
435 let token = match &self.identity {
436 None => {
437 self.reject("AUTH_FAILED", AUTH_FAILED_MESSAGE).await;
438 return false;
439 }
440 Some(Identity::Anonymous) => None,
441 Some(Identity::Operator(token)) | Some(Identity::Device { token, .. }) => {
442 Some(token.clone())
443 }
444 };
445
446 let state = &self.state;
447 let router = self
448 .router
449 .get_or_insert_with(|| crate::host_router(state.clone()))
450 .clone();
451 let Ok(admitted) = Arc::clone(&self.admitted).try_acquire_owned() else {
452 let _ = self
453 .out
454 .try_send(response_frame(id, 429, TOO_MANY_REQUESTS.as_bytes()));
455 return true;
456 };
457 let permits = Arc::clone(&self.permits);
458 let out = self.out.clone();
459 self.requests.spawn(async move {
460 let _admitted = admitted;
461 let Ok(_permit) = permits.acquire_owned().await else {
462 return;
463 };
464 let (status, body) = dispatch(router, &method, &path, body, token.as_deref()).await;
465 let _ = out.send(response_frame(id, status, &body)).await;
466 });
467
468 true
469 }
470
471 async fn on_event(&mut self, event: Result<CoreEvent, broadcast::error::RecvError>) -> bool {
472 match event {
473 Ok(event) => {
474 if let (CoreEvent::DeviceRevoked { id }, Some(Identity::Device { id: own, .. })) =
475 (&event, &self.identity)
476 {
477 if id == own {
478 self.reject("DEVICE_REMOVED", "This device was removed.")
479 .await;
480 return false;
481 }
482 }
483
484 match event_frame(&event) {
485 Some(json) => self.out.send(Message::Text(json.into())).await.is_ok(),
486 None => true,
487 }
488 }
489 Err(broadcast::error::RecvError::Lagged(skipped)) => {
490 tracing::warn!(skipped, "WebSocket client fell behind, closing");
491 let _ = self.out.send(Message::Close(None)).await;
492 false
493 }
494 Err(broadcast::error::RecvError::Closed) => false,
495 }
496 }
497}
498
499pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
500 ws.on_upgrade(move |socket| handle_socket(socket, state))
501}
502
503pub(crate) async fn closed(closing: &mut watch::Receiver<bool>) {
504 let _ = closing.wait_for(|closed| *closed).await;
505}
506
507async fn handle_socket(socket: WebSocket, state: AppState) {
508 let (sender, mut receiver) = socket.split();
509 let (out, outbox) = mpsc::channel(OUTBOX_CAPACITY);
510 let writer = tokio::spawn(write_loop(sender, outbox));
511 let mut conn = Connection::new(state, out);
512 let mut closing = conn.state.closing.subscribe();
513 let auth_deadline = tokio::time::sleep(AUTH_TIMEOUT);
514 tokio::pin!(auth_deadline);
515
516 loop {
517 tokio::select! {
518 _ = conn.out.closed() => break,
519 _ = closed(&mut closing) => {
520 let _ = conn.out.send(Message::Close(None)).await;
521 break;
522 }
523 frame = receiver.next() => match frame {
524 Some(Ok(Message::Text(raw))) => {
525 if !conn.on_text(&raw).await {
526 break;
527 }
528 }
529 Some(Ok(Message::Close(_))) | None => {
530 tracing::debug!("WebSocket client disconnected");
531 break;
532 }
533 Some(Err(error)) => {
534 tracing::warn!("WebSocket error: {}", error);
535 break;
536 }
537 Some(Ok(_)) => {}
538 },
539 event = next_event(&mut conn.events) => {
540 if !conn.on_event(event).await {
541 break;
542 }
543 }
544 Some(_) = conn.requests.join_next(), if !conn.requests.is_empty() => {}
545 _ = &mut auth_deadline, if conn.identity.is_none() => {
546 conn.reject("AUTH_TIMEOUT", "Authentication timed out.").await;
547 break;
548 }
549 }
550 }
551
552 drop(conn);
553 let _ = writer.await;
554}
555
556#[cfg(test)]
557mod tests {
558 use std::sync::Arc;
559 use std::time::Duration;
560
561 use axum::routing::get;
562 use axum::Router;
563 use serde_json::{
564 json,
565 Value,
566 };
567 use tokio::io::{
568 AsyncReadExt,
569 AsyncWriteExt,
570 };
571 use tokio::net::{
572 TcpListener,
573 TcpStream,
574 };
575
576 use super::*;
577 use crate::test_support::hosted;
578
579 async fn read_frame(stream: &mut TcpStream) -> Option<(u8, Vec<u8>)> {
580 let mut head = [0u8; 2];
581 stream.read_exact(&mut head).await.ok()?;
582 let len = match head[1] & 0x7f {
583 126 => stream.read_u16().await.ok()? as usize,
584 127 => stream.read_u64().await.ok()? as usize,
585 len => len as usize,
586 };
587 let mut payload = vec![0u8; len];
588 stream.read_exact(&mut payload).await.ok()?;
589 Some((head[0] & 0x0f, payload))
590 }
591
592 async fn upgrade(addr: std::net::SocketAddr, path: &str) -> TcpStream {
593 let mut stream = TcpStream::connect(addr).await.unwrap();
594 let handshake = format!(
595 "GET {path} HTTP/1.1\r\nHost: {addr}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
596 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n"
597 );
598 stream.write_all(handshake.as_bytes()).await.unwrap();
599 let mut response = Vec::new();
600 while !response.ends_with(b"\r\n\r\n") {
601 response.push(stream.read_u8().await.unwrap());
602 }
603 assert!(response.starts_with(b"HTTP/1.1 101"));
604 tokio::time::sleep(Duration::from_millis(100)).await;
605
606 stream
607 }
608
609 async fn connect_state(state: AppState) -> TcpStream {
610 let app = Router::new()
611 .route("/ws", get(ws_handler))
612 .with_state(state);
613 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
614 let addr = listener.local_addr().unwrap();
615 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
616
617 upgrade(addr, "/ws").await
618 }
619
620 async fn connect(bus: &Arc<WebSocketEventBus>) -> TcpStream {
621 connect_state(AppState::setup_mode(bus.clone())).await
622 }
623
624 async fn send_json(stream: &mut TcpStream, value: Value) {
625 let payload = value.to_string().into_bytes();
626 let mask = [7u8, 11, 13, 17];
627 let mut frame = vec![0x81];
628 match payload.len() {
629 len if len < 126 => frame.push(0x80 | len as u8),
630 len => {
631 frame.push(0x80 | 126);
632 frame.extend((len as u16).to_be_bytes());
633 }
634 }
635 frame.extend(mask);
636 frame.extend(payload.iter().enumerate().map(|(i, b)| b ^ mask[i % 4]));
637 stream.write_all(&frame).await.unwrap();
638 }
639
640 async fn next_message(stream: &mut TcpStream) -> Value {
641 let (opcode, payload) = tokio::time::timeout(Duration::from_secs(5), read_frame(stream))
642 .await
643 .expect("no message arrived")
644 .expect("the socket closed");
645 assert_eq!(opcode, 0x1);
646 serde_json::from_slice(&payload).unwrap()
647 }
648
649 async fn assert_closed(stream: &mut TcpStream) {
650 let closed = tokio::time::timeout(Duration::from_secs(5), async {
651 loop {
652 match read_frame(stream).await {
653 None | Some((0x8, _)) => break,
654 Some(_) => {}
655 }
656 }
657 })
658 .await;
659 assert!(closed.is_ok(), "the socket stayed open");
660 }
661
662 async fn claim(stream: &mut TcpStream, code: &str) -> Value {
663 send_json(
664 stream,
665 json!({ "type": "claim", "code": code, "name": "Pixel", "platform": "android" }),
666 )
667 .await;
668 next_message(stream).await
669 }
670
671 #[tokio::test]
672 async fn lagged_client_gets_closed_so_it_reconnects() {
673 let bus = Arc::new(WebSocketEventBus::new());
674 let mut stream = connect(&bus).await;
675
676 for _ in 0..1100 {
677 bus.emit(CoreEvent::ProvidersChanged).await;
678 }
679
680 let closed = tokio::time::timeout(Duration::from_secs(5), async {
681 loop {
682 match read_frame(&mut stream).await {
683 None => break,
684 Some((0x8, _)) => break,
685 Some(_) => {}
686 }
687 }
688 })
689 .await;
690 assert!(closed.is_ok(), "socket stayed open after the client lagged");
691 }
692
693 #[tokio::test]
694 async fn live_run_events_reach_clients_with_their_payloads() {
695 let bus = Arc::new(WebSocketEventBus::new());
696 let mut stream = connect(&bus).await;
697 let run: pipedash_core::domain::PipelineRun = serde_json::from_value(serde_json::json!({
698 "id": "p__3", "pipeline_id": "p", "run_number": 3, "status": "running",
699 "started_at": "2026-01-01T00:00:00Z", "duration_seconds": 12, "logs_url": ""
700 }))
701 .unwrap();
702
703 bus.emit(CoreEvent::RunLogTreeChanged {
704 pipeline_id: "p".into(),
705 run_number: 3,
706 })
707 .await;
708 bus.emit(CoreEvent::RunUpdated {
709 pipeline_id: "p".into(),
710 run_number: 3,
711 run: run.clone(),
712 })
713 .await;
714
715 assert_eq!(
716 next_message(&mut stream).await,
717 serde_json::json!({
718 "type": "run-log-tree-changed",
719 "payload": { "pipelineId": "p", "runNumber": 3 },
720 })
721 );
722 assert_eq!(
723 next_message(&mut stream).await,
724 serde_json::json!({
725 "type": "run-updated",
726 "payload": {
727 "pipelineId": "p",
728 "runNumber": 3,
729 "run": serde_json::to_value(&run).unwrap(),
730 },
731 })
732 );
733 }
734
735 #[tokio::test]
736 async fn a_browser_auth_frame_gets_auth_ok_when_no_token_is_required() {
737 let bus = Arc::new(WebSocketEventBus::new());
738 let mut stream = connect(&bus).await;
739
740 send_json(&mut stream, json!({ "type": "auth", "token": "anything" })).await;
741
742 assert_eq!(
743 next_message(&mut stream).await,
744 json!({
745 "type": "auth-ok",
746 "payload": { "host": "server", "name": null, "deviceId": null },
747 })
748 );
749 }
750
751 #[tokio::test]
752 async fn the_operator_token_authenticates_and_requests_carry_it() {
753 let state = AppState::setup_mode(Arc::new(WebSocketEventBus::new()));
754 state.set_api_token("vault-password");
755
756 let mut stream = connect_state(state.clone()).await;
757 send_json(&mut stream, json!({ "type": "auth", "token": "wrong" })).await;
758 let rejected = next_message(&mut stream).await;
759 assert_eq!(rejected["type"], "auth-error");
760 assert_eq!(rejected["payload"]["code"], "AUTH_FAILED");
761 assert_closed(&mut stream).await;
762
763 let mut stream = connect_state(state).await;
764 send_json(
765 &mut stream,
766 json!({ "type": "auth", "token": "vault-password" }),
767 )
768 .await;
769 assert_eq!(next_message(&mut stream).await["type"], "auth-ok");
770 send_json(
771 &mut stream,
772 json!({ "type": "request", "id": 5, "method": "GET", "path": "/providers" }),
773 )
774 .await;
775 let response = next_message(&mut stream).await;
776
777 assert_eq!(response["type"], "response");
778 assert_eq!(response["id"], 5);
779 assert_eq!(response["status"], 503);
780 }
781
782 #[tokio::test]
783 async fn a_request_before_authenticating_is_refused() {
784 let state = AppState::setup_mode(Arc::new(WebSocketEventBus::new()));
785 state.set_api_token("vault-password");
786 let mut stream = connect_state(state).await;
787
788 send_json(
789 &mut stream,
790 json!({ "type": "request", "id": 1, "method": "GET", "path": "/providers" }),
791 )
792 .await;
793
794 assert_eq!(
795 next_message(&mut stream).await["payload"]["code"],
796 "AUTH_FAILED"
797 );
798 assert_closed(&mut stream).await;
799 }
800
801 #[tokio::test]
802 async fn a_claim_pairs_the_device_and_serves_requests_then_the_token_reconnects() {
803 let host = hosted("ws-claim").await;
804 let (code, _) = host.state.pairing_codes.issue().unwrap();
805 let mut stream = connect_state(host.state.clone()).await;
806
807 let ok = claim(&mut stream, &code).await;
808
809 assert_eq!(ok["type"], "auth-ok");
810 assert_eq!(ok["payload"]["host"], "desktop");
811 assert_eq!(ok["payload"]["name"], "test-mac");
812 let device_id = ok["payload"]["deviceId"].as_str().unwrap().to_string();
813 let token = ok["payload"]["token"].as_str().unwrap().to_string();
814 assert_eq!(token.len(), 64);
815
816 send_json(
817 &mut stream,
818 json!({ "type": "request", "id": 1, "method": "GET", "path": "/devices" }),
819 )
820 .await;
821 let response = next_message(&mut stream).await;
822 assert_eq!(response["id"], 1);
823 assert_eq!(response["status"], 200);
824 let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
825 assert_eq!(body["devices"][0]["id"], device_id);
826 assert_eq!(body["devices"][0]["current"], true);
827
828 let mut again = connect_state(host.state.clone()).await;
829 send_json(&mut again, json!({ "type": "auth", "token": token })).await;
830 let ok = next_message(&mut again).await;
831 assert_eq!(ok["payload"]["deviceId"], device_id);
832 assert!(ok["payload"].get("token").is_none());
833
834 let mut stranger = connect_state(host.state.clone()).await;
835 send_json(&mut stranger, json!({ "type": "auth", "token": "unknown" })).await;
836 assert_eq!(
837 next_message(&mut stranger).await["payload"]["code"],
838 "AUTH_FAILED"
839 );
840 }
841
842 #[tokio::test]
843 async fn stopping_the_host_closes_connected_sockets() {
844 let state = AppState::hosted(Arc::new(WebSocketEventBus::new()), None, "test-mac");
845 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
846 let addr = listener.local_addr().unwrap();
847 let (stop, stopped) = tokio::sync::oneshot::channel::<()>();
848 let server = tokio::spawn(crate::serve_host(listener, state, async move {
849 let _ = stopped.await;
850 }));
851 let mut stream = upgrade(addr, "/api/v1/ws").await;
852
853 stop.send(()).unwrap();
854
855 assert_closed(&mut stream).await;
856 tokio::time::timeout(Duration::from_secs(5), server)
857 .await
858 .expect("serve_host kept running")
859 .unwrap()
860 .unwrap();
861 }
862
863 #[tokio::test]
864 async fn a_busy_socket_rejects_excess_requests_and_still_closes() {
865 let host = crate::test_support::server("ws-busy").await;
866 let mut stream = connect_state(host.state.clone()).await;
867 let blocked = host.state.inner.write().await;
868
869 for id in 1..=MAX_ADMITTED as u64 + 1 {
870 send_json(
871 &mut stream,
872 json!({ "type": "request", "id": id, "method": "GET", "path": "/providers" }),
873 )
874 .await;
875 }
876
877 let rejected = next_message(&mut stream).await;
878 assert_eq!(rejected["id"], MAX_ADMITTED as u64 + 1);
879 assert_eq!(rejected["status"], 429);
880
881 host.state.closing.send_replace(true);
882 assert_closed(&mut stream).await;
883 drop(blocked);
884 }
885
886 #[tokio::test]
887 async fn a_shared_host_refuses_socket_upgrades_from_other_origins() {
888 let state = AppState::hosted(Arc::new(WebSocketEventBus::new()), None, "test-mac");
889 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
890 let addr = listener.local_addr().unwrap();
891 tokio::spawn(crate::serve_host(listener, state, std::future::pending()));
892
893 let mut stream = TcpStream::connect(addr).await.unwrap();
894 let handshake = format!(
895 "GET /api/v1/ws HTTP/1.1\r\nHost: {addr}\r\nOrigin: https://attacker.example\r\n\
896 Upgrade: websocket\r\nConnection: Upgrade\r\n\
897 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n"
898 );
899 stream.write_all(handshake.as_bytes()).await.unwrap();
900 let mut status = [0u8; 12];
901 stream.read_exact(&mut status).await.unwrap();
902 assert_eq!(&status, b"HTTP/1.1 403");
903
904 upgrade(addr, "/api/v1/ws").await;
905 }
906
907 #[tokio::test]
908 async fn a_device_token_on_a_server_without_an_api_token_becomes_a_device() {
909 let host = crate::test_support::server("ws-open-device").await;
910 let core = host.state.core().await.unwrap();
911 let (device, token) = core.pair_device("Pixel", "android").await.unwrap();
912 let mut stream = connect_state(host.state.clone()).await;
913
914 send_json(&mut stream, json!({ "type": "auth", "token": token })).await;
915 let ok = next_message(&mut stream).await;
916 assert_eq!(ok["payload"]["deviceId"], device.id);
917
918 send_json(
919 &mut stream,
920 json!({ "type": "request", "id": 1, "method": "GET", "path": "/devices" }),
921 )
922 .await;
923 let response = next_message(&mut stream).await;
924 let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
925 assert_eq!(body["devices"][0]["current"], true);
926
927 core.revoke_device(&device.id).await.unwrap();
928 let removed = next_message(&mut stream).await;
929 assert_eq!(removed["payload"]["code"], "DEVICE_REMOVED");
930 assert_closed(&mut stream).await;
931 }
932
933 #[tokio::test]
934 async fn a_failed_device_lookup_is_not_reported_as_a_revoked_device() {
935 let host = hosted("ws-lookup-failure").await;
936 let core = host.state.core().await.unwrap();
937 let (_, token) = core.pair_device("Pixel", "android").await.unwrap();
938 let pipedash_core::infrastructure::database::DatabasePool::Sqlite(pool) =
939 core.provider_service.repository().cache_pool().clone()
940 else {
941 panic!("expected a SQLite pool");
942 };
943 pool.close().await;
944 let mut stream = connect_state(host.state.clone()).await;
945
946 send_json(&mut stream, json!({ "type": "auth", "token": token })).await;
947
948 let rejected = next_message(&mut stream).await;
949 assert_eq!(rejected["payload"]["code"], "SERVER_NOT_READY");
950 assert_closed(&mut stream).await;
951 }
952
953 #[tokio::test]
954 async fn a_locked_host_answers_host_locked_instead_of_forgetting_the_device() {
955 let state = AppState::hosted(Arc::new(WebSocketEventBus::new()), None, "test-mac");
956 let mut stream = connect_state(state).await;
957
958 send_json(&mut stream, json!({ "type": "auth", "token": "any" })).await;
959
960 let rejected = next_message(&mut stream).await;
961 assert_eq!(rejected["payload"]["code"], "HOST_LOCKED");
962 assert_eq!(rejected["payload"]["message"], crate::HOST_LOCKED_MESSAGE);
963 assert_closed(&mut stream).await;
964 }
965
966 #[tokio::test]
967 async fn a_server_with_a_broken_config_does_not_forget_its_devices() {
968 let state = AppState::config_error(Arc::new(WebSocketEventBus::new()), "broken".into());
969 state.set_api_token("vault-password");
970 let mut stream = connect_state(state).await;
971
972 send_json(
973 &mut stream,
974 json!({ "type": "auth", "token": "device-token" }),
975 )
976 .await;
977
978 let rejected = next_message(&mut stream).await;
979 assert_eq!(rejected["payload"]["code"], "SERVER_NOT_READY");
980 assert_closed(&mut stream).await;
981 }
982
983 #[tokio::test]
984 async fn a_pairing_code_can_be_claimed_only_once() {
985 let host = hosted("ws-claim-once").await;
986 let (code, _) = host.state.pairing_codes.issue().unwrap();
987
988 let mut first = connect_state(host.state.clone()).await;
989 assert_eq!(claim(&mut first, &code).await["type"], "auth-ok");
990
991 let mut second = connect_state(host.state.clone()).await;
992 let rejected = claim(&mut second, &code).await;
993 assert_eq!(rejected["type"], "auth-error");
994 assert_eq!(rejected["payload"]["code"], "PAIRING_CODE_INVALID");
995 assert_closed(&mut second).await;
996 }
997
998 #[tokio::test]
999 async fn a_claim_with_invalid_device_details_is_rejected_without_burning_the_code() {
1000 let host = hosted("ws-claim-invalid").await;
1001 let (code, _) = host.state.pairing_codes.issue().unwrap();
1002 let mut stream = connect_state(host.state.clone()).await;
1003
1004 send_json(
1005 &mut stream,
1006 json!({ "type": "claim", "code": code, "name": "Pixel", "platform": "palm" }),
1007 )
1008 .await;
1009
1010 assert_eq!(
1011 next_message(&mut stream).await["payload"]["code"],
1012 "INVALID_PLATFORM"
1013 );
1014 assert!(host.state.pairing_codes.take(&code));
1015 }
1016
1017 #[tokio::test]
1018 async fn revoking_a_device_closes_its_socket_with_device_removed() {
1019 let host = hosted("ws-revoke").await;
1020 let (code, _) = host.state.pairing_codes.issue().unwrap();
1021 let mut stream = connect_state(host.state.clone()).await;
1022 let ok = claim(&mut stream, &code).await;
1023 let device_id = ok["payload"]["deviceId"].as_str().unwrap().to_string();
1024
1025 host.state
1026 .core()
1027 .await
1028 .unwrap()
1029 .revoke_device(&device_id)
1030 .await
1031 .unwrap();
1032
1033 let removed = next_message(&mut stream).await;
1034 assert_eq!(removed["type"], "auth-error");
1035 assert_eq!(removed["payload"]["code"], "DEVICE_REMOVED");
1036 assert_closed(&mut stream).await;
1037 }
1038
1039 #[tokio::test]
1040 async fn a_connection_that_never_authenticates_is_closed_after_ten_seconds() {
1041 let host = hosted("ws-timeout").await;
1042 let mut stream = connect_state(host.state.clone()).await;
1043
1044 tokio::time::pause();
1045 tokio::time::advance(AUTH_TIMEOUT + Duration::from_secs(1)).await;
1046
1047 let timed_out = next_message(&mut stream).await;
1048 assert_eq!(timed_out["type"], "auth-error");
1049 assert_eq!(timed_out["payload"]["code"], "AUTH_TIMEOUT");
1050 assert_closed(&mut stream).await;
1051 }
1052}