1pub(crate) mod browser;
4pub mod config;
5pub(crate) mod desktop;
6pub(crate) mod remote_desktop;
7
8use crate::wire::ServerMessage;
9
10use std::borrow::Cow;
11use std::ffi::OsString;
12use std::fs::{self, File, OpenOptions};
13use std::io::Read;
14use std::path::{Path, PathBuf};
15use std::process::Stdio;
16use std::time::Duration;
17
18use mobius::backend::model::provider::{HttpCertificate, HttpClient};
19#[cfg(unix)]
20use mobius::backend::sandbox::ProcessGroupGuard;
21use sha2::{Digest, Sha256};
22use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
23use tokio::process::Command;
24
25use crate::wire::MiddlewareConfig;
26use crate::{Error, Result};
27pub use config::{BrowserConfig, ComputerConfig, DesktopConfig, RuntimeMode};
28
29const WORKER: &str = include_str!("computer_runtime/worker.cjs");
30const DOCUMENTATION: &str = include_str!("computer_runtime/computer-control.md");
31const PACKAGE: &str = include_str!("computer_runtime/package.json");
32const LOCKFILE: &str = include_str!("computer_runtime/package-lock.json");
33const MAX_DOWNLOAD_BYTES: usize = 128 * 1024 * 1024;
34const ROOT_SANDBOX_ERROR: &str = "Chromium cannot run as root with its sandbox enabled; run the gateway as a non-root user or explicitly set computer.browser.sandbox = false.";
35
36pub(crate) enum AppUpdate {
37 Desktop(Option<ServerMessage>),
38 Browser(Option<ServerMessage>),
39}
40
41pub(crate) async fn next_app_update(
42 desktop: &mut Option<desktop::DesktopConnection>,
43 browser: &mut Option<browser::BrowserConnection>,
44) -> AppUpdate {
45 tokio::select! {
46 message = desktop::next_update(desktop) => AppUpdate::Desktop(message),
47 message = browser::next_update(browser) => AppUpdate::Browser(message),
48 }
49}
50
51pub(crate) async fn write_app_update(
52 update: AppUpdate,
53 desktop: &mut Option<desktop::DesktopConnection>,
54 browser: &mut Option<browser::BrowserConnection>,
55 writer: &mut (impl tokio::io::AsyncWrite + Unpin),
56) -> Result<()> {
57 match update {
58 AppUpdate::Desktop(message) => desktop::write_update(desktop, message, writer).await,
59 AppUpdate::Browser(message) => browser::write_update(browser, message, writer).await,
60 }
61}
62
63pub(crate) async fn prepare(
64 state_dir: &Path,
65 settings: &MiddlewareConfig,
66 config: &ComputerConfig,
67) -> Result<Option<PathBuf>> {
68 config.validate()?;
69 remote_desktop::prepare_configured_profile(config)?;
70 if !settings.enabled("computer_control") {
71 return Ok(None);
72 }
73 prepare_desktop(state_dir, config).await.map(Some)
74}
75
76pub(crate) async fn prepare_desktop(state_dir: &Path, config: &ComputerConfig) -> Result<PathBuf> {
77 config.validate()?;
78 remote_desktop::prepare_configured_profile(config)?;
79 let override_path = config.directory.as_deref().map(Cow::Borrowed).or_else(|| {
80 std::env::var_os("MOBIUS_COMPUTER_RUNTIME").map(|path| Cow::Owned(PathBuf::from(path)))
81 });
82 let path = override_path.as_deref().map_or_else(
83 || Cow::Owned(managed_directory_for(state_dir, config)),
84 Cow::Borrowed,
85 );
86 if config.mode != RuntimeMode::Managed
87 || (config.directory.is_none() && override_path.is_some())
88 {
89 validate(&path, config)?;
90 return Ok(fs::canonicalize(path)?);
91 }
92 install(&path, config).await.map_err(|error| {
93 Error::Config(format!(
94 "computer control setup failed: {error}; check the operator computer configuration"
95 ))
96 })?;
97 Ok(fs::canonicalize(path)?)
98}
99
100#[cfg(test)]
101pub(crate) fn managed_directory(state_dir: &Path) -> PathBuf {
102 managed_directory_for(state_dir, &ComputerConfig::default())
103}
104
105fn managed_directory_for(state_dir: &Path, config: &ComputerConfig) -> PathBuf {
106 let mut name = state_dir
108 .file_name()
109 .map_or_else(|| OsString::from("mobius"), OsString::from);
110 name.push("-runtimes");
111 let mut hash = Sha256::new();
112 for value in [
113 config::DEFAULTS_TEXT,
114 PACKAGE,
115 LOCKFILE,
116 WORKER,
117 DOCUMENTATION,
118 ] {
119 hash.update(value.as_bytes());
120 }
121 for path in [
123 &config.node_executable,
124 &config.npm_executable,
125 &config.playwright_module,
126 &config.browsers_directory,
127 &config.browser.executable,
128 ] {
129 hash.update([u8::from(path.is_some())]);
130 if let Some(path) = path {
131 hash.update(path.as_os_str().as_encoded_bytes());
132 }
133 hash.update([0]);
134 }
135 let revision = format!("{:x}", hash.finalize());
136 state_dir
137 .with_file_name(name)
138 .join("computer-control")
139 .join(revision)
140}
141
142async fn install(destination: &Path, config: &ComputerConfig) -> Result<()> {
143 tokio::time::timeout(
144 Duration::from_secs(config.install_timeout_seconds),
145 install_locked(destination, config),
146 )
147 .await
148 .map_err(|_| Error::Config("runtime installation timed out".into()))?
149}
150
151async fn install_locked(destination: &Path, config: &ComputerConfig) -> Result<()> {
152 if destination.exists() {
153 return validate(destination, config);
154 }
155 let parent = destination
156 .parent()
157 .ok_or_else(|| Error::Config("runtime directory has no parent".into()))?;
158 fs::create_dir_all(parent)?;
159 let lock_path = parent.join("install.lock");
160 let _lock = {
161 let file = OpenOptions::new()
162 .create(true)
163 .truncate(false)
164 .read(true)
165 .write(true)
166 .open(lock_path)?;
167 loop {
168 match file.try_lock() {
169 Ok(()) => break file,
170 Err(std::fs::TryLockError::WouldBlock) => {
171 tokio::time::sleep(Duration::from_millis(50)).await
172 }
173 Err(std::fs::TryLockError::Error(error)) => return Err(error.into()),
174 }
175 }
176 };
177 if destination.exists() {
178 return validate(destination, config);
179 }
180 let stage = tempfile::Builder::new()
181 .prefix(".install-")
182 .tempdir_in(parent)?;
183 populate(stage.path(), config).await?;
184 validate(stage.path(), config)?;
185 fs::rename(stage.path(), destination)?;
187 Ok(())
188}
189
190fn validate(path: &Path, config: &ComputerConfig) -> Result<()> {
191 if !path.is_absolute() {
192 return Err(Error::Config(
193 "computer runtime directory must be absolute".into(),
194 ));
195 }
196 if config.node_executable.is_none() && !path.join("node").is_file() {
197 return Err(Error::Config(format!(
198 "computer runtime is missing node in {}",
199 path.display()
200 )));
201 }
202 node_executable(path, config)?;
203 for file in ["worker.cjs", "computer-control.md"] {
204 if !path.join(file).is_file() {
205 return Err(Error::Config(format!(
206 "computer runtime is missing {file} in {}",
207 path.display()
208 )));
209 }
210 }
211 let mut worker = Vec::new();
212 File::open(path.join("worker.cjs"))?
213 .take(128 * 1024)
214 .read_to_end(&mut worker)?;
215 if worker != WORKER.as_bytes() {
216 return Err(Error::Config(
217 "computer worker does not match this gateway; export its current computer resources"
218 .into(),
219 ));
220 }
221 if !playwright_module(path, config)
222 .join("package.json")
223 .is_file()
224 {
225 return Err(Error::Config(
226 "computer runtime is missing Playwright".into(),
227 ));
228 }
229 if let Some(executable) = &config.browser.executable {
230 config::resolve_executable(executable)?;
231 } else if !browsers_directory(path, config).is_dir() {
232 return Err(Error::Config(
233 "computer runtime is missing its browser installation".into(),
234 ));
235 }
236 Ok(())
237}
238
239async fn install_node(path: &Path, config: &ComputerConfig) -> Result<()> {
240 if let Some(node) = &config.node_executable {
241 config::resolve_executable(node)?;
242 return Ok(());
243 }
244 let (platform, checksum) =
245 config::node_distribution(std::env::consts::OS, std::env::consts::ARCH)?;
246 let node_version = config::node_version();
247 let archive = path.join("node.tar.gz");
248 download(
249 &format!(
250 "{}/v{node_version}/node-v{node_version}-{platform}.tar.gz",
251 config.node_download_base_url.trim_end_matches('/')
252 ),
253 &archive,
254 checksum,
255 config,
256 )
257 .await?;
258 let node_directory = path.join("distribution");
259 fs::create_dir(&node_directory)?;
260 let mut extract = Command::new(config::resolve_executable(Path::new(
261 config.tar_executable.as_ref(),
262 ))?);
263 extract
264 .arg("-xzf")
265 .arg(&archive)
266 .arg("-C")
267 .arg(&node_directory)
268 .args(["--strip-components", "1"]);
269 run(extract, path, config).await?;
270 fs::remove_file(archive)?;
271 fs::hard_link(node_directory.join("bin/node"), path.join("node"))?;
272 Ok(())
273}
274
275pub fn export_resources(path: &Path) -> Result<()> {
281 if !path.is_absolute() {
282 return Err(Error::Config(
283 "computer resource destination must be absolute".into(),
284 ));
285 }
286 fs::create_dir_all(path)?;
287 for (name, content) in [
288 ("worker.cjs", WORKER),
289 ("computer-control.md", DOCUMENTATION),
290 ("package.json", PACKAGE),
291 ("package-lock.json", LOCKFILE),
292 ("LICENSE", include_str!("../LICENSE")),
293 ("NOTICE", include_str!("../NOTICE")),
294 ] {
295 fs::write(path.join(name), content)?;
296 }
297 let config = ComputerConfig::default();
298 let mut options = worker_options(path, &config)?;
299 options.playwright_module = Cow::Borrowed(Path::new("playwright"));
300 options.browsers_directory = Cow::Borrowed(Path::new("browsers"));
301 fs::write(
302 path.join("browser.json"),
303 serde_json::to_vec_pretty(&options)?,
304 )?;
305 Ok(())
306}
307
308async fn populate(path: &Path, config: &ComputerConfig) -> Result<()> {
309 config
310 .browser
311 .validate_root_sandbox(nix::unistd::geteuid().is_root())?;
312 install_node(path, config).await?;
313 export_resources(path)?;
314 if config.playwright_module.is_none() {
315 let mut npm = npm_command(path, config)?;
316 npm.args(["ci", "--ignore-scripts", "--no-fund", "--no-audit"]);
317 run(npm, path, config).await?;
318 }
319 if config.browser.executable.is_none() {
320 let mut browser = Command::new(node_executable(path, config)?);
321 browser
322 .arg(playwright_module(path, config).join("cli.js"))
323 .args(["install", "chromium"]);
324 run(browser, path, config).await?;
325 }
326 let mut verify = Command::new(node_executable(path, config)?);
327 verify.args(["-e", "(async()=>{const o=JSON.parse(process.argv[1]); const {chromium}=require(o.playwright_module); const b=await chromium.launch({headless:true,chromiumSandbox:o.sandbox,args:o.arguments,...(o.executable?{executablePath:o.executable}:{})}); await b.close()})().catch(e=>{console.error(e.message);process.exitCode=1})"])
328 .arg(serde_json::to_string(&worker_options(path, config)?)?);
329 run(verify, path, config).await?;
330 fs::remove_dir_all(path.join(".home"))?;
331 fs::remove_dir_all(path.join(".tmp"))?;
332 fs::remove_file(path.join("install.log"))?;
333 Ok(())
334}
335
336fn npm_command(path: &Path, config: &ComputerConfig) -> Result<Command> {
337 if config.node_executable.is_some() || config.npm_executable.is_some() {
338 let npm = config
339 .npm_executable
340 .as_deref()
341 .unwrap_or_else(|| Path::new("npm"));
342 return Ok(Command::new(config::resolve_executable(npm)?));
343 }
344 let mut command = Command::new(node_executable(path, config)?);
345 command.arg(path.join("distribution/lib/node_modules/npm/bin/npm-cli.js"));
346 Ok(command)
347}
348
349pub(crate) fn node_executable(path: &Path, config: &ComputerConfig) -> Result<PathBuf> {
350 match config.node_executable.as_deref() {
351 Some(executable) => config::resolve_executable(executable),
352 None => config::resolve_executable(&path.join("node")),
353 }
354}
355
356fn playwright_module<'a>(path: &Path, config: &'a ComputerConfig) -> Cow<'a, Path> {
357 config.playwright_module.as_deref().map_or_else(
358 || Cow::Owned(path.join("node_modules/playwright")),
359 Cow::Borrowed,
360 )
361}
362
363pub(crate) fn browsers_directory<'a>(path: &Path, config: &'a ComputerConfig) -> Cow<'a, Path> {
364 config
365 .browsers_directory
366 .as_deref()
367 .map_or_else(|| Cow::Owned(path.join("browsers")), Cow::Borrowed)
368}
369
370#[derive(serde::Serialize)]
371struct WorkerOptions<'a> {
372 executable: Option<PathBuf>,
373 sandbox: bool,
374 arguments: Vec<&'a str>,
375 viewport: [u32; 2],
376 image_presentation: mobius::backend::session_files::ImagePresentation,
377 start_page: &'a str,
378 playwright_module: Cow<'a, Path>,
379 browsers_directory: Cow<'a, Path>,
380 root_sandbox_error: &'static str,
381}
382
383fn worker_options<'a>(path: &Path, config: &'a ComputerConfig) -> Result<WorkerOptions<'a>> {
384 Ok(WorkerOptions {
385 executable: config
386 .browser
387 .executable
388 .as_deref()
389 .map(config::resolve_executable)
390 .transpose()?,
391 sandbox: config.browser.sandbox,
392 arguments: config.browser.launch_arguments().collect(),
393 viewport: config.browser.viewport,
394 image_presentation: mobius::backend::session_files::ImagePresentation::default(),
395 start_page: &config.browser.start_page,
396 playwright_module: playwright_module(path, config),
397 browsers_directory: browsers_directory(path, config),
398 root_sandbox_error: ROOT_SANDBOX_ERROR,
399 })
400}
401
402pub(crate) fn worker_command(
403 path: &Path,
404 config: &ComputerConfig,
405) -> Result<mobius::backend::sandbox::WorkerCommand> {
406 config.validate()?;
407 Ok(mobius::backend::sandbox::WorkerCommand {
408 executable: node_executable(path, config)?,
409 arguments: vec![
410 path.join("worker.cjs").to_string_lossy().into_owned(),
411 serde_json::to_string(&worker_options(path, config)?)?,
412 ],
413 })
414}
415
416pub(crate) fn resource_roots(
417 path: &Path,
418 config: &ComputerConfig,
419 state_dir: &Path,
420 workspace: &Path,
421 attached_folders: &[PathBuf],
422) -> Result<Vec<PathBuf>> {
423 let forbidden = std::iter::once(state_dir)
424 .chain(std::iter::once(workspace))
425 .chain(attached_folders.iter().map(PathBuf::as_path))
426 .map(fs::canonicalize)
427 .collect::<std::io::Result<Vec<_>>>()?;
428 let node = node_executable(path, config)?;
429 let browser = config
430 .browser
431 .executable
432 .as_deref()
433 .map(config::resolve_executable)
434 .transpose()?;
435 let playwright = playwright_module(path, config);
436 let browsers = browsers_directory(path, config);
437 let mut roots = [
438 Some(path),
439 Some(playwright.as_ref()),
440 Some(browsers.as_ref()),
441 node.parent(),
442 browser.as_deref().and_then(Path::parent),
443 ]
444 .into_iter()
445 .flatten()
446 .filter(|path| path.exists())
447 .map(fs::canonicalize)
448 .collect::<std::io::Result<Vec<_>>>()?;
449 for root in &roots {
450 if !root.is_dir() {
451 return Err(Error::Config(
452 "computer resource roots must be directories".into(),
453 ));
454 }
455 if forbidden
456 .iter()
457 .any(|path| root.starts_with(path) || path.starts_with(root))
458 {
459 return Err(Error::Config(
460 "computer resources must remain outside gateway state and all writable workspaces"
461 .into(),
462 ));
463 }
464 }
465 roots.sort_unstable();
466 roots.dedup();
467 Ok(roots)
468}
469
470async fn download(
471 url: &str,
472 destination: &Path,
473 checksum: &str,
474 config: &ComputerConfig,
475) -> Result<()> {
476 let mut client = HttpClient::builder()
477 .https_only(true)
478 .connect_timeout(Duration::from_secs(config.download_connect_timeout_seconds))
479 .timeout(Duration::from_secs(config.download_timeout_seconds));
480 let ca_file =
481 config.ca_file.as_deref().map(Cow::Borrowed).or_else(|| {
482 std::env::var_os("SSL_CERT_FILE").map(|path| Cow::Owned(PathBuf::from(path)))
483 });
484 if let Some(path) = ca_file {
485 for certificate in ca_certificates(&path).await? {
486 client = client.add_root_certificate(certificate);
487 }
488 }
489 let client = client
490 .build()
491 .map_err(|error| Error::Config(error.to_string()))?;
492 let mut response = client
493 .get(url)
494 .send()
495 .await
496 .and_then(|response| response.error_for_status())
497 .map_err(|error| Error::Config(error.to_string()))?;
498 let mut file = tokio::fs::File::create(destination).await?;
499 let mut hash = Sha256::new();
500 let mut size = 0_usize;
501 while let Some(chunk) = response
502 .chunk()
503 .await
504 .map_err(|error| Error::Config(error.to_string()))?
505 {
506 size = size.saturating_add(chunk.len());
507 if size > MAX_DOWNLOAD_BYTES {
508 return Err(Error::Config(
509 "runtime download exceeds its size limit".into(),
510 ));
511 }
512 hash.update(&chunk);
513 file.write_all(&chunk).await?;
514 }
515 if format!("{:x}", hash.finalize()) != checksum {
516 return Err(Error::Config("Node runtime checksum did not match".into()));
517 }
518 file.flush().await?;
519 Ok(())
520}
521
522async fn run(mut command: Command, path: &Path, config: &ComputerConfig) -> Result<()> {
523 fs::create_dir_all(path.join(".home"))?;
524 fs::create_dir_all(path.join(".tmp"))?;
525 command
526 .current_dir(path)
527 .kill_on_drop(true)
528 .env_clear()
529 .env("HOME", path.join(".home"))
530 .env("TMPDIR", path.join(".tmp"))
531 .env("PATH", installer_path(path, config)?)
532 .env(
533 "PLAYWRIGHT_BROWSERS_PATH",
534 browsers_directory(path, config).as_ref(),
535 )
536 .env("PLAYWRIGHT_SKIP_BROWSER_GC", "1")
537 .env("npm_config_userconfig", "/dev/null")
538 .env("npm_config_globalconfig", path.join(".home/global-npmrc"))
539 .stdin(Stdio::null())
540 .stdout(Stdio::null())
541 .stderr(File::create(path.join("install.log"))?);
542 forward_installer_environment(&mut command, |name| std::env::var_os(name));
543 if let Some(path) = &config.ca_file {
544 command
545 .env("SSL_CERT_FILE", path)
546 .env("NODE_EXTRA_CA_CERTS", path)
547 .env("npm_config_cafile", path);
548 }
549 #[cfg(unix)]
550 command.process_group(0);
551 let mut child = command.spawn()?;
552 #[cfg(unix)]
553 let _group = ProcessGroupGuard::new(&child)?;
554 if !child.wait().await?.success() {
555 return Err(Error::Config("runtime installer failed".into()));
558 }
559 Ok(())
560}
561
562fn installer_path(path: &Path, config: &ComputerConfig) -> Result<OsString> {
563 let directory = match &config.node_executable {
564 Some(node) => config::resolve_executable(node)?
565 .parent()
566 .ok_or_else(|| Error::Config("Node executable has no parent".into()))?
567 .to_path_buf(),
568 None => path.join("distribution/bin"),
569 };
570 std::env::join_paths([directory, PathBuf::from("/usr/bin"), PathBuf::from("/bin")])
573 .map_err(|error| Error::Config(error.to_string()))
574}
575
576fn forward_installer_environment(command: &mut Command, get: impl Fn(&str) -> Option<OsString>) {
577 crate::process_environment::forward_network_environment(command, &get);
578 for name in [
579 "NODE_EXTRA_CA_CERTS",
580 "NODE_USE_SYSTEM_CA",
581 "npm_config_cafile",
582 "NPM_CONFIG_CAFILE",
583 "PLAYWRIGHT_DOWNLOAD_HOST",
584 "PLAYWRIGHT_CHROMIUM_DOWNLOAD_HOST",
585 ] {
586 if let Some(value) = get(name) {
587 command.env(name, value);
588 }
589 }
590 if get("NODE_EXTRA_CA_CERTS").is_none()
592 && let Some(value) = get("SSL_CERT_FILE")
593 {
594 command.env("NODE_EXTRA_CA_CERTS", value);
595 }
596}
597
598async fn ca_certificates(path: &Path) -> Result<Vec<HttpCertificate>> {
599 const MAX_CA_BYTES: usize = 1024 * 1024;
600 let mut bytes = Vec::new();
601 tokio::fs::File::open(path)
602 .await?
603 .take(
604 u64::try_from(MAX_CA_BYTES.saturating_add(1))
605 .map_err(|_| Error::Config("computer CA limit is not representable".into()))?,
606 )
607 .read_to_end(&mut bytes)
608 .await?;
609 if bytes.len() > MAX_CA_BYTES {
610 return Err(Error::Config(
611 "computer CA bundle exceeds its size limit".into(),
612 ));
613 }
614 let certificates = HttpCertificate::from_pem_bundle(&bytes)
615 .map_err(|_| Error::Config("invalid computer CA certificate bundle".into()))?;
616 if certificates.is_empty() {
617 return Err(Error::Config(
618 "computer CA bundle contains no certificates".into(),
619 ));
620 }
621 Ok(certificates)
622}
623
624#[cfg(test)]
625mod tests;