Skip to main content

nexus_core/host/
process.rs

1//! Host-side child-process management: `cloudflared` quick tunnels and
2//! platform sleep inhibitors. These helpers are never started by unit tests;
3//! the CLI opts into them only for `nexus host --tunnel` or normal hosting.
4
5use std::process::Stdio;
6use std::time::Duration;
7
8use anyhow::{Context as _, Result, bail};
9use tokio::io::{AsyncBufReadExt, BufReader};
10use tokio::process::{Child, Command};
11
12/// A child process that is killed when hosting stops.
13pub struct ChildGuard {
14    child: Option<Child>,
15}
16
17impl ChildGuard {
18    /// Kill the guarded process and wait for it to exit.
19    pub async fn stop(&mut self) {
20        if let Some(child) = self.child.as_mut() {
21            let _ = child.kill().await;
22            let _ = child.wait().await;
23        }
24        self.child = None;
25    }
26}
27
28/// A quick `trycloudflare.com` tunnel process. Named-tunnel configuration is
29/// deliberately supplied by the CLI/setup layer; this type only owns the
30/// sidecar lifecycle and URL discovery.
31pub struct Tunnel {
32    child: Option<Child>,
33    public_url: Option<String>,
34}
35
36impl Tunnel {
37    /// Start a quick tunnel to a loopback host port. URL discovery is bounded;
38    /// a running process with no parsed URL is still returned so callers can
39    /// report "started, URL unknown" instead of hanging forever.
40    pub async fn quick(port: u16) -> Result<Self> {
41        let mut child = Command::new("cloudflared")
42            .args([
43                "tunnel",
44                "--no-autoupdate",
45                "--url",
46                &format!("http://127.0.0.1:{port}"),
47            ])
48            .stdout(Stdio::null())
49            .stderr(Stdio::piped())
50            .stdin(Stdio::null())
51            .kill_on_drop(true)
52            .spawn()
53            .context("starting cloudflared quick tunnel")?;
54        let stderr = child
55            .stderr
56            .take()
57            .context("capturing cloudflared output")?;
58        let mut lines = BufReader::new(stderr).lines();
59        let deadline = tokio::time::sleep(Duration::from_secs(15));
60        tokio::pin!(deadline);
61        let mut public_url = None;
62        loop {
63            tokio::select! {
64                line = lines.next_line() => match line {
65                    Ok(Some(line)) => {
66                        if let Some(url) = parse_trycloudflare_url(&line) {
67                            public_url = Some(url);
68                            break;
69                        }
70                    }
71                    _ => break,
72                },
73                () = &mut deadline => break,
74            }
75        }
76        // Keep draining stderr after discovery so a verbose sidecar cannot
77        // block on a full pipe. The task ends when the child is killed.
78        tokio::spawn(async move { while lines.next_line().await.ok().flatten().is_some() {} });
79        Ok(Self {
80            child: Some(child),
81            public_url,
82        })
83    }
84
85    /// Start a named tunnel from a generated `cloudflared` config.
86    pub fn named(config: &std::path::Path, tunnel_id: &str) -> Result<Self> {
87        let mut child = Command::new("cloudflared")
88            .args(["tunnel", "--config"])
89            .arg(config)
90            .args(["run", tunnel_id])
91            .stdout(Stdio::null())
92            .stderr(Stdio::null())
93            .stdin(Stdio::null())
94            .kill_on_drop(true)
95            .spawn()
96            .context("starting cloudflared named tunnel")?;
97        // Named tunnels use the hostname selected during setup, so there is
98        // no quick-tunnel URL to parse here.
99        if child.try_wait().ok().flatten().is_some() {
100            bail!("cloudflared named tunnel exited during startup");
101        }
102        Ok(Self {
103            child: Some(child),
104            public_url: None,
105        })
106    }
107
108    /// The parsed public URL, if cloudflared printed one during startup.
109    pub fn public_url(&self) -> Option<&str> {
110        self.public_url.as_deref()
111    }
112
113    /// Wait until the sidecar exits, returning its success status.
114    pub async fn wait(&mut self) -> Option<bool> {
115        let child = self.child.as_mut()?;
116        Some(child.wait().await.is_ok_and(|status| status.success()))
117    }
118
119    /// Kill the sidecar and wait for it to exit.
120    pub async fn stop(&mut self) {
121        if let Some(child) = self.child.as_mut() {
122            let _ = child.kill().await;
123            let _ = child.wait().await;
124        }
125        self.child = None;
126    }
127}
128
129/// Start the platform's sleep inhibitor. Unsupported platforms return
130/// `Ok(None)`; a missing Linux `systemd-inhibit` is an actionable error for
131/// the caller, which may choose to continue with a warning.
132pub fn sleep_guard() -> Result<Option<ChildGuard>> {
133    #[cfg(target_os = "macos")]
134    {
135        let child = Command::new("caffeinate")
136            .args(["-dimsu"])
137            .stdin(Stdio::null())
138            .stdout(Stdio::null())
139            .stderr(Stdio::null())
140            .kill_on_drop(true)
141            .spawn()
142            .context("starting macOS caffeinate")?;
143        return Ok(Some(ChildGuard { child: Some(child) }));
144    }
145    #[cfg(target_os = "linux")]
146    {
147        let child = Command::new("systemd-inhibit")
148            .args([
149                "--what=sleep",
150                "--who=nexus",
151                "--mode=block",
152                "sleep",
153                "infinity",
154            ])
155            .stdin(Stdio::null())
156            .stdout(Stdio::null())
157            .stderr(Stdio::null())
158            .kill_on_drop(true)
159            .spawn()
160            .context("starting systemd-inhibit")?;
161        return Ok(Some(ChildGuard { child: Some(child) }));
162    }
163    #[allow(unreachable_code)]
164    Ok(None)
165}
166
167/// Whether `cloudflared` can be launched from `PATH`.
168pub fn cloudflared_available() -> bool {
169    std::process::Command::new("cloudflared")
170        .arg("--version")
171        .stdout(Stdio::null())
172        .stderr(Stdio::null())
173        .status()
174        .is_ok_and(|status| status.success())
175}
176
177fn parse_trycloudflare_url(line: &str) -> Option<String> {
178    let start = line.find("https://")?;
179    let candidate = &line[start..];
180    let end = candidate
181        .find(|character: char| {
182            character.is_whitespace() || matches!(character, '"' | '\'' | ')' | ']')
183        })
184        .unwrap_or(candidate.len());
185    let url = &candidate[..end];
186    url.contains(".trycloudflare.com")
187        .then(|| url.trim_end_matches('/').to_string())
188}
189
190/// Probe the public host. A `401` from `/v1/snapshot` is healthy: it proves
191/// the tunnel reached the daemon and only host authentication stopped it.
192pub async fn health_check(base: &str) -> bool {
193    let url = format!("{}/v1/snapshot", base.trim_end_matches('/'));
194    reqwest::Client::new()
195        .get(url)
196        .timeout(Duration::from_secs(3))
197        .send()
198        .await
199        .is_ok_and(|response| response.status().is_success() || response.status().as_u16() == 401)
200}
201
202/// Battery warning used by the macOS host CLI.
203pub fn on_battery() -> Option<bool> {
204    #[cfg(target_os = "macos")]
205    {
206        let output = std::process::Command::new("pmset")
207            .args(["-g", "batt"])
208            .output()
209            .ok()?;
210        let text = String::from_utf8_lossy(&output.stdout);
211        return Some(text.contains("Battery Power") && !text.contains("AC Power"));
212    }
213    #[cfg(not(target_os = "macos"))]
214    {
215        None
216    }
217}
218
219/// A small helper for callers that need a consistent missing-sidecar error.
220pub fn require_cloudflared() -> Result<()> {
221    if cloudflared_available() {
222        Ok(())
223    } else {
224        bail!("cloudflared is not in PATH — install it or run without --tunnel")
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::parse_trycloudflare_url;
231
232    #[test]
233    fn parses_quick_tunnel_url_from_cloudflared_log() {
234        assert_eq!(
235            parse_trycloudflare_url("INF | https://quiet-river.trycloudflare.com").as_deref(),
236            Some("https://quiet-river.trycloudflare.com")
237        );
238        assert!(parse_trycloudflare_url("no public URL yet").is_none());
239    }
240}