Skip to main content

pipedash_web/
ws.rs

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}