Skip to main content

ljos_cli/
mesh.rs

1//! `ljos mesh up`, `ljos mesh down`, `ljos mesh status` and `ljos mesh acl`:
2//! join this machine to the Headscale mesh, leave it, print the nodes the
3//! daemon reports, and print the policy `mesh.toml` describes. `up` takes a
4//! key file, a login server and a hostname. A seat works without the mesh,
5//! so `up` and `down` end every failure with one line and exit 0.
6
7use anyhow::{anyhow, bail, Context, Result};
8use serde_json::{json, Value};
9use std::path::{Path, PathBuf};
10use std::process::{Child, Command, Output, Stdio};
11use std::time::{Duration, Instant};
12
13/// `mesh.toml` in the ljos config directory. `ljos mesh acl` reads it.
14/// `up` and `down` do not: the join comes from flags and the environment.
15#[derive(serde::Deserialize)]
16#[serde(deny_unknown_fields)]
17struct Config {
18    #[allow(dead_code)]
19    login_server: String,
20    /// Kept so a file written for an earlier mesh still parses.
21    #[allow(dead_code)]
22    key_env: String,
23    swarm: String,
24    ports: Ports,
25}
26
27#[derive(serde::Deserialize)]
28#[serde(deny_unknown_fields, rename_all = "kebab-case")]
29struct Ports {
30    api: u16,
31    store_ro: u16,
32    docs_ro: u16,
33    drop: u16,
34    /// Not a box port. `up` uses [`SOCKS_PORT`] on its own.
35    #[allow(dead_code)]
36    socks: u16,
37}
38
39impl Ports {
40    fn on_box(&self) -> [u16; 4] {
41        [self.api, self.store_ro, self.docs_ro, self.drop]
42    }
43}
44
45/// The leidarljos Headscale server. `--login-server` and
46/// `LJOS_MESH_LOGIN_SERVER` override it.
47const DEFAULT_LOGIN_SERVER: &str = "https://hs.grund.rgoswami.me";
48
49/// The bound on one `up` or `down`, in seconds: the `timeout -k 5 30` the
50/// documented caller wraps a sitting in. `LJOS_MESH_WAIT` sets another.
51/// Each external call in the join uses this same bound.
52const WAIT_SECS: u64 = 30;
53/// How long a stopped tailscaled has to exit before it is killed: the
54/// `-k 5` of that bound.
55const GRACE: Duration = Duration::from_secs(5);
56/// A delay a person reads as no delay: 0.1 s (Nielsen, Usability
57/// Engineering, 1993, §5.5).
58const INSTANT_MS: u64 = 100;
59/// The step between looks at a socket or a process. Each polled wait ends
60/// up to one step late, and one `up` sits through at most two of them (a
61/// stale daemon's exit, then the new socket), so two steps fit in
62/// `INSTANT_MS`. A look is one stat or one signal.
63const POLL: Duration = Duration::from_millis(INSTANT_MS / 2);
64/// Random hex digits in a node name. Three bytes give N = 2^24 names, so n
65/// nodes in one swarm share a name with odds near n²/2N (the birthday
66/// bound): under 1 in 1,000 for at most 183 nodes, under 1 in 100 for at
67/// most 579.
68const NODE_HEX: usize = 6;
69/// The longest swarm id a node name fits: a DNS label is at most 63
70/// octets (RFC 1035 §2.3.4), less `ag-`, a dash and the hex digits.
71const SWARM_MAX: usize = 63 - "ag-".len() - 1 - NODE_HEX;
72/// CAP_NET_ADMIN's bit in a capability mask (linux/capability.h). Creating
73/// a TUN interface needs it. Opening `/dev/net/tun` does not.
74const CAP_NET_ADMIN: u32 = 12;
75
76/// SOCKS5 port in userspace mode. Tailscale's userspace networking guide
77/// uses 1055, and `examples/mesh.toml` records the same port. The proxy
78/// listens on localhost only.
79const SOCKS_PORT: u16 = 1055;
80
81/// Static tailscale build used when `tailscale` or `tailscaled` is not on
82/// `PATH`. The archive on pkgs.tailscale.com is
83/// `tailscale_1.90.6_amd64.tgz`.
84const TAILSCALE_VERSION: &str = "1.90.6";
85/// pkgs.tailscale.com names the x86_64 archive `amd64`.
86const TAILSCALE_ARCH: &str = "amd64";
87/// sha256 of that tarball. The published file
88/// `tailscale_1.90.6_amd64.tgz.sha256` is this hex and nothing else
89/// (pkgs.tailscale.com, 2026-11-03).
90const TARBALL_SHA256: &str = "cc51b5b6fe86a25e66b2ef037334e799a61d06dc64fabcadfe6e2a7588a2b2c7";
91/// Bytes in that tarball: Content-Length on pkgs.tailscale.com,
92/// 2026-11-03. The reader stops here so a different body cannot fill the
93/// disk; the sha256 then rejects it.
94const TARBALL_MAX_BYTES: u64 = 33_387_158;
95
96const SOCKET: &str = "tailscaled.sock";
97const PID: &str = "tailscaled.pid";
98const LOG: &str = "tailscaled.log";
99const JOINED: &str = "joined";
100const RELEASE: &str = "release";
101
102/// Arguments for `ljos mesh up`. Empty fields fall through to the environment
103/// and then to the defaults in [`up`].
104pub struct UpArgs {
105    pub key_file: Option<PathBuf>,
106    pub login_server: Option<String>,
107    pub hostname: Option<String>,
108}
109
110/// What `up` or `down` prints. `error` goes to stderr. The process still
111/// exits 0: the line names the step and does not stop the caller.
112#[must_use]
113pub struct MeshOut {
114    text: String,
115    error: bool,
116}
117
118impl MeshOut {
119    fn ok(text: impl Into<String>) -> Self {
120        Self {
121            text: text.into(),
122            error: false,
123        }
124    }
125
126    fn err(text: impl Into<String>) -> Self {
127        Self {
128            text: text.into(),
129            error: true,
130        }
131    }
132
133    /// Print the line on stdout, or on stderr when it records a failure.
134    pub fn write(&self) {
135        if self.text.is_empty() {
136            return;
137        }
138        if self.error {
139            eprintln!("{}", self.text);
140        } else {
141            println!("{}", self.text);
142        }
143    }
144}
145
146struct Fail {
147    step: &'static str,
148    err: anyhow::Error,
149}
150
151impl Fail {
152    fn step(step: &'static str, err: anyhow::Error) -> Self {
153        Self { step, err }
154    }
155
156    fn line(&self, verb: &str) -> String {
157        one_line(&format!("mesh {verb}: {}: {:#}", self.step, self.err))
158    }
159}
160
161fn call_timeout() -> Duration {
162    let secs = std::env::var("LJOS_MESH_WAIT")
163        .ok()
164        .and_then(|v| v.trim().parse::<u64>().ok())
165        .filter(|s| *s > 0)
166        .unwrap_or(WAIT_SECS);
167    Duration::from_secs(secs)
168}
169
170fn timed_out(err: &anyhow::Error) -> bool {
171    err.to_string().starts_with("did not finish")
172}
173
174/// Binaries, the socket, the pid and the log: under the ljos state
175/// directory, outside any checkout.
176fn state_dir() -> PathBuf {
177    crate::state_base_dir().join("mesh")
178}
179
180/// The node `ljos mesh up` joined as, while the tailscaled it started runs.
181/// A daemon that is gone takes the label with it.
182#[must_use]
183pub fn joined_node() -> Option<String> {
184    let dir = state_dir();
185    let node = std::fs::read_to_string(dir.join(JOINED))
186        .ok()
187        .map(|n| n.trim().to_string())
188        .filter(|n| !n.is_empty())?;
189    if still_up(&dir) {
190        return Some(node);
191    }
192    let _ = std::fs::remove_file(dir.join(JOINED));
193    None
194}
195
196/// Whether the pid file still names the tailscaled `start_daemon`
197/// recorded. On Linux that is one read of `/proc`, so every write checks.
198#[cfg(target_os = "linux")]
199fn still_up(dir: &Path) -> bool {
200    recorded(dir).is_some_and(|(_, ours)| ours)
201}
202
203/// The same check with `ps` run once per pid file in this process, so a
204/// pack write does not start a process each time. Later writes ask only
205/// whether the pid is live. A pid reused between two writes of one
206/// process keeps the label until that process ends.
207#[cfg(not(target_os = "linux"))]
208fn still_up(dir: &Path) -> bool {
209    use std::sync::{Mutex, PoisonError};
210    static SEEN: Mutex<Option<String>> = Mutex::new(None);
211    let Ok(text) = std::fs::read_to_string(dir.join(PID)) else {
212        return false;
213    };
214    let mut seen = SEEN.lock().unwrap_or_else(PoisonError::into_inner);
215    let pid = text
216        .lines()
217        .next()
218        .and_then(|l| l.trim().parse::<libc::pid_t>().ok());
219    // SAFETY: signal 0 sends nothing; it only asks whether the pid exists.
220    let live = pid.is_some_and(|pid| unsafe { libc::kill(pid, 0) } == 0);
221    if live && seen.as_deref() == Some(text.as_str()) {
222        return true;
223    }
224    let ours = recorded(dir).is_some_and(|(_, ours)| ours);
225    *seen = ours.then_some(text);
226    ours
227}
228
229/// Returns the start time of `pid` in clock ticks since boot. It returns
230/// none when no live process has that pid. A reused pid starts later, so
231/// the start time pins down one process.
232#[cfg(target_os = "linux")]
233fn started(pid: u32) -> Option<String> {
234    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
235    // The name before the last `)` may contain spaces. After it come the
236    // state (field 3) and, 19 fields on, the start time (field 22), as
237    // proc_pid_stat(5) numbers them.
238    let mut fields = stat.rsplit_once(')')?.1.split_whitespace();
239    if matches!(fields.next()?, "Z" | "X") {
240        return None;
241    }
242    fields.nth(18).map(str::to_string)
243}
244
245/// When `pid` started, as `ps` prints it, or none when no process has it.
246/// `lstart` counts whole seconds, so a pid reused within the second the
247/// daemon started would match. macOS hands out pids in turn up to 99,999,
248/// so that needs the pids to wrap within that second.
249#[cfg(not(target_os = "linux"))]
250fn started(pid: u32) -> Option<String> {
251    let out = Command::new("ps")
252        .args(["-o", "lstart=", "-p", &pid.to_string()])
253        .stdin(Stdio::null())
254        .stderr(Stdio::null())
255        .output()
256        .ok()?;
257    let when = String::from_utf8_lossy(&out.stdout).trim().to_string();
258    (out.status.success() && !when.is_empty()).then_some(when)
259}
260
261/// The pid and start time in the pid file. An ljos before the start time
262/// check wrote the pid alone.
263fn pid_file(dir: &Path) -> Option<(u32, Option<String>)> {
264    let text = std::fs::read_to_string(dir.join(PID)).ok()?;
265    let mut lines = text.lines();
266    let pid = lines.next()?.trim().parse::<u32>().ok()?;
267    let at = lines.next().map(str::trim).filter(|at| !at.is_empty());
268    Some((pid, at.map(str::to_string)))
269}
270
271/// The pid in the pid file, and whether it still names the tailscaled
272/// `start_daemon` recorded: the same pid with the same start time.
273fn recorded(dir: &Path) -> Option<(u32, bool)> {
274    let (pid, at) = pid_file(dir)?;
275    let ours = at.is_some_and(|at| started(pid).as_deref() == Some(at.as_str()));
276    Some((pid, ours))
277}
278
279/// The note for a pid file with no start time, or none.
280fn old_format(dir: &Path, then: &str) -> Option<String> {
281    let (pid, None) = pid_file(dir)? else {
282        return None;
283    };
284    Some(format!(
285        "{PID} names pid {pid} with no start time, so {then}"
286    ))
287}
288
289fn load() -> Result<Config> {
290    let path = crate::config_base()
291        .context("no config directory: HOME and XDG_CONFIG_HOME are unset")?
292        .join("ljos")
293        .join("mesh.toml");
294    let text = std::fs::read_to_string(&path)
295        .with_context(|| format!("no mesh config at {}", path.display()))?;
296    let config: Config =
297        toml::from_str(&text).with_context(|| format!("{} does not read", path.display()))?;
298    check_swarm(&config.swarm)?;
299    Ok(config)
300}
301
302fn check_swarm(swarm: &str) -> Result<()> {
303    let label = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-';
304    if swarm.is_empty()
305        || swarm.len() > SWARM_MAX
306        || !swarm.bytes().all(label)
307        || swarm.starts_with('-')
308        || swarm.ends_with('-')
309    {
310        bail!(
311            "swarm id {swarm:?} is not 1 to {SWARM_MAX} lowercase letters, digits and inner dashes"
312        );
313    }
314    Ok(())
315}
316
317/// Whitespace folded to single spaces.
318fn one_line(text: &str) -> String {
319    text.split_whitespace().collect::<Vec<_>>().join(" ")
320}
321
322/// The join key, read only so a child that echoes it can be scrubbed.
323/// The file is not copied and the bytes are not written anywhere.
324fn redact(text: &str, key_file: Option<&Path>) -> String {
325    let Some(path) = key_file else {
326        return text.to_string();
327    };
328    let Ok(raw) = std::fs::read(path) else {
329        return text.to_string();
330    };
331    let owned = String::from_utf8_lossy(&raw);
332    let mut needle = owned.trim().to_string();
333    let out = if needle.is_empty() {
334        text.to_string()
335    } else {
336        text.replace(&needle, "[key]")
337    };
338    use zeroize::Zeroize;
339    needle.zeroize();
340    out
341}
342
343fn known_key(args: &UpArgs) -> Option<PathBuf> {
344    let path = args
345        .key_file
346        .clone()
347        .or_else(|| std::env::var_os("LJOS_MESH_KEY_FILE").map(PathBuf::from))?;
348    path.is_file().then_some(path)
349}
350
351fn scrub(text: &str, key_file: Option<&Path>) -> String {
352    one_line(&redact(text, key_file))
353}
354
355/// `ljos mesh up`. A successful join prints this node's name, its mesh
356/// IPv4 and IPv6, and each peer's name and addresses. Any failure is one
357/// line; [`MeshOut::write`] sends it to stderr.
358pub fn up(args: UpArgs) -> MeshOut {
359    let key = known_key(&args);
360    match bring_up(&args) {
361        Ok(text) => MeshOut::ok(redact(&text, key.as_deref())),
362        Err(fail) => MeshOut::err(scrub(&fail.line("up"), key.as_deref())),
363    }
364}
365
366fn bring_up(args: &UpArgs) -> Result<String, Fail> {
367    let dir = state_dir();
368    ensure_dir(&dir).map_err(|e| Fail::step("state", e))?;
369    let hostname = hostname_of(args).map_err(|e| Fail::step("hostname", e))?;
370    let server = login_server_of(args);
371    let (tailscale, tailscaled) = ensure_bins(&dir)?;
372    let socket = dir.join(SOCKET);
373    let answered =
374        status_json(&tailscale, &socket).map_err(|e| Fail::step("tailscale status", e))?;
375    if answered
376        .as_ref()
377        .is_some_and(|status| running_as(status, &hostname))
378    {
379        let _ = std::fs::write(dir.join(JOINED), format!("{hostname}\n"));
380        let mut notes = Vec::new();
381        if let Some(note) = old_format(
382            &dir,
383            "writes carry no source.mesh label and mesh down signals nothing",
384        ) {
385            notes.push(note);
386        }
387        return Ok(with_notes(
388            mesh_status(&tailscale, &socket, &hostname)?,
389            &notes,
390        ));
391    }
392    let key = key_file(args).map_err(|e| Fail::step("key file", e))?;
393    let kernel = tun_usable();
394    let (started, stale) = daemon_for(&tailscaled, &dir, &socket, answered.is_none(), kernel)?;
395    let started_here = started.is_some();
396    if let Err(e) = login(&tailscale, &socket, &server, &key, &hostname) {
397        reap(started, &dir);
398        return Err(Fail::step("tailscale up", e));
399    }
400    // Detach. Dropping `Child` does not stop the daemon.
401    drop(started);
402    let _ = std::fs::write(dir.join(JOINED), format!("{hostname}\n"));
403    let mut notes = Vec::new();
404    if let Some(note) = stale {
405        notes.push(note);
406    }
407    if started_here {
408        if let Some(note) = old_format(
409            &dir,
410            "writes carry no source.mesh label and mesh down signals nothing",
411        ) {
412            notes.push(note);
413        }
414    }
415    Ok(with_notes(
416        mesh_status(&tailscale, &socket, &hostname)?,
417        &notes,
418    ))
419}
420
421fn with_notes(line: String, notes: &[String]) -> String {
422    if notes.is_empty() {
423        line
424    } else {
425        format!("{line}; {}", notes.join("; "))
426    }
427}
428
429/// This node, then each peer, from `tailscale status --json`.
430/// `TailscaleIPs` lists the mesh addresses; a colon marks IPv6.
431fn mesh_status(tailscale: &Path, socket: &Path, hostname: &str) -> Result<String, Fail> {
432    match status_json(tailscale, socket) {
433        Ok(Some(status)) => Ok(status_line(&status, hostname)),
434        Ok(None) => Ok(format!("mesh up: {hostname}")),
435        Err(e) if timed_out(&e) => Err(Fail::step("tailscale status", e)),
436        Err(_) => Ok(format!("mesh up: {hostname}")),
437    }
438}
439
440fn status_line(status: &Value, hostname: &str) -> String {
441    let name = status["Self"]["HostName"]
442        .as_str()
443        .filter(|name| !name.is_empty())
444        .unwrap_or(hostname);
445    let (v4, v6) = mesh_addrs(&status["Self"]);
446    let mut parts = vec![name.to_string()];
447    if !v4.is_empty() {
448        parts.push(v4);
449    }
450    if !v6.is_empty() {
451        parts.push(v6);
452    }
453    let mut line = format!("mesh up: {}", parts.join(" "));
454    let peers = peer_list(status);
455    if !peers.is_empty() {
456        line.push_str(" peer ");
457        line.push_str(&peers);
458    }
459    line
460}
461
462fn mesh_addrs(node: &Value) -> (String, String) {
463    let mut v4 = String::new();
464    let mut v6 = String::new();
465    if let Some(ips) = node["TailscaleIPs"].as_array() {
466        for ip in ips.iter().filter_map(Value::as_str) {
467            if ip.contains(':') {
468                if v6.is_empty() {
469                    v6 = ip.to_string();
470                }
471            } else if v4.is_empty() {
472                v4 = ip.to_string();
473            }
474        }
475    }
476    (v4, v6)
477}
478
479fn peer_list(status: &Value) -> String {
480    let Some(peers) = status["Peer"].as_object() else {
481        return String::new();
482    };
483    let mut lines = Vec::new();
484    for peer in peers.values() {
485        let name = peer["HostName"].as_str().unwrap_or("");
486        let (v4, v6) = mesh_addrs(peer);
487        let mut bits = Vec::new();
488        if !name.is_empty() {
489            bits.push(name.to_string());
490        }
491        if !v4.is_empty() {
492            bits.push(v4);
493        }
494        if !v6.is_empty() {
495            bits.push(v6);
496        }
497        if !bits.is_empty() {
498            lines.push(bits.join(" "));
499        }
500    }
501    lines.sort();
502    lines.join(", ")
503}
504
505fn daemon_for(
506    tailscaled: &Path,
507    dir: &Path,
508    socket: &Path,
509    start: bool,
510    kernel: bool,
511) -> Result<(Option<Child>, Option<String>), Fail> {
512    if !start {
513        return Ok((None, None));
514    }
515    let stale = stop_daemon(dir, Instant::now() + GRACE);
516    let _ = std::fs::remove_file(socket);
517    let mut child = match start_daemon(tailscaled, dir, kernel) {
518        Ok(child) => child,
519        Err(e) => return Err(Fail::step("tailscaled", e)),
520    };
521    if let Err(e) = wait_for_socket(socket, Some(&mut child), Instant::now() + call_timeout()) {
522        reap(Some(child), dir);
523        return Err(Fail::step("tailscaled", e));
524    }
525    Ok((Some(child), stale))
526}
527
528fn ensure_dir(dir: &Path) -> Result<()> {
529    use std::os::unix::fs::DirBuilderExt as _;
530    std::fs::DirBuilder::new()
531        .recursive(true)
532        .mode(0o700)
533        .create(dir)
534        .with_context(|| format!("{} could not be made", dir.display()))?;
535    Ok(())
536}
537
538fn hostname_of(args: &UpArgs) -> Result<String> {
539    if let Some(name) = args
540        .hostname
541        .as_deref()
542        .map(str::trim)
543        .filter(|s| !s.is_empty())
544    {
545        return Ok(name.to_string());
546    }
547    system_hostname()
548}
549
550fn system_hostname() -> Result<String> {
551    let mut buf = [0u8; 256];
552    // POSIX.1-2008 says the name fits in 255 bytes plus a NUL. Linux
553    // HOST_NAME_MAX is 64. 256 covers both.
554    // SAFETY: gethostname writes at most `buf.len()` bytes into `buf` and
555    // does not retain the pointer.
556    let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
557    if rc != 0 {
558        bail!("gethostname: {}", std::io::Error::last_os_error());
559    }
560    let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len());
561    let name = String::from_utf8_lossy(&buf[..end]).trim().to_string();
562    if name.is_empty() {
563        bail!("the system hostname is empty");
564    }
565    Ok(name)
566}
567
568fn login_server_of(args: &UpArgs) -> String {
569    args.login_server
570        .as_deref()
571        .map(str::trim)
572        .filter(|s| !s.is_empty())
573        .map(str::to_string)
574        .or_else(|| {
575            std::env::var("LJOS_MESH_LOGIN_SERVER")
576                .ok()
577                .map(|s| s.trim().to_string())
578                .filter(|s| !s.is_empty())
579        })
580        .unwrap_or_else(|| DEFAULT_LOGIN_SERVER.to_string())
581}
582
583fn key_file(args: &UpArgs) -> Result<PathBuf> {
584    let path = args
585        .key_file
586        .clone()
587        .or_else(|| std::env::var_os("LJOS_MESH_KEY_FILE").map(PathBuf::from))
588        .filter(|p| !p.as_os_str().is_empty())
589        .context("set --key-file or LJOS_MESH_KEY_FILE")?;
590    let meta = std::fs::metadata(&path).with_context(|| path.display().to_string())?;
591    if !meta.is_file() {
592        bail!("{} is not a file", path.display());
593    }
594    Ok(path)
595}
596
597fn running_as(status: &Value, hostname: &str) -> bool {
598    status["BackendState"].as_str() == Some("Running")
599        && status["Self"]["HostName"]
600            .as_str()
601            .is_some_and(|name| name.eq_ignore_ascii_case(hostname))
602}
603
604/// `/dev/net/tun`, or `LJOS_MESH_TUN` when that names another node.
605/// Missing, or not openable for read and write, means userspace networking.
606fn tun_path() -> PathBuf {
607    std::env::var_os("LJOS_MESH_TUN")
608        .filter(|p| !p.is_empty())
609        .map(PathBuf::from)
610        .unwrap_or_else(|| PathBuf::from("/dev/net/tun"))
611}
612
613/// Effective capability set includes `CAP_NET_ADMIN`. No `/proc` (not
614/// Linux) is an unusable TUN: userspace networking then.
615fn cap_net_admin() -> bool {
616    let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
617        return false;
618    };
619    let Some(hex) = status.lines().find_map(|line| line.strip_prefix("CapEff:")) else {
620        return false;
621    };
622    u64::from_str_radix(hex.trim(), 16).is_ok_and(|caps| (caps >> CAP_NET_ADMIN) & 1 == 1)
623}
624
625fn tun_usable() -> bool {
626    let opened = std::fs::OpenOptions::new()
627        .read(true)
628        .write(true)
629        .open(tun_path())
630        .is_ok();
631    opened && cap_net_admin()
632}
633
634fn on_path() -> Option<(PathBuf, PathBuf)> {
635    Some((
636        which::which("tailscale").ok()?,
637        which::which("tailscaled").ok()?,
638    ))
639}
640
641fn executable(path: &Path) -> bool {
642    use std::os::unix::fs::PermissionsExt as _;
643    std::fs::symlink_metadata(path)
644        .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
645}
646
647fn cached(dir: &Path) -> Option<(PathBuf, PathBuf)> {
648    let release = std::fs::read_to_string(dir.join(RELEASE)).ok()?;
649    if release.trim() != TAILSCALE_VERSION {
650        return None;
651    }
652    let tailscale = dir.join("tailscale");
653    let tailscaled = dir.join("tailscaled");
654    (executable(&tailscale) && executable(&tailscaled)).then_some((tailscale, tailscaled))
655}
656
657/// Both binaries from one place. A `tailscale` and a `tailscaled` from
658/// different builds disagree on the socket, so a single hit on `PATH`
659/// still downloads the pinned pair.
660fn ensure_bins(dir: &Path) -> Result<(PathBuf, PathBuf), Fail> {
661    if let Some(pair) = on_path() {
662        return Ok(pair);
663    }
664    if let Some(pair) = cached(dir) {
665        return Ok(pair);
666    }
667    fetch(dir)?;
668    cached(dir).ok_or_else(|| {
669        Fail::step(
670            "tailscale",
671            anyhow!("the tarball unpacked no tailscale binaries"),
672        )
673    })
674}
675
676fn tarball_name() -> String {
677    format!("tailscale_{TAILSCALE_VERSION}_{TAILSCALE_ARCH}.tgz")
678}
679
680fn fetch(dir: &Path) -> Result<(), Fail> {
681    if std::env::consts::ARCH != "x86_64" {
682        return Err(Fail::step(
683            "tailscale",
684            anyhow!(
685                "the pinned tailscale {TAILSCALE_VERSION} build is {TAILSCALE_ARCH}, and this machine is {}",
686                std::env::consts::ARCH
687            ),
688        ));
689    }
690    let tarball = dir.join(tarball_name());
691    let fetched = download(&tarball).map_err(|e| Fail::step("download", e));
692    if let Err(fail) = fetched {
693        let _ = std::fs::remove_file(&tarball);
694        return Err(fail);
695    }
696    let hashed = match sha256_file(&tarball) {
697        Ok(got) if got == TARBALL_SHA256 => Ok(()),
698        Ok(got) => Err(Fail::step(
699            "checksum",
700            anyhow!("published {TARBALL_SHA256}, file {got}"),
701        )),
702        Err(e) => Err(Fail::step("checksum", e)),
703    };
704    if let Err(fail) = hashed {
705        let _ = std::fs::remove_file(&tarball);
706        return Err(fail);
707    }
708    let unpacked = unpack(&tarball, dir).map_err(|e| Fail::step("tailscale", e));
709    let _ = std::fs::remove_file(&tarball);
710    unpacked
711}
712
713fn download(dest: &Path) -> Result<()> {
714    use std::io::Read as _;
715    let url = format!("https://pkgs.tailscale.com/stable/{}", tarball_name());
716    let agent = ureq::AgentBuilder::new().timeout(call_timeout()).build();
717    let resp = agent.get(&url).call().with_context(|| url.to_string())?;
718    let mut file =
719        std::fs::File::create(dest).with_context(|| format!("write {}", dest.display()))?;
720    let mut reader = resp.into_reader().take(TARBALL_MAX_BYTES);
721    std::io::copy(&mut reader, &mut file).with_context(|| format!("read {url}"))?;
722    Ok(())
723}
724
725fn sha256_file(path: &Path) -> Result<String> {
726    use sha2::Digest as _;
727    let mut file = std::fs::File::open(path).with_context(|| format!("read {}", path.display()))?;
728    let mut hasher = sha2::Sha256::new();
729    std::io::copy(&mut file, &mut hasher)?;
730    Ok(hex::encode(hasher.finalize()))
731}
732
733fn unpack(tarball: &Path, dir: &Path) -> Result<()> {
734    let tree = dir.join("unpack");
735    let _ = std::fs::remove_dir_all(&tree);
736    std::fs::create_dir_all(&tree)?;
737    let prefix = format!("tailscale_{TAILSCALE_VERSION}_{TAILSCALE_ARCH}");
738    let mut cmd = Command::new("tar");
739    cmd.arg("-xzf")
740        .arg(tarball)
741        .arg("-C")
742        .arg(&tree)
743        .arg(format!("{prefix}/tailscale"))
744        .arg(format!("{prefix}/tailscaled"));
745    let out = run(cmd)?;
746    if !out.status.success() {
747        bail!("tar exited {}{}", code(out.status), last_line(&out));
748    }
749    for name in ["tailscale", "tailscaled"] {
750        place_bin(&tree.join(&prefix).join(name), &dir.join(name))?;
751    }
752    let _ = std::fs::remove_dir_all(&tree);
753    std::fs::write(dir.join(RELEASE), format!("{TAILSCALE_VERSION}\n"))?;
754    Ok(())
755}
756
757fn place_bin(src: &Path, dest: &Path) -> Result<()> {
758    use std::os::unix::fs::PermissionsExt as _;
759    let meta =
760        std::fs::symlink_metadata(src).with_context(|| format!("missing {}", src.display()))?;
761    if !meta.is_file() {
762        bail!("{} is not a regular file", src.display());
763    }
764    let _ = std::fs::remove_file(dest);
765    std::fs::rename(src, dest).with_context(|| format!("install {}", dest.display()))?;
766    std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))?;
767    Ok(())
768}
769
770fn tailscale_cmd(tailscale: &Path, socket: &Path) -> Command {
771    let mut cmd = Command::new(tailscale);
772    cmd.arg(format!("--socket={}", socket.display()));
773    // The key file path is not a secret. The bytes stay in the file, so
774    // no child receives them through the environment either.
775    cmd.env_remove("LJOS_MESH_KEY_FILE");
776    cmd
777}
778
779fn code(status: std::process::ExitStatus) -> String {
780    status
781        .code()
782        .map_or_else(|| "on a signal".into(), |c| c.to_string())
783}
784
785fn last_line(out: &Output) -> String {
786    [&out.stderr, &out.stdout]
787        .into_iter()
788        .find_map(|bytes| {
789            String::from_utf8_lossy(bytes)
790                .lines()
791                .rev()
792                .find(|line| !line.trim().is_empty())
793                .map(|line| format!(": {line}"))
794        })
795        .unwrap_or_default()
796}
797
798fn run(cmd: Command) -> Result<Output> {
799    let timeout = call_timeout();
800    crate::output_within(cmd, None, timeout).map_err(|e| {
801        if e.kind() == std::io::ErrorKind::TimedOut {
802            anyhow!("did not finish within {} s", timeout.as_secs())
803        } else {
804            anyhow!("could not run: {e}")
805        }
806    })
807}
808
809/// `tailscale status --json`, or none when no daemon answers on `socket`.
810fn status_json(tailscale: &Path, socket: &Path) -> Result<Option<Value>> {
811    if !socket.exists() {
812        return Ok(None);
813    }
814    let mut cmd = tailscale_cmd(tailscale, socket);
815    cmd.args(["status", "--json"]);
816    match run(cmd) {
817        Ok(out) if out.status.success() => Ok(serde_json::from_slice(&out.stdout).ok()),
818        Ok(_) => Ok(None),
819        Err(e) if timed_out(&e) => Err(e),
820        Err(_) => Ok(None),
821    }
822}
823
824/// tailscaled in its own process group, so `mesh down` can stop it and
825/// what it started. The node key stays in memory (`--state=mem:`), its
826/// state and logs stay in `dir`, and no log leaves for a log server.
827fn start_daemon(tailscaled: &Path, dir: &Path, kernel: bool) -> Result<Child> {
828    use std::os::unix::process::CommandExt as _;
829    let log = std::fs::File::create(dir.join(LOG))?;
830    let mut cmd = Command::new(tailscaled);
831    cmd.arg("--state=mem:")
832        .arg(format!("--statedir={}", dir.display()))
833        .arg(format!("--socket={}", dir.join(SOCKET).display()))
834        .arg("--no-logs-no-support")
835        .env("TS_LOGS_DIR", dir)
836        .env_remove("LJOS_MESH_KEY_FILE");
837    if !kernel {
838        cmd.arg("--tun=userspace-networking")
839            .arg(format!("--socks5-server=localhost:{SOCKS_PORT}"));
840    }
841    cmd.stdin(Stdio::null())
842        .stdout(log.try_clone()?)
843        .stderr(log)
844        .process_group(0);
845    let child = cmd.spawn().context("could not start")?;
846    record(dir, child.id(), started(child.id()))?;
847    Ok(child)
848}
849
850/// Write the pid file: the daemon's pid and its start time `at`. With no
851/// start time the file has the pid alone, as an older ljos wrote it, and
852/// the daemon runs on unlabelled: `down` cannot tell it from a reused pid.
853fn record(dir: &Path, pid: u32, at: Option<String>) -> Result<()> {
854    let at = at.map(|at| format!("{at}\n")).unwrap_or_default();
855    std::fs::write(dir.join(PID), format!("{pid}\n{at}"))?;
856    Ok(())
857}
858
859fn wait_for_socket(socket: &Path, mut child: Option<&mut Child>, deadline: Instant) -> Result<()> {
860    while !socket.exists() {
861        if let Some(status) = child.as_mut().and_then(|c| c.try_wait().ok().flatten()) {
862            bail!("exited {status} before it opened its socket");
863        }
864        if Instant::now() >= deadline {
865            bail!("opened no socket within {} s", call_timeout().as_secs());
866        }
867        std::thread::sleep(POLL);
868    }
869    Ok(())
870}
871
872fn login(tailscale: &Path, socket: &Path, server: &str, key: &Path, hostname: &str) -> Result<()> {
873    let mut cmd = tailscale_cmd(tailscale, socket);
874    // The key already carries the tag. Headscale refuses a second request
875    // ("requested tags are invalid or not permitted", seen 2026-11-03),
876    // so `--advertise-tags` is not passed. `--reset` lets a later up
877    // change the flags below without aborting.
878    cmd.arg("up")
879        .arg("--accept-routes=false")
880        .arg("--accept-dns=false")
881        .arg(format!("--login-server={server}"))
882        .arg(format!("--auth-key=file:{}", key.display()))
883        .arg(format!("--hostname={hostname}"))
884        .arg("--reset");
885    let out = run(cmd)?;
886    if out.status.success() {
887        return Ok(());
888    }
889    bail!("exited {}{}", code(out.status), last_line(&out));
890}
891
892fn reap(child: Option<Child>, dir: &Path) {
893    if let Some(mut child) = child {
894        kill_group(child.id(), libc::SIGKILL);
895        let _ = child.wait();
896    }
897    let _ = std::fs::remove_file(dir.join(PID));
898    let _ = std::fs::remove_file(dir.join(SOCKET));
899}
900
901/// Signal the process group `pid` leads.
902fn kill_group(pid: u32, signal: libc::c_int) -> bool {
903    let Ok(pid) = libc::pid_t::try_from(pid) else {
904        return false;
905    };
906    // Group 1 is every process the user can signal, and group 0 is this one.
907    if pid <= 1 {
908        return false;
909    }
910    // SAFETY: a negative pid signals the group `start_daemon` made with
911    // `process_group(0)`, which this process is not in.
912    unsafe { libc::kill(-pid, signal) == 0 }
913}
914
915/// Stop the tailscaled `start_daemon` recorded, within the grace. A pid
916/// that no longer names it is never signalled, and the note says so.
917fn stop_daemon(dir: &Path, deadline: Instant) -> Option<String> {
918    let note = match recorded(dir) {
919        Some((pid, true)) => {
920            if kill_group(pid, libc::SIGTERM) {
921                let until = deadline.min(Instant::now() + GRACE);
922                while kill_group(pid, 0) && Instant::now() < until {
923                    std::thread::sleep(POLL);
924                }
925                // A pid is not reused while a group with that id has a
926                // process in it (POSIX, Process Group Lifetime). So the
927                // group is still this daemon's while the pid names no live
928                // process or still names the daemon, and only then does it
929                // get SIGKILL.
930                if started(pid).is_none() || recorded(dir).is_some_and(|(_, ours)| ours) {
931                    kill_group(pid, libc::SIGKILL);
932                }
933            }
934            None
935        }
936        Some((pid, false)) => old_format(dir, "nothing was signalled").or_else(|| {
937            Some(format!(
938                "{PID} named pid {pid}, no longer the tailscaled ljos started, so nothing was signalled"
939            ))
940        }),
941        None => None,
942    };
943    let _ = std::fs::remove_file(dir.join(PID));
944    note
945}
946
947fn clear_run(dir: &Path) {
948    for name in [SOCKET, PID, LOG, JOINED] {
949        let _ = std::fs::remove_file(dir.join(name));
950    }
951    if std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
952        let _ = std::fs::remove_dir(dir);
953    }
954}
955
956/// `ljos mesh down`: log out, then stop the tailscaled this command
957/// started, and only that pid. One line. The caller exits 0.
958pub fn down() -> MeshOut {
959    let key = std::env::var_os("LJOS_MESH_KEY_FILE")
960        .map(PathBuf::from)
961        .filter(|path| path.is_file());
962    let out = down_inner();
963    let text = scrub(&out.text, key.as_deref());
964    if out.error {
965        MeshOut::err(text)
966    } else {
967        MeshOut::ok(text)
968    }
969}
970
971fn down_inner() -> MeshOut {
972    let dir = state_dir();
973    let socket = dir.join(SOCKET);
974    let joined = dir.join(JOINED);
975    if !socket.exists() && !dir.join(PID).exists() && !joined.exists() {
976        return MeshOut::ok("mesh down: not on the mesh");
977    }
978    let hostname = std::fs::read_to_string(&joined).unwrap_or_default();
979    let hostname = hostname.trim().to_string();
980    let mut errors = Vec::new();
981    if socket.exists() {
982        match logout_bin(&dir) {
983            Some(tailscale) => {
984                let mut cmd = tailscale_cmd(&tailscale, &socket);
985                cmd.arg("logout");
986                match run(cmd) {
987                    Ok(out) if out.status.success() => {}
988                    Ok(out) => errors.push(format!(
989                        "tailscale logout: exited {}{}",
990                        code(out.status),
991                        last_line(&out)
992                    )),
993                    Err(e) => errors.push(format!("tailscale logout: {e:#}")),
994                }
995            }
996            None => errors.push("tailscale logout: tailscale is not available".into()),
997        }
998    }
999    let note = stop_daemon(&dir, Instant::now() + GRACE);
1000    clear_run(&dir);
1001    if errors.is_empty() {
1002        let who = if hostname.is_empty() {
1003            "this machine"
1004        } else {
1005            &hostname
1006        };
1007        let mut line = format!("mesh down: {who} left the mesh");
1008        if let Some(note) = note {
1009            line = format!("{line}; {note}");
1010        }
1011        MeshOut::ok(line)
1012    } else {
1013        let mut line = errors.join("; ");
1014        if let Some(note) = note {
1015            line = format!("{line}; {note}");
1016        }
1017        MeshOut::err(format!("mesh down: {line}"))
1018    }
1019}
1020
1021fn logout_bin(dir: &Path) -> Option<PathBuf> {
1022    on_path()
1023        .map(|(tailscale, _)| tailscale)
1024        .or_else(|| cached(dir).map(|(tailscale, _)| tailscale))
1025}
1026
1027/// `ljos mesh status`: the nodes `tailscale status --json` reports, each
1028/// with the tags, addresses and id the daemon gave it. A node the JSON
1029/// marks ephemeral says so. Nothing here names a particular host.
1030#[must_use]
1031pub fn status_text() -> String {
1032    let key_env = load().map(|c| c.key_env).unwrap_or_default();
1033    let Ok(tailscale) = which::which("tailscale") else {
1034        return "mesh status: tailscale is not on PATH".into();
1035    };
1036    let socket = state_dir().join(SOCKET);
1037    match status_for_text(&tailscale, &socket, &key_env) {
1038        Ok(Some(value)) => format_status(&value),
1039        Ok(None) => "mesh status: not on the mesh".into(),
1040        Err(e) => format!("mesh status: {e:#}"),
1041    }
1042}
1043
1044/// `tailscale status --json` for [`status_text`]. `key_env` is removed from
1045/// the child, the same way a join does not hand the key to tailscale.
1046fn status_for_text(tailscale: &Path, socket: &Path, key_env: &str) -> Result<Option<Value>> {
1047    if !socket.exists() {
1048        return Ok(None);
1049    }
1050    let mut cmd = tailscale_cmd(tailscale, socket);
1051    if !key_env.is_empty() {
1052        cmd.env_remove(key_env);
1053    }
1054    cmd.args(["status", "--json"]);
1055    match run(cmd) {
1056        Ok(out) if out.status.success() => Ok(serde_json::from_slice(&out.stdout).ok()),
1057        Ok(_) => Ok(None),
1058        Err(e) if timed_out(&e) => Err(e),
1059        Err(_) => Ok(None),
1060    }
1061}
1062
1063/// The lines [`status_text`] prints for one status document. Peers come
1064/// from the `Peer` object. Tags, ids and addresses are the fields the
1065/// daemon set. A missing `Ephemeral` field is not called either way.
1066#[must_use]
1067pub fn format_status(status: &Value) -> String {
1068    let mut lines = Vec::new();
1069    if let Some(node) = node_of(&status["Self"]) {
1070        lines.push(render_node("self", &node));
1071    }
1072    if let Some(peers) = status.get("Peer").and_then(Value::as_object) {
1073        let mut peers: Vec<Node> = peers.values().filter_map(node_of).collect();
1074        peers.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.cmp(&b.id)));
1075        for peer in peers {
1076            lines.push(render_node("peer", &peer));
1077        }
1078    }
1079    if lines.is_empty() {
1080        "mesh status: no node".into()
1081    } else {
1082        lines.join("\n")
1083    }
1084}
1085
1086struct Node {
1087    name: String,
1088    id: String,
1089    ips: Vec<String>,
1090    tags: Vec<String>,
1091    ephemeral: Option<bool>,
1092}
1093
1094fn node_of(value: &Value) -> Option<Node> {
1095    let name = value.get("HostName")?.as_str()?.trim();
1096    if name.is_empty() {
1097        return None;
1098    }
1099    let strings = |key: &str| {
1100        value
1101            .get(key)
1102            .and_then(Value::as_array)
1103            .map(|items| {
1104                items
1105                    .iter()
1106                    .filter_map(Value::as_str)
1107                    .map(str::trim)
1108                    .filter(|s| !s.is_empty())
1109                    .map(str::to_string)
1110                    .collect()
1111            })
1112            .unwrap_or_default()
1113    };
1114    Some(Node {
1115        name: name.to_string(),
1116        id: match value.get("ID") {
1117            Some(Value::String(id)) => id.trim().to_string(),
1118            Some(Value::Number(id)) => id.to_string(),
1119            _ => String::new(),
1120        },
1121        ips: strings("TailscaleIPs"),
1122        tags: strings("Tags"),
1123        ephemeral: value.get("Ephemeral").and_then(Value::as_bool),
1124    })
1125}
1126
1127fn render_node(kind: &str, node: &Node) -> String {
1128    let id = if node.id.is_empty() {
1129        String::new()
1130    } else {
1131        format!(" id {}", node.id)
1132    };
1133    let ips = if node.ips.is_empty() {
1134        String::new()
1135    } else {
1136        format!(" {}", node.ips.join(" "))
1137    };
1138    let tags = if node.tags.is_empty() {
1139        String::new()
1140    } else {
1141        format!(" tags {}", node.tags.join(","))
1142    };
1143    let life = match node.ephemeral {
1144        Some(true) => " ephemeral",
1145        Some(false) => " non-ephemeral",
1146        None => "",
1147    };
1148    format!("mesh status: {kind} {}{id}{ips}{tags}{life}", node.name)
1149}
1150
1151/// `ljos mesh acl`: the Headscale ACL policy for the config's swarm and
1152/// each of `more`. A swarm reaches its own nodes on every port and the box
1153/// on its listed ports. There is no other rule, so no swarm reaches
1154/// another and the box reaches none. No user owns a tag: the join key
1155/// carries it, so a node cannot tag itself into another swarm.
1156///
1157/// # Errors
1158///
1159/// A config that does not read, or a swarm id that cannot be a tag.
1160pub fn acl(more: &[String]) -> Result<String> {
1161    let config = load()?;
1162    let mut swarms = vec![config.swarm.as_str()];
1163    for swarm in more {
1164        check_swarm(swarm)?;
1165        if !swarms.contains(&swarm.as_str()) {
1166            swarms.push(swarm);
1167        }
1168    }
1169    let ports = config.ports.on_box().map(|p| p.to_string()).join(",");
1170    let mut owners = serde_json::Map::new();
1171    owners.insert("tag:box".into(), json!([]));
1172    let mut acls = Vec::new();
1173    for swarm in swarms {
1174        let tag = format!("tag:swarm-{swarm}");
1175        acls.push(json!({"action": "accept", "src": [tag], "dst": [format!("{tag}:*")]}));
1176        acls.push(json!({"action": "accept", "src": [tag], "dst": [format!("tag:box:{ports}")]}));
1177        owners.insert(tag, json!([]));
1178    }
1179    Ok(serde_json::to_string_pretty(
1180        &json!({"tagOwners": owners, "acls": acls}),
1181    )?)
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186    use super::*;
1187
1188    #[test]
1189    fn a_daemon_whose_start_time_cannot_be_read_runs_on_and_down_signals_nothing() {
1190        use std::os::unix::process::CommandExt as _;
1191        let dir = tempfile::tempdir().unwrap();
1192        // `cat` on a pipe this test holds runs until it is killed.
1193        let mut child = Command::new("cat")
1194            .stdin(Stdio::piped())
1195            .process_group(0)
1196            .spawn()
1197            .unwrap();
1198        let pid = child.id();
1199        record(dir.path(), pid, None).unwrap();
1200        let named = format!("{PID} names pid {pid} with no start time");
1201        let up = old_format(
1202            dir.path(),
1203            "writes carry no source.mesh label and mesh down signals nothing",
1204        )
1205        .expect("up says the pid file has no start time");
1206        assert!(up.starts_with(&named), "{up}");
1207        let down = stop_daemon(dir.path(), Instant::now() + GRACE)
1208            .expect("down says why it signalled nothing");
1209        assert!(
1210            down.starts_with(&named) && down.ends_with("nothing was signalled"),
1211            "{down}"
1212        );
1213        assert!(
1214            child.try_wait().unwrap().is_none(),
1215            "down signalled pid {pid}"
1216        );
1217        let _ = child.kill();
1218        let _ = child.wait();
1219    }
1220}