1use 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#[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
39pub const DISPLAY_WIDTH: u16 = 1280;
43pub const DISPLAY_HEIGHT: u16 = 720;
44
45pub struct DisplayHandle {
48 pub display: String,
49 pub web_port: u16,
50 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
77pub 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
91pub 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 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#[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#[derive(Clone)]
295pub struct DisplayProxyState {
296 pub provider: DisplayProvider,
297 pub display: String,
298 pub web_addr: SocketAddr,
299 pub window_manager: bool,
302 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 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 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 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 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 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
411fn kasmvnc_quality_params(pct: u8) -> String {
420 let level = (u32::from(pct.min(100)) * 9 + 50) / 100; 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 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 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 assert!(kasmvnc_quality_params(200).contains("&quality=9&"));
626 assert!(kasmvnc_quality_params(10).contains("max_video_resolution_x=3840"));
628 }
629}