agent_first_http/host/display/
mod.rs1use std::net::SocketAddr;
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10use std::sync::Arc;
11use std::time::Duration;
12
13#[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
30pub const DISPLAY_WIDTH: u16 = 1280;
34pub const DISPLAY_HEIGHT: u16 = 720;
35
36pub struct DisplayHandle {
39 pub display: String,
40 pub web_port: u16,
41 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
68pub 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 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#[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#[derive(Clone)]
272pub struct DisplayProxyState {
273 pub display: String,
274 pub web_addr: SocketAddr,
275 pub window_manager: bool,
278 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}