1use 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#[derive(serde::Deserialize)]
16#[serde(deny_unknown_fields)]
17struct Config {
18 #[allow(dead_code)]
19 login_server: String,
20 #[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 #[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
45const DEFAULT_LOGIN_SERVER: &str = "https://hs.grund.rgoswami.me";
48
49const WAIT_SECS: u64 = 30;
53const GRACE: Duration = Duration::from_secs(5);
56const INSTANT_MS: u64 = 100;
59const POLL: Duration = Duration::from_millis(INSTANT_MS / 2);
64const NODE_HEX: usize = 6;
69const SWARM_MAX: usize = 63 - "ag-".len() - 1 - NODE_HEX;
72const CAP_NET_ADMIN: u32 = 12;
75
76const SOCKS_PORT: u16 = 1055;
80
81const TAILSCALE_VERSION: &str = "1.90.6";
85const TAILSCALE_ARCH: &str = "amd64";
87const TARBALL_SHA256: &str = "cc51b5b6fe86a25e66b2ef037334e799a61d06dc64fabcadfe6e2a7588a2b2c7";
91const 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
102pub struct UpArgs {
105 pub key_file: Option<PathBuf>,
106 pub login_server: Option<String>,
107 pub hostname: Option<String>,
108}
109
110#[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 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
174fn state_dir() -> PathBuf {
177 crate::state_base_dir().join("mesh")
178}
179
180#[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#[cfg(target_os = "linux")]
199fn still_up(dir: &Path) -> bool {
200 recorded(dir).is_some_and(|(_, ours)| ours)
201}
202
203#[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 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#[cfg(target_os = "linux")]
233fn started(pid: u32) -> Option<String> {
234 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
235 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#[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
261fn 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
271fn 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
279fn 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
317fn one_line(text: &str) -> String {
319 text.split_whitespace().collect::<Vec<_>>().join(" ")
320}
321
322fn 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
355pub 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 ¬es,
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 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 ¬es,
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
429fn 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 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
604fn 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
613fn 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
657fn 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 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
809fn 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
824fn 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
850fn 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 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
901fn 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 if pid <= 1 {
908 return false;
909 }
910 unsafe { libc::kill(-pid, signal) == 0 }
913}
914
915fn 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 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
956pub 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#[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
1044fn 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#[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
1151pub 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 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}