Skip to main content

mj_controller/
tailscale.rs

1//! Optional trusted HTTPS for the daemon-owned web viewer.
2//!
3//! Tailscale owns ACME issuance for its `ts.net` names. Hel only discovers the
4//! local node, asks the CLI for that certificate, and stores the resulting
5//! pair in its private data directory.
6
7use std::ffi::OsStr;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result, anyhow, bail};
12use serde::Deserialize;
13
14use crate::targets::{CommandExecutor, CommandSpec};
15use mj_core::config::atomic_write;
16
17const CERT_FILE: &str = "tailscale-cert.pem";
18const KEY_FILE: &str = "tailscale-key.pem";
19
20#[derive(Debug, Clone)]
21struct Tailscale {
22    binary: PathBuf,
23    cert_domain: String,
24}
25
26/// A Tailscale identity and its persisted certificate pair.
27#[derive(Debug, Clone)]
28pub struct TailscaleTls {
29    tailscale: Tailscale,
30    cert_path: PathBuf,
31    key_path: PathBuf,
32}
33
34impl TailscaleTls {
35    pub fn cert_domain(&self) -> &str {
36        &self.tailscale.cert_domain
37    }
38
39    pub fn cert_path(&self) -> &Path {
40        &self.cert_path
41    }
42
43    pub fn key_path(&self) -> &Path {
44        &self.key_path
45    }
46
47    /// Refresh the persisted pair. Callers keep serving their currently
48    /// loaded certificate until they have successfully reloaded these files.
49    pub fn renew(&self, executor: &impl CommandExecutor) -> Result<()> {
50        mint_certificate(&self.tailscale, &self.cert_path, &self.key_path, executor)
51    }
52}
53
54/// Discover a certificate-capable local Tailscale node and mint its initial
55/// certificate. Discovery failures are intentionally returned to the caller,
56/// which can safely fall back to a loopback-only listener.
57pub fn prepare_tailscale_tls(root: &Path, executor: &impl CommandExecutor) -> Result<TailscaleTls> {
58    let binary = find_binary()
59        .ok_or_else(|| anyhow!("tailscale CLI not found in PATH or the macOS app bundle"))?;
60    prepare_tailscale_tls_with_binary(root, binary, executor)
61}
62
63fn prepare_tailscale_tls_with_binary(
64    root: &Path,
65    binary: PathBuf,
66    executor: &impl CommandExecutor,
67) -> Result<TailscaleTls> {
68    let tailscale = discover_with_binary(binary, executor)?;
69    fs::create_dir_all(root)
70        .with_context(|| format!("create web viewer TLS directory {}", root.display()))?;
71    let tls = TailscaleTls {
72        tailscale,
73        cert_path: root.join(CERT_FILE),
74        key_path: root.join(KEY_FILE),
75    };
76    tls.renew(executor)?;
77    Ok(tls)
78}
79
80fn discover_with_binary(binary: PathBuf, executor: &impl CommandExecutor) -> Result<Tailscale> {
81    let command = CommandSpec::new(
82        binary.to_string_lossy(),
83        ["status".to_owned(), "--json".to_owned()],
84    )
85    .purpose("inspect local Tailscale status");
86    let output = executor.execute(&command)?;
87    if output.status != 0 {
88        bail!(
89            "`tailscale status` failed: {}",
90            String::from_utf8_lossy(&output.stderr).trim()
91        );
92    }
93    let status: Status =
94        serde_json::from_slice(&output.stdout).context("parse `tailscale status --json` output")?;
95    let cert_domain = cert_domain(&status)?;
96    Ok(Tailscale {
97        binary,
98        cert_domain,
99    })
100}
101
102fn mint_certificate(
103    tailscale: &Tailscale,
104    cert_path: &Path,
105    key_path: &Path,
106    executor: &impl CommandExecutor,
107) -> Result<()> {
108    let parent = cert_path
109        .parent()
110        .filter(|parent| !parent.as_os_str().is_empty())
111        .unwrap_or_else(|| Path::new("."));
112    fs::create_dir_all(parent)
113        .with_context(|| format!("create web viewer TLS directory {}", parent.display()))?;
114    let suffix = temporary_suffix()?;
115    let staged_cert = parent.join(format!(".{CERT_FILE}.{suffix}.tmp"));
116    let staged_key = parent.join(format!(".{KEY_FILE}.{suffix}.tmp"));
117    let result = (|| -> Result<()> {
118        let command = CommandSpec::new(
119            tailscale.binary.to_string_lossy(),
120            [
121                "cert".to_owned(),
122                "--cert-file".to_owned(),
123                staged_cert.to_string_lossy().into_owned(),
124                "--key-file".to_owned(),
125                staged_key.to_string_lossy().into_owned(),
126                tailscale.cert_domain.clone(),
127            ],
128        )
129        .purpose("obtain the web viewer Tailscale certificate");
130        let output = executor.execute(&command)?;
131        if output.status != 0 {
132            bail!(
133                "`tailscale cert {}` failed: {}",
134                tailscale.cert_domain,
135                String::from_utf8_lossy(&output.stderr).trim()
136            );
137        }
138        let cert = fs::read(&staged_cert)
139            .with_context(|| format!("read issued certificate {}", staged_cert.display()))?;
140        let key = fs::read(&staged_key)
141            .with_context(|| format!("read issued private key {}", staged_key.display()))?;
142        validate_pem(&cert, &key)?;
143        atomic_write(cert_path, &cert)?;
144        atomic_write(key_path, &key)?;
145        Ok(())
146    })();
147    remove_staged_file(&staged_cert);
148    remove_staged_file(&staged_key);
149    result
150}
151
152fn temporary_suffix() -> Result<String> {
153    let mut random = [0_u8; 8];
154    getrandom::fill(&mut random)
155        .map_err(|error| anyhow!("generate temporary certificate filename: {error}"))?;
156    Ok(format!(
157        "{}.{:016x}",
158        std::process::id(),
159        u64::from_le_bytes(random)
160    ))
161}
162
163fn validate_pem(cert: &[u8], key: &[u8]) -> Result<()> {
164    if !cert
165        .windows(b"-----BEGIN CERTIFICATE-----".len())
166        .any(|window| window == b"-----BEGIN CERTIFICATE-----")
167    {
168        bail!("tailscale returned a certificate file without a PEM certificate");
169    }
170    if !key
171        .windows(b"PRIVATE KEY-----".len())
172        .any(|window| window == b"PRIVATE KEY-----")
173    {
174        bail!("tailscale returned a key file without a PEM private key");
175    }
176    Ok(())
177}
178
179fn remove_staged_file(path: &Path) {
180    if let Err(error) = fs::remove_file(path)
181        && error.kind() != std::io::ErrorKind::NotFound
182    {
183        tracing::warn!(path = %path.display(), %error, "could not remove staged Tailscale certificate file");
184    }
185}
186
187fn find_binary() -> Option<PathBuf> {
188    if let Some(path) = std::env::var_os("PATH")
189        && let Some(binary) = find_binary_in_path(&path)
190    {
191        return Some(binary);
192    }
193    find_bundled_binary()
194}
195
196fn find_binary_in_path(path: &OsStr) -> Option<PathBuf> {
197    std::env::split_paths(path).find_map(|directory| {
198        tailscale_binary_names()
199            .iter()
200            .map(|name| directory.join(name))
201            .find(|candidate| candidate.is_file())
202    })
203}
204
205#[cfg(windows)]
206fn tailscale_binary_names() -> &'static [&'static str] {
207    &["tailscale.exe", "tailscale"]
208}
209
210#[cfg(not(windows))]
211fn tailscale_binary_names() -> &'static [&'static str] {
212    &["tailscale"]
213}
214
215#[cfg(target_os = "macos")]
216fn find_bundled_binary() -> Option<PathBuf> {
217    let bundled = PathBuf::from("/Applications/Tailscale.app/Contents/MacOS/Tailscale");
218    bundled.is_file().then_some(bundled)
219}
220
221#[cfg(not(target_os = "macos"))]
222fn find_bundled_binary() -> Option<PathBuf> {
223    None
224}
225
226#[derive(Debug, Deserialize)]
227struct Status {
228    #[serde(rename = "BackendState")]
229    backend_state: String,
230    #[serde(rename = "CertDomains")]
231    cert_domains: Option<Vec<String>>,
232}
233
234fn cert_domain(status: &Status) -> Result<String> {
235    if status.backend_state != "Running" {
236        bail!(
237            "tailscale is not running (state: {}); run `tailscale up` first",
238            status.backend_state
239        );
240    }
241    status
242        .cert_domains
243        .as_deref()
244        .unwrap_or_default()
245        .first()
246        .map(|domain| domain.trim_end_matches('.').to_owned())
247        .filter(|domain| !domain.is_empty())
248        .ok_or_else(|| {
249            anyhow!(
250                "this tailnet has no HTTPS certificate domains; enable MagicDNS and HTTPS Certificates under the DNS tab at https://login.tailscale.com/admin/dns, then run `mj daemon restart`"
251            )
252        })
253}
254
255#[cfg(test)]
256mod tests {
257    use std::collections::VecDeque;
258    use std::sync::Mutex;
259
260    use super::*;
261    use crate::targets::CommandOutput;
262
263    struct FakeExecutor {
264        outputs: Mutex<VecDeque<CommandOutput>>,
265        commands: Mutex<Vec<CommandSpec>>,
266        issue_files: bool,
267    }
268
269    impl FakeExecutor {
270        fn new(outputs: impl IntoIterator<Item = CommandOutput>, issue_files: bool) -> Self {
271            Self {
272                outputs: Mutex::new(outputs.into_iter().collect()),
273                commands: Mutex::new(Vec::new()),
274                issue_files,
275            }
276        }
277    }
278
279    impl CommandExecutor for FakeExecutor {
280        fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
281            self.commands.lock().unwrap().push(command.clone());
282            if self.issue_files && command.args.first().map(String::as_str) == Some("cert") {
283                fs::write(&command.args[2], b"-----BEGIN CERTIFICATE-----\ncert\n")?;
284                fs::write(&command.args[4], b"-----BEGIN PRIVATE KEY-----\nkey\n")?;
285            }
286            self.outputs
287                .lock()
288                .unwrap()
289                .pop_front()
290                .context("fake command output missing")
291        }
292    }
293
294    fn output(status: i32, stdout: &str, stderr: &str) -> CommandOutput {
295        CommandOutput {
296            status,
297            stdout: stdout.as_bytes().to_vec(),
298            stderr: stderr.as_bytes().to_vec(),
299        }
300    }
301
302    #[test]
303    fn discovers_domain_and_mints_expected_certificate() {
304        let directory = tempfile::tempdir().unwrap();
305        let executor = FakeExecutor::new(
306            [
307                output(
308                    0,
309                    r#"{"BackendState":"Running","CertDomains":["minas.tail.ts.net."]}"#,
310                    "",
311                ),
312                output(0, "", ""),
313            ],
314            true,
315        );
316
317        let tls = prepare_tailscale_tls_with_binary(
318            directory.path(),
319            PathBuf::from("/usr/bin/tailscale"),
320            &executor,
321        )
322        .unwrap();
323
324        assert_eq!(tls.cert_domain(), "minas.tail.ts.net");
325        assert!(tls.cert_path().is_file());
326        assert!(tls.key_path().is_file());
327        let commands = executor.commands.lock().unwrap();
328        assert_eq!(commands[0].args, ["status", "--json"]);
329        assert_eq!(commands[1].args[0], "cert");
330        assert_eq!(commands[1].args[5], "minas.tail.ts.net");
331    }
332
333    #[test]
334    fn missing_certificate_domains_has_actionable_guidance() {
335        let executor = FakeExecutor::new([output(0, r#"{"BackendState":"Running"}"#, "")], false);
336        let error = discover_with_binary(PathBuf::from("tailscale"), &executor).unwrap_err();
337
338        assert!(error.to_string().contains("MagicDNS"));
339        assert!(error.to_string().contains("mj daemon restart"));
340    }
341
342    #[test]
343    fn stopped_or_malformed_status_is_rejected() {
344        let stopped = FakeExecutor::new(
345            [output(
346                0,
347                r#"{"BackendState":"Stopped","CertDomains":["host.ts.net"]}"#,
348                "",
349            )],
350            false,
351        );
352        assert!(
353            discover_with_binary(PathBuf::from("tailscale"), &stopped)
354                .unwrap_err()
355                .to_string()
356                .contains("not running")
357        );
358
359        let malformed = FakeExecutor::new([output(0, "not-json", "")], false);
360        assert!(
361            discover_with_binary(PathBuf::from("tailscale"), &malformed)
362                .unwrap_err()
363                .to_string()
364                .contains("parse")
365        );
366    }
367
368    #[test]
369    fn command_failure_does_not_replace_existing_pair() {
370        let directory = tempfile::tempdir().unwrap();
371        let cert_path = directory.path().join(CERT_FILE);
372        let key_path = directory.path().join(KEY_FILE);
373        fs::write(&cert_path, "old-cert").unwrap();
374        fs::write(&key_path, "old-key").unwrap();
375        let executor = FakeExecutor::new([output(1, "", "issuance failed")], false);
376        let tailscale = Tailscale {
377            binary: PathBuf::from("tailscale"),
378            cert_domain: "host.ts.net".into(),
379        };
380
381        assert!(mint_certificate(&tailscale, &cert_path, &key_path, &executor).is_err());
382        assert_eq!(fs::read_to_string(cert_path).unwrap(), "old-cert");
383        assert_eq!(fs::read_to_string(key_path).unwrap(), "old-key");
384    }
385
386    #[cfg(unix)]
387    #[test]
388    fn persisted_private_key_is_owner_only() {
389        use std::os::unix::fs::PermissionsExt;
390
391        let directory = tempfile::tempdir().unwrap();
392        let executor = FakeExecutor::new(
393            [
394                output(
395                    0,
396                    r#"{"BackendState":"Running","CertDomains":["host.ts.net"]}"#,
397                    "",
398                ),
399                output(0, "", ""),
400            ],
401            true,
402        );
403        let tls = prepare_tailscale_tls_with_binary(
404            directory.path(),
405            PathBuf::from("tailscale"),
406            &executor,
407        )
408        .unwrap();
409
410        let mode = fs::metadata(tls.key_path()).unwrap().permissions().mode() & 0o777;
411        assert_eq!(mode, 0o600);
412    }
413}