Skip to main content

agent_first_http/host/display/
mod.rs

1//! In-container real-display takeover supervision.
2//!
3//! KasmVNC is kept as an external GPL process: afhttp only starts `Xvnc`,
4//! waits for its X display + localhost web listener, and reverse-proxies the
5//! web client from the authenticated host listener.
6
7use std::net::SocketAddr;
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10use std::sync::Arc;
11use std::time::Duration;
12
13// KasmVNC takeover is Unix-only (Xvnc + a unix-socket control channel); these
14// imports are used solely by the `#[cfg(unix)]` launch path below. On Windows
15// `launch_kasmvnc()` returns BackendUnsupported, so the rest of afhttp — fetch,
16// host, ops-panel screencast — still builds and runs.
17#[cfg(unix)]
18use tokio::io::AsyncBufReadExt;
19#[cfg(unix)]
20use tokio::net::UnixStream;
21use tokio::process::Child;
22#[cfg(unix)]
23use tokio::process::Command;
24use tokio::sync::Mutex;
25use tokio::task::JoinHandle;
26
27use crate::host::browser::{pick_ephemeral_port, resolve_named_bin, wait_for_tcp_ready};
28use crate::shared::error::{Error, ErrorCode};
29
30/// Virtual framebuffer geometry for the takeover display. No window manager
31/// runs on the X display, so the headful browser window is pinned to this size
32/// (see `AppState::launch`) to fill the framebuffer.
33pub const DISPLAY_WIDTH: u16 = 1280;
34pub const DISPLAY_HEIGHT: u16 = 720;
35
36/// Running KasmVNC display. Cloning the surrounding `Arc` keeps the process
37/// alive until the listener state is dropped.
38pub struct DisplayHandle {
39    pub display: String,
40    pub web_port: u16,
41    /// Whether a window manager is running on the display. With one, the
42    /// browser is kept maximized so the client can use `resize=remote` (the
43    /// framebuffer tracks the browser window exactly); without one, the client
44    /// must fall back to `resize=scale` (letterboxed).
45    pub window_manager: bool,
46    _rfb_port: u16,
47    child: Mutex<Child>,
48    wm_child: Option<Mutex<Child>>,
49    stderr_task: Option<JoinHandle<()>>,
50}
51
52impl Drop for DisplayHandle {
53    fn drop(&mut self) {
54        if let Some(task) = self.stderr_task.take() {
55            task.abort();
56        }
57        if let Some(wm) = &self.wm_child {
58            if let Ok(mut child) = wm.try_lock() {
59                let _ = child.start_kill();
60            }
61        }
62        if let Ok(mut child) = self.child.try_lock() {
63            let _ = child.start_kill();
64        }
65    }
66}
67
68/// Launch KasmVNC's `Xvnc` and wait until both the X display socket and the
69/// embedded web client are reachable on localhost.
70pub async fn launch_kasmvnc() -> Result<Arc<DisplayHandle>, Error> {
71    #[cfg(not(unix))]
72    {
73        return Err(Error::new(
74            ErrorCode::BackendUnsupported,
75            "KasmVNC display takeover is only supported on Unix-like container hosts",
76        ));
77    }
78
79    #[cfg(unix)]
80    {
81        launch_kasmvnc_unix().await
82    }
83}
84
85#[cfg(unix)]
86async fn launch_kasmvnc_unix() -> Result<Arc<DisplayHandle>, Error> {
87    let bin = resolve_kasmvnc_bin()?;
88    let web_root = resolve_kasmvnc_web_root()?;
89    let display_num = pick_display_number()?;
90    let display = format!(":{display_num}");
91    let web_port = pick_ephemeral_port().map_err(|e| {
92        Error::new(
93            ErrorCode::BrowserLaunchFailed,
94            format!("could not reserve KasmVNC web port: {e}"),
95        )
96    })?;
97    let rfb_port = pick_ephemeral_port().map_err(|e| {
98        Error::new(
99            ErrorCode::BrowserLaunchFailed,
100            format!("could not reserve KasmVNC VNC port: {e}"),
101        )
102    })?;
103
104    let mut cmd = Command::new(&bin);
105    cmd.arg(&display)
106        .arg("-geometry")
107        .arg(format!("{DISPLAY_WIDTH}x{DISPLAY_HEIGHT}"))
108        .arg("-depth")
109        .arg("24")
110        .arg("-interface")
111        .arg("127.0.0.1")
112        .arg("-rfbport")
113        .arg(rfb_port.to_string())
114        .arg("-websocketPort")
115        .arg(web_port.to_string())
116        .arg("-httpd")
117        .arg(web_root)
118        .arg("-sslOnly=0")
119        .arg("-SecurityTypes")
120        .arg("None")
121        .arg("-disableBasicAuth")
122        .arg("-PublicIP")
123        .arg("127.0.0.1")
124        .arg("-Log")
125        .arg("*:stderr:30")
126        .kill_on_drop(true)
127        .stdout(Stdio::null())
128        .stderr(Stdio::piped());
129
130    let mut child = cmd.spawn().map_err(|e| {
131        Error::new(
132            ErrorCode::BrowserLaunchFailed,
133            format!("spawn KasmVNC Xvnc {}: {e}", bin.display()),
134        )
135    })?;
136    let stderr_task = child.stderr.take().map(|stderr| {
137        tokio::spawn(async move {
138            let mut lines = tokio::io::BufReader::new(stderr).lines();
139            while matches!(lines.next_line().await, Ok(Some(_))) {}
140        })
141    });
142
143    if let Err(e) = wait_for_x_display(display_num, Duration::from_secs(10)).await {
144        let _ = child.start_kill();
145        return Err(Error::new(
146            ErrorCode::BrowserLaunchFailed,
147            format!("KasmVNC display {display} did not become ready: {e}"),
148        ));
149    }
150    if let Err(e) = wait_for_tcp_ready(("127.0.0.1", web_port), Duration::from_secs(10)).await {
151        let _ = child.start_kill();
152        return Err(Error::new(
153            ErrorCode::BrowserLaunchFailed,
154            format!("KasmVNC web client did not accept connections on port {web_port}: {e}"),
155        ));
156    }
157
158    // Start a minimal window manager so the headful browser is auto-maximized
159    // and tracks framebuffer-size changes (enables `resize=remote` dynamic
160    // fit). Optional: if no WM binary is on PATH the display still works, the
161    // client just falls back to scaled rendering.
162    let wm_child = spawn_window_manager(&display);
163
164    Ok(Arc::new(DisplayHandle {
165        display,
166        web_port,
167        window_manager: wm_child.is_some(),
168        _rfb_port: rfb_port,
169        child: Mutex::new(child),
170        wm_child: wm_child.map(Mutex::new),
171        stderr_task,
172    }))
173}
174
175/// Spawn a lightweight window manager (matchbox or openbox) on `display` to
176/// keep the single browser window maximized. Returns `None` if none is on
177/// PATH — the takeover still works, just without dynamic resize.
178#[cfg(unix)]
179fn spawn_window_manager(display: &str) -> Option<Child> {
180    for (bin, args) in [
181        ("matchbox-window-manager", &["-use_titlebar", "no"][..]),
182        ("openbox", &[][..]),
183    ] {
184        if resolve_named_bin(bin, &None).is_err() {
185            continue;
186        }
187        match Command::new(bin)
188            .args(args)
189            .env("DISPLAY", display)
190            .stdout(Stdio::null())
191            .stderr(Stdio::null())
192            .kill_on_drop(true)
193            .spawn()
194        {
195            Ok(child) => return Some(child),
196            Err(_) => continue,
197        }
198    }
199    None
200}
201
202fn resolve_kasmvnc_bin() -> Result<PathBuf, Error> {
203    if let Ok(raw) = std::env::var("AFHTTP_KASMVNC_BIN") {
204        let path = PathBuf::from(raw);
205        if path.exists() {
206            return Ok(path);
207        }
208    }
209    resolve_named_bin("Xvnc", &None).or_else(|_| resolve_named_bin("kasmvncserver", &None))
210}
211
212fn resolve_kasmvnc_web_root() -> Result<PathBuf, Error> {
213    if let Ok(raw) = std::env::var("AFHTTP_KASMVNC_WEB_ROOT") {
214        let path = PathBuf::from(raw);
215        if path.exists() {
216            return Ok(path);
217        }
218    }
219    for candidate in [
220        "/usr/share/kasmvnc/www",
221        "/usr/local/share/kasmvnc/www",
222        "/opt/kasmvnc/share/kasmvnc/www",
223    ] {
224        let path = PathBuf::from(candidate);
225        if path.exists() {
226            return Ok(path);
227        }
228    }
229    Err(Error::new(
230        ErrorCode::BrowserLaunchFailed,
231        "could not find KasmVNC web root; set AFHTTP_KASMVNC_WEB_ROOT",
232    ))
233}
234
235#[cfg(unix)]
236fn pick_display_number() -> Result<u16, Error> {
237    for display in 90..200 {
238        let socket = x_socket_path(display);
239        if !socket.exists() {
240            return Ok(display);
241        }
242    }
243    Err(Error::new(
244        ErrorCode::BrowserLaunchFailed,
245        "could not find a free X display number for KasmVNC",
246    ))
247}
248
249#[cfg(unix)]
250async fn wait_for_x_display(display: u16, timeout: Duration) -> Result<(), String> {
251    let deadline = tokio::time::Instant::now() + timeout;
252    let socket = x_socket_path(display);
253    loop {
254        if tokio::time::Instant::now() >= deadline {
255            return Err(format!("timed out after {timeout:?}"));
256        }
257        if UnixStream::connect(&socket).await.is_ok() {
258            return Ok(());
259        }
260        tokio::time::sleep(Duration::from_millis(50)).await;
261    }
262}
263
264#[cfg(unix)]
265fn x_socket_path(display: u16) -> PathBuf {
266    Path::new("/tmp/.X11-unix").join(format!("X{display}"))
267}
268
269/// Minimal state used by the listener reverse proxy. Tests can construct this
270/// without spawning KasmVNC; production keeps the process alive via `_handle`.
271#[derive(Clone)]
272pub struct DisplayProxyState {
273    pub display: String,
274    pub web_addr: SocketAddr,
275    /// A window manager is running, so the client can use `resize=remote`
276    /// (dynamic framebuffer fit) rather than the letterboxed `resize=scale`.
277    pub window_manager: bool,
278    /// Image quality 0-100 seeded onto the client (see `host::bootstrap`).
279    pub quality: u8,
280    _handle: Option<Arc<DisplayHandle>>,
281}
282
283impl DisplayProxyState {
284    pub fn new(handle: Arc<DisplayHandle>, quality: u8) -> Self {
285        Self {
286            display: handle.display.clone(),
287            web_addr: SocketAddr::from(([127, 0, 0, 1], handle.web_port)),
288            window_manager: handle.window_manager,
289            quality,
290            _handle: Some(handle),
291        }
292    }
293
294    #[cfg(any(test, feature = "host"))]
295    pub fn for_tests(web_port: u16) -> Self {
296        Self {
297            display: ":99".to_string(),
298            web_addr: SocketAddr::from(([127, 0, 0, 1], web_port)),
299            window_manager: false,
300            quality: 100,
301            _handle: None,
302        }
303    }
304}