Skip to main content

agent_first_http/host/display/
mod.rs

1//! In-container real-display takeover supervision.
2//!
3//! Display providers are process-level adapters behind `/ops/display`.
4//! KasmVNC is the current provider and is kept as an external GPL process:
5//! afhttp only starts `Xvnc`, waits for its X display + localhost web listener,
6//! and reverse-proxies the web client from the authenticated host listener.
7
8use std::net::SocketAddr;
9use std::path::{Path, PathBuf};
10use std::process::Stdio;
11use std::sync::Arc;
12use std::time::Duration;
13
14use axum::body::{to_bytes, Body};
15use axum::extract::ws::rejection::WebSocketUpgradeRejection;
16use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
17use axum::http::{header, HeaderMap, StatusCode, Uri};
18use axum::response::{IntoResponse, Response};
19use futures::{SinkExt, StreamExt};
20// KasmVNC takeover is Unix-only (Xvnc + a unix-socket control channel); these
21// imports are used solely by the `#[cfg(unix)]` launch path below. On Windows
22// `launch_kasmvnc_provider()` returns BackendUnsupported, so the rest of
23// afhttp — fetch, host, ops-panel screencast — still builds and runs.
24#[cfg(unix)]
25use tokio::io::AsyncBufReadExt;
26#[cfg(unix)]
27use tokio::net::UnixStream;
28use tokio::process::Child;
29#[cfg(unix)]
30use tokio::process::Command;
31use tokio::sync::Mutex;
32use tokio::task::JoinHandle;
33use tokio_tungstenite::tungstenite;
34
35use crate::host::bootstrap::DisplayProvider;
36use crate::host::browser::{pick_ephemeral_port, resolve_named_bin, wait_for_tcp_ready};
37use crate::shared::error::{Error, ErrorCode};
38
39/// Virtual framebuffer geometry for the takeover display. No window manager
40/// runs on the X display, so the headful browser window is pinned to this size
41/// (see `AppState::launch`) to fill the framebuffer.
42pub const DISPLAY_WIDTH: u16 = 1280;
43pub const DISPLAY_HEIGHT: u16 = 720;
44
45/// Running KasmVNC display. Cloning the surrounding `Arc` keeps the process
46/// alive until the listener state is dropped.
47pub struct DisplayHandle {
48    pub display: String,
49    pub web_port: u16,
50    /// Whether a window manager is running on the display. With one, the
51    /// browser is kept maximized so the client can use `resize=remote` (the
52    /// framebuffer tracks the browser window exactly); without one, the client
53    /// must fall back to `resize=scale` (letterboxed).
54    pub window_manager: bool,
55    _rfb_port: u16,
56    child: Mutex<Child>,
57    wm_child: Option<Mutex<Child>>,
58    stderr_task: Option<JoinHandle<()>>,
59}
60
61impl Drop for DisplayHandle {
62    fn drop(&mut self) {
63        if let Some(task) = self.stderr_task.take() {
64            task.abort();
65        }
66        if let Some(wm) = &self.wm_child {
67            if let Ok(mut child) = wm.try_lock() {
68                let _ = child.start_kill();
69            }
70        }
71        if let Ok(mut child) = self.child.try_lock() {
72            let _ = child.start_kill();
73        }
74    }
75}
76
77/// Launch a display provider and return the runtime state used by `/ops/display`.
78pub async fn launch_display_provider(
79    provider: DisplayProvider,
80    quality: u8,
81) -> Result<DisplayProxyState, Error> {
82    match provider {
83        DisplayProvider::KasmVnc => Ok(DisplayProxyState::new(
84            provider,
85            launch_kasmvnc_provider().await?,
86            quality,
87        )),
88    }
89}
90
91/// Launch KasmVNC's `Xvnc` and wait until both the X display socket and the
92/// embedded web client are reachable on localhost.
93pub async fn launch_kasmvnc_provider() -> Result<Arc<DisplayHandle>, Error> {
94    #[cfg(not(unix))]
95    {
96        return Err(Error::new(
97            ErrorCode::BackendUnsupported,
98            "KasmVNC display provider is only supported on Unix-like container hosts",
99        ));
100    }
101
102    #[cfg(unix)]
103    {
104        launch_kasmvnc_unix().await
105    }
106}
107
108#[cfg(unix)]
109async fn launch_kasmvnc_unix() -> Result<Arc<DisplayHandle>, Error> {
110    let bin = resolve_kasmvnc_bin()?;
111    let web_root = resolve_kasmvnc_web_root()?;
112    let display_num = pick_display_number()?;
113    let display = format!(":{display_num}");
114    let web_port = pick_ephemeral_port().map_err(|e| {
115        Error::new(
116            ErrorCode::BrowserLaunchFailed,
117            format!("could not reserve KasmVNC web port: {e}"),
118        )
119    })?;
120    let rfb_port = pick_ephemeral_port().map_err(|e| {
121        Error::new(
122            ErrorCode::BrowserLaunchFailed,
123            format!("could not reserve KasmVNC VNC port: {e}"),
124        )
125    })?;
126
127    let mut cmd = Command::new(&bin);
128    cmd.arg(&display)
129        .arg("-geometry")
130        .arg(format!("{DISPLAY_WIDTH}x{DISPLAY_HEIGHT}"))
131        .arg("-depth")
132        .arg("24")
133        .arg("-interface")
134        .arg("127.0.0.1")
135        .arg("-rfbport")
136        .arg(rfb_port.to_string())
137        .arg("-websocketPort")
138        .arg(web_port.to_string())
139        .arg("-httpd")
140        .arg(web_root)
141        .arg("-sslOnly=0")
142        .arg("-SecurityTypes")
143        .arg("None")
144        .arg("-disableBasicAuth")
145        .arg("-PublicIP")
146        .arg("127.0.0.1")
147        .arg("-Log")
148        .arg("*:stderr:30")
149        .kill_on_drop(true)
150        .stdout(Stdio::null())
151        .stderr(Stdio::piped());
152
153    let mut child = cmd.spawn().map_err(|e| {
154        Error::new(
155            ErrorCode::BrowserLaunchFailed,
156            format!("spawn KasmVNC Xvnc {}: {e}", bin.display()),
157        )
158    })?;
159    let stderr_task = child.stderr.take().map(|stderr| {
160        tokio::spawn(async move {
161            let mut lines = tokio::io::BufReader::new(stderr).lines();
162            while matches!(lines.next_line().await, Ok(Some(_))) {}
163        })
164    });
165
166    if let Err(e) = wait_for_x_display(display_num, Duration::from_secs(10)).await {
167        let _ = child.start_kill();
168        return Err(Error::new(
169            ErrorCode::BrowserLaunchFailed,
170            format!("KasmVNC display {display} did not become ready: {e}"),
171        ));
172    }
173    if let Err(e) = wait_for_tcp_ready(("127.0.0.1", web_port), Duration::from_secs(10)).await {
174        let _ = child.start_kill();
175        return Err(Error::new(
176            ErrorCode::BrowserLaunchFailed,
177            format!("KasmVNC web client did not accept connections on port {web_port}: {e}"),
178        ));
179    }
180
181    // Start a minimal window manager so the headful browser is auto-maximized
182    // and tracks framebuffer-size changes (enables `resize=remote` dynamic
183    // fit). Optional: if no WM binary is on PATH the display still works, the
184    // client just falls back to scaled rendering.
185    let wm_child = spawn_window_manager(&display);
186
187    Ok(Arc::new(DisplayHandle {
188        display,
189        web_port,
190        window_manager: wm_child.is_some(),
191        _rfb_port: rfb_port,
192        child: Mutex::new(child),
193        wm_child: wm_child.map(Mutex::new),
194        stderr_task,
195    }))
196}
197
198/// Spawn a lightweight window manager (matchbox or openbox) on `display` to
199/// keep the single browser window maximized. Returns `None` if none is on
200/// PATH — the takeover still works, just without dynamic resize.
201#[cfg(unix)]
202fn spawn_window_manager(display: &str) -> Option<Child> {
203    for (bin, args) in [
204        ("matchbox-window-manager", &["-use_titlebar", "no"][..]),
205        ("openbox", &[][..]),
206    ] {
207        if resolve_named_bin(bin, &None).is_err() {
208            continue;
209        }
210        match Command::new(bin)
211            .args(args)
212            .env("DISPLAY", display)
213            .stdout(Stdio::null())
214            .stderr(Stdio::null())
215            .kill_on_drop(true)
216            .spawn()
217        {
218            Ok(child) => return Some(child),
219            Err(_) => continue,
220        }
221    }
222    None
223}
224
225fn resolve_kasmvnc_bin() -> Result<PathBuf, Error> {
226    if let Ok(raw) = std::env::var("AFHTTP_KASMVNC_BIN") {
227        let path = PathBuf::from(raw);
228        if path.exists() {
229            return Ok(path);
230        }
231    }
232    resolve_named_bin("Xvnc", &None).or_else(|_| resolve_named_bin("kasmvncserver", &None))
233}
234
235fn resolve_kasmvnc_web_root() -> Result<PathBuf, Error> {
236    if let Ok(raw) = std::env::var("AFHTTP_KASMVNC_WEB_ROOT") {
237        let path = PathBuf::from(raw);
238        if path.exists() {
239            return Ok(path);
240        }
241    }
242    for candidate in [
243        "/usr/share/kasmvnc/www",
244        "/usr/local/share/kasmvnc/www",
245        "/opt/kasmvnc/share/kasmvnc/www",
246    ] {
247        let path = PathBuf::from(candidate);
248        if path.exists() {
249            return Ok(path);
250        }
251    }
252    Err(Error::new(
253        ErrorCode::BrowserLaunchFailed,
254        "could not find KasmVNC web root; set AFHTTP_KASMVNC_WEB_ROOT",
255    ))
256}
257
258#[cfg(unix)]
259fn pick_display_number() -> Result<u16, Error> {
260    for display in 90..200 {
261        let socket = x_socket_path(display);
262        if !socket.exists() {
263            return Ok(display);
264        }
265    }
266    Err(Error::new(
267        ErrorCode::BrowserLaunchFailed,
268        "could not find a free X display number for KasmVNC",
269    ))
270}
271
272#[cfg(unix)]
273async fn wait_for_x_display(display: u16, timeout: Duration) -> Result<(), String> {
274    let deadline = tokio::time::Instant::now() + timeout;
275    let socket = x_socket_path(display);
276    loop {
277        if tokio::time::Instant::now() >= deadline {
278            return Err(format!("timed out after {timeout:?}"));
279        }
280        if UnixStream::connect(&socket).await.is_ok() {
281            return Ok(());
282        }
283        tokio::time::sleep(Duration::from_millis(50)).await;
284    }
285}
286
287#[cfg(unix)]
288fn x_socket_path(display: u16) -> PathBuf {
289    Path::new("/tmp/.X11-unix").join(format!("X{display}"))
290}
291
292/// Minimal state used by the listener reverse proxy. Tests can construct this
293/// without spawning KasmVNC; production keeps the process alive via `_handle`.
294#[derive(Clone)]
295pub struct DisplayProxyState {
296    pub provider: DisplayProvider,
297    pub display: String,
298    pub web_addr: SocketAddr,
299    /// A window manager is running, so the client can use `resize=remote`
300    /// (dynamic framebuffer fit) rather than the letterboxed `resize=scale`.
301    pub window_manager: bool,
302    /// Image quality 0-100 seeded onto the client (see `host::bootstrap`).
303    pub quality: u8,
304    _handle: Option<Arc<DisplayHandle>>,
305}
306
307impl DisplayProxyState {
308    pub fn new(provider: DisplayProvider, handle: Arc<DisplayHandle>, quality: u8) -> Self {
309        Self {
310            provider,
311            display: handle.display.clone(),
312            web_addr: SocketAddr::from(([127, 0, 0, 1], handle.web_port)),
313            window_manager: handle.window_manager,
314            quality,
315            _handle: Some(handle),
316        }
317    }
318
319    #[cfg(any(test, feature = "host"))]
320    pub fn for_tests(web_port: u16) -> Self {
321        Self {
322            provider: DisplayProvider::KasmVnc,
323            display: ":99".to_string(),
324            web_addr: SocketAddr::from(([127, 0, 0, 1], web_port)),
325            window_manager: false,
326            quality: 100,
327            _handle: None,
328        }
329    }
330
331    /// Proxy an authenticated `/ops/display` request to the active provider.
332    pub async fn proxy(
333        self,
334        ws: Result<WebSocketUpgrade, WebSocketUpgradeRejection>,
335        uri: Uri,
336        request: axum::extract::Request,
337    ) -> Response {
338        match self.provider {
339            DisplayProvider::KasmVnc => proxy_kasmvnc(self, ws, uri, request).await,
340        }
341    }
342}
343
344async fn proxy_kasmvnc(
345    display: DisplayProxyState,
346    ws: Result<WebSocketUpgrade, WebSocketUpgradeRejection>,
347    uri: Uri,
348    request: axum::extract::Request,
349) -> Response {
350    // noVNC builds its WebSocket URL from the host root (default
351    // `/websockify`), ignoring this `/ops/display/` mount prefix — so without
352    // help the client connects to `/websockify` and 404s. Seed noVNC's `path`
353    // setting via query param so it targets the proxied
354    // `/ops/display/websockify`. Applied by redirecting the landing page when
355    // the param is absent; the redirect also normalizes the missing trailing
356    // slash (axum's `{*path}` wildcard never matches the bare `/ops/display/`).
357    // `resize=remote` makes the framebuffer (and the WM-maximized browser)
358    // track the client window for an exact fit; without a window manager fall
359    // back to `resize=scale` (letterboxed) since the browser window can't
360    // follow a framebuffer resize on its own.
361    let resize = if display.window_manager {
362        "remote"
363    } else {
364        "scale"
365    };
366    let is_landing = matches!(uri.path(), "/ops/display" | "/ops/display/");
367    let q = uri.query().unwrap_or("");
368    let has_path = q.split('&').any(|p| p.starts_with("path="));
369    // Match the exact preferred value, not just presence, so a client that
370    // cached `resize=scale` from before a window manager was available gets
371    // upgraded to `resize=remote` on its next landing hit.
372    let want_resize = format!("resize={resize}");
373    let resize_ok = q.split('&').any(|p| p == want_resize);
374    if is_landing && ws.is_err() && !(has_path && resize_ok) {
375        // Rebuild canonically, dropping any stale `path`/`resize` and keeping
376        // the rest (e.g. `token_secret`). Redirecting whenever *either* is
377        // missing also fixes clients that cached a `?path=...` URL from before
378        // `resize` existed. Once both are present the condition is false.
379        let quality = kasmvnc_quality_params(display.quality);
380        let mut location =
381            format!("/ops/display/?path=ops/display/websockify&resize={resize}{quality}");
382        for pair in q.split('&') {
383            if pair.is_empty() || pair.starts_with("path=") || pair.starts_with("resize=") {
384                continue;
385            }
386            location.push('&');
387            location.push_str(pair);
388        }
389        return (
390            StatusCode::TEMPORARY_REDIRECT,
391            [(header::LOCATION, location)],
392        )
393            .into_response();
394    }
395
396    let upstream_path = display_upstream_path_and_query(&uri);
397    if let Ok(ws) = ws {
398        let upstream = format!("ws://{}{}", display.web_addr, upstream_path);
399        // KasmVNC's websockify speaks the `binary` WebSocket subprotocol and
400        // closes the stream if it isn't negotiated. Echo it back to the
401        // browser (it always offers `binary`) so noVNC accepts the socket;
402        // the upstream leg requests it in `forward_display_ws`.
403        return ws
404            .protocols(["binary"])
405            .on_upgrade(move |socket| forward_kasmvnc_ws(socket, upstream));
406    }
407
408    forward_display_http(display.web_addr, upstream_path, request).await
409}
410
411/// KasmVNC client quality settings seeded on the display panel (the client's
412/// values override the server's). The `pct` (0-100, from
413/// `--display-quality-percent`) maps to KasmVNC's 0-9 JPEG quality tiers.
414/// Static/idle content can always climb to tier 9 (`dynamic_quality_max`);
415/// `pct` sets the floor for moving content. Regardless of `pct` we stop the
416/// client's default 960x540 "video mode" downscale (`max_video_resolution`),
417/// which is what blurs detailed images like captcha challenges while they
418/// load/animate.
419fn kasmvnc_quality_params(pct: u8) -> String {
420    let level = (u32::from(pct.min(100)) * 9 + 50) / 100; // 0-9, rounded
421    format!(
422        "&quality={level}&dynamic_quality_min={level}&dynamic_quality_max=9\
423         &jpeg_video_quality={level}&webp_video_quality={level}\
424         &max_video_resolution_x=3840&max_video_resolution_y=2160"
425    )
426}
427
428async fn forward_display_http(
429    upstream: std::net::SocketAddr,
430    path_and_query: String,
431    request: axum::extract::Request,
432) -> Response {
433    let (parts, body) = request.into_parts();
434    let url = format!("http://{upstream}{path_and_query}");
435    let method = reqwest::Method::from_bytes(parts.method.as_str().as_bytes())
436        .unwrap_or(reqwest::Method::GET);
437    let body = match to_bytes(body, 64 * 1024 * 1024).await {
438        Ok(bytes) => bytes,
439        Err(e) => {
440            return (
441                StatusCode::BAD_REQUEST,
442                format!("display proxy: could not read request body: {e}"),
443            )
444                .into_response();
445        }
446    };
447
448    let client = reqwest::Client::new();
449    let mut upstream_req = client.request(method, url).body(body);
450    for (name, value) in &parts.headers {
451        if should_forward_header(name) {
452            upstream_req = upstream_req.header(name, value);
453        }
454    }
455    upstream_req = upstream_req.header(header::HOST.as_str(), upstream.to_string());
456
457    let resp = match upstream_req.send().await {
458        Ok(resp) => resp,
459        Err(e) => {
460            return (
461                StatusCode::BAD_GATEWAY,
462                format!("display proxy: upstream request failed: {e}"),
463            )
464                .into_response();
465        }
466    };
467
468    let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
469    let mut headers = HeaderMap::new();
470    for (name, value) in resp.headers() {
471        if should_forward_response_header(name) {
472            headers.insert(name.clone(), value.clone());
473        }
474    }
475    let bytes = match resp.bytes().await {
476        Ok(bytes) => bytes,
477        Err(e) => {
478            return (
479                StatusCode::BAD_GATEWAY,
480                format!("display proxy: upstream body failed: {e}"),
481            )
482                .into_response();
483        }
484    };
485
486    (status, headers, Body::from(bytes)).into_response()
487}
488
489async fn forward_kasmvnc_ws(client: WebSocket, upstream_url: String) {
490    use tungstenite::client::IntoClientRequest;
491    let (mut client_tx, mut client_rx) = client.split();
492    // Request KasmVNC's `binary` subprotocol on the upstream leg; without it
493    // websockify refuses to bridge the RFB stream (101 then immediate close).
494    let upstream_req = match upstream_url.as_str().into_client_request() {
495        Ok(mut req) => {
496            req.headers_mut().insert(
497                tungstenite::http::header::SEC_WEBSOCKET_PROTOCOL,
498                tungstenite::http::HeaderValue::from_static("binary"),
499            );
500            // KasmVNC rejects WS upgrades that lack an `Origin` header (404),
501            // so synthesize one matching the upstream authority. The browser's
502            // own Origin points at afhttp's listener, not KasmVNC, so it can't
503            // be forwarded verbatim.
504            if let Some(authority) = req.uri().authority().map(|a| a.to_string()) {
505                if let Ok(origin) =
506                    tungstenite::http::HeaderValue::from_str(&format!("http://{authority}"))
507                {
508                    req.headers_mut()
509                        .insert(tungstenite::http::header::ORIGIN, origin);
510                }
511            }
512            req
513        }
514        Err(_) => {
515            let _ = client_tx.close().await;
516            return;
517        }
518    };
519    let upstream_stream = match tokio_tungstenite::connect_async(upstream_req).await {
520        Ok((stream, _resp)) => stream,
521        Err(_) => {
522            let _ = client_tx.close().await;
523            return;
524        }
525    };
526    let (mut upstream_tx, mut upstream_rx) = upstream_stream.split();
527
528    let c2u = async {
529        while let Some(Ok(msg)) = client_rx.next().await {
530            let outbound = match msg {
531                Message::Text(t) => tungstenite::Message::Text(t.as_str().into()),
532                Message::Binary(b) => tungstenite::Message::Binary(b.to_vec().into()),
533                Message::Ping(p) => tungstenite::Message::Ping(p.to_vec().into()),
534                Message::Pong(p) => tungstenite::Message::Pong(p.to_vec().into()),
535                Message::Close(_) => break,
536            };
537            if upstream_tx.send(outbound).await.is_err() {
538                break;
539            }
540        }
541        let _ = upstream_tx.send(tungstenite::Message::Close(None)).await;
542    };
543    let u2c = async {
544        while let Some(Ok(msg)) = upstream_rx.next().await {
545            let inbound = match msg {
546                tungstenite::Message::Text(t) => Message::Text(t.as_str().into()),
547                tungstenite::Message::Binary(b) => Message::Binary(b.to_vec().into()),
548                tungstenite::Message::Ping(p) => Message::Ping(p.to_vec().into()),
549                tungstenite::Message::Pong(p) => Message::Pong(p.to_vec().into()),
550                tungstenite::Message::Close(_) => break,
551                tungstenite::Message::Frame(_) => continue,
552            };
553            if client_tx.send(inbound).await.is_err() {
554                break;
555            }
556        }
557        let _ = client_tx.close().await;
558    };
559    tokio::pin!(c2u);
560    tokio::pin!(u2c);
561    tokio::select! {
562        _ = &mut c2u => {},
563        _ = &mut u2c => {},
564    }
565}
566
567fn display_upstream_path_and_query(uri: &Uri) -> String {
568    let suffix = uri
569        .path()
570        .strip_prefix("/ops/display")
571        .filter(|s| !s.is_empty())
572        .unwrap_or("/");
573    let path = if suffix.starts_with('/') {
574        suffix.to_string()
575    } else {
576        format!("/{suffix}")
577    };
578    let Some(query) = uri.query() else {
579        return path;
580    };
581    let filtered: Vec<&str> = query
582        .split('&')
583        .filter(|pair| !pair.starts_with("token_secret="))
584        .collect();
585    if filtered.is_empty() {
586        path
587    } else {
588        format!("{path}?{}", filtered.join("&"))
589    }
590}
591
592fn should_forward_header(name: &header::HeaderName) -> bool {
593    !matches!(
594        name.as_str().to_ascii_lowercase().as_str(),
595        "host"
596            | "connection"
597            | "upgrade"
598            | "content-length"
599            | "sec-websocket-key"
600            | "sec-websocket-version"
601            | "sec-websocket-protocol"
602            | "sec-websocket-extensions"
603            | "authorization"
604            | "cookie"
605    )
606}
607
608fn should_forward_response_header(name: &header::HeaderName) -> bool {
609    !matches!(
610        name.as_str().to_ascii_lowercase().as_str(),
611        "connection" | "transfer-encoding" | "upgrade" | "content-length"
612    )
613}
614
615#[cfg(test)]
616mod tests {
617    use super::kasmvnc_quality_params;
618
619    #[test]
620    fn quality_pct_maps_to_kasmvnc_tiers() {
621        assert!(kasmvnc_quality_params(100).contains("&quality=9&"));
622        assert!(kasmvnc_quality_params(0).contains("&quality=0&"));
623        assert!(kasmvnc_quality_params(50).contains("&quality=5&"));
624        // Clamps above 100.
625        assert!(kasmvnc_quality_params(200).contains("&quality=9&"));
626        // Never downscales, regardless of quality.
627        assert!(kasmvnc_quality_params(10).contains("max_video_resolution_x=3840"));
628    }
629}