From e816f3f6e28c42fba0b977741c9decc5f4d5b757 Mon Sep 17 00:00:00 2001 From: andi Date: Sun, 19 Jul 2026 08:46:41 +0200 Subject: [PATCH 1/7] Add `tunmux launchd install|reload|uninstall` commands for a single binary distribution --- Makefile | 28 +- doc/launchd-socket-activation.md | 22 +- etc/me.pansen.tunmux.privileged.plist | 6 +- src/cli.rs | 88 ++++- src/launchd.rs | 494 ++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 14 +- 7 files changed, 614 insertions(+), 39 deletions(-) create mode 100644 src/launchd.rs diff --git a/Makefile b/Makefile index bc55860..2bb64cd 100644 --- a/Makefile +++ b/Makefile @@ -16,27 +16,9 @@ build.release: submodule .PHONY: install/privileged install/privileged: - sudo dseditgroup -o read tunmux >/dev/null 2>&1 || sudo dseditgroup -o create tunmux - sudo dseditgroup -o edit -a $$(id -un) -t user tunmux - + @# Binary copy is a dev stand-in for the future Homebrew bottle. sudo install -m 0755 target/release/tunmux /usr/local/bin/tunmux - sudo mkdir -p /var/log/tunmux && sudo chmod 755 /var/log/tunmux - sudo mkdir -p "/Library/Application Support/tunmux/run" - sudo chgrp tunmux "/Library/Application Support/tunmux/run" - sudo chmod 0750 "/Library/Application Support/tunmux/run" - - sudo cp etc/me.pansen.tunmux.privileged.plist /Library/LaunchDaemons/ - sudo chown root:wheel /Library/LaunchDaemons/me.pansen.tunmux.privileged.plist - sudo chmod 644 /Library/LaunchDaemons/me.pansen.tunmux.privileged.plist - GID=$$(dscl . -read /Groups/tunmux PrimaryGroupID | awk '{print $$2}'); \ - sudo /usr/libexec/PlistBuddy -c "Delete :Sockets:Listeners:SockPathGroup" \ - /Library/LaunchDaemons/me.pansen.tunmux.privileged.plist 2>/dev/null || true; \ - sudo /usr/libexec/PlistBuddy -c "Add :Sockets:Listeners:SockPathGroup integer $$GID" \ - /Library/LaunchDaemons/me.pansen.tunmux.privileged.plist - sudo launchctl bootout system/me.pansen.tunmux.privileged 2>/dev/null || true - @# Clear any stale "disabled" override — bootstrap of a disabled label fails with EIO (5). - sudo launchctl enable system/me.pansen.tunmux.privileged - sudo launchctl bootstrap system /Library/LaunchDaemons/me.pansen.tunmux.privileged.plist + sudo /usr/local/bin/tunmux launchd install .PHONY: install/autostart @@ -57,7 +39,7 @@ install: build.release install/privileged install/autostart .PHONY: reload/privileged reload/privileged: - sudo launchctl kickstart -k system/me.pansen.tunmux.privileged + sudo /usr/local/bin/tunmux launchd restart .PHONY: reload/connections reload/connections: @@ -94,10 +76,8 @@ uninstall/dns: .PHONY: uninstall/privileged uninstall/privileged: - sudo launchctl bootout system/me.pansen.tunmux.privileged 2>/dev/null || true - sudo launchctl disable system/me.pansen.tunmux.privileged 2>/dev/null || true + sudo /usr/local/bin/tunmux launchd uninstall 2>/dev/null || true sudo pkill -f '/usr/local/bin/tunmux wgconf' 2>/dev/null || true - sudo rm -f /Library/LaunchDaemons/me.pansen.tunmux.privileged.plist sudo rm -f /usr/local/bin/tunmux sudo rm -rf "/Library/Application Support/tunmux" sudo rm -rf /var/log/tunmux diff --git a/doc/launchd-socket-activation.md b/doc/launchd-socket-activation.md index 1be421d..33f2149 100644 --- a/doc/launchd-socket-activation.md +++ b/doc/launchd-socket-activation.md @@ -353,20 +353,24 @@ Changes from current: > shorter `SockMode`/`SockGroup`. Confirm which the running launchd honors (see gotchas); > the value must end up `0660 root:tunmux` on the created socket. -### 3. `Makefile` — pre-create the socket directory +### 3. `Makefile` — delegate daemon setup to the binary -Under on-demand activation the daemon may not be running to create -`/Library/Application Support/tunmux/run`; launchd needs the parent directory to exist to create the socket -file. Add to `install/privileged` (the `tunmux` group already exists by this point): +`install/privileged` now installs the binary and delegates all daemon setup to +`tunmux launchd install`: ```make -sudo mkdir -p "/Library/Application Support/tunmux/run" -sudo chgrp tunmux "/Library/Application Support/tunmux/run" -sudo chmod 0750 "/Library/Application Support/tunmux/run" +sudo install -m 0755 target/release/tunmux /usr/local/bin/tunmux +sudo /usr/local/bin/tunmux launchd install ``` -The rest of `install/privileged` (group creation, binary install, log dir, plist -copy/chown, `bootout`/`bootstrap`) is unchanged. +`tunmux launchd install` handles group creation, log dir setup, socket dir +pre-creation, plist copy/chown, and launchctl bootstrap. Similarly, +`tunmux launchd restart` replaces the direct `launchctl kickstart`, and +`tunmux launchd uninstall` handles bootout/disable and plist removal. + +This centralizes the entire daemon lifecycle in the binary, keeping the +Makefile simple and reducing duplication between install-time and upgrade +flows. ### 4. Unprivileged client — no change diff --git a/etc/me.pansen.tunmux.privileged.plist b/etc/me.pansen.tunmux.privileged.plist index 4410629..bbf923f 100644 --- a/etc/me.pansen.tunmux.privileged.plist +++ b/etc/me.pansen.tunmux.privileged.plist @@ -8,7 +8,7 @@ ProgramArguments - /usr/local/bin/tunmux + @TUNMUX_BIN@ --debug privileged --serve @@ -25,8 +25,8 @@ SockPathName /Library/Application Support/tunmux/run/ctl.sock - + + SockPathMode 432 diff --git a/src/cli.rs b/src/cli.rs index 3e4ca24..4312c67 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,5 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; #[derive(Parser)] #[command( @@ -55,6 +56,12 @@ pub enum TopCommand { command: HookCommand, }, + /// Manage the privileged launchd daemon (system domain) + Launchd { + #[command(subcommand)] + command: LaunchdCommand, + }, + /// Internal privileged service mode (hidden) #[command(hide = true)] Privileged { @@ -103,6 +110,32 @@ pub enum HookCommand { }, } +#[derive(Subcommand)] +pub enum LaunchdCommand { + /// Register and start the privileged daemon with launchd (run with sudo) + /// + /// The launchd plist is rendered from a template. By default this is the + /// template baked into the binary at build time; pass --plist-template to + /// supply your own. + /// + /// Template placeholders substituted at install time: + /// @TUNMUX_BIN@ absolute path of the tunmux binary launchd runs + /// @SOCK_PATH_GROUP@ marker comment replaced with the SockPathGroup key + /// (integer GID of the tunmux group) + #[command(verbatim_doc_comment)] + Install { + /// Path to a custom plist template (defaults to the template baked + /// into the binary at build time). Must contain the @TUNMUX_BIN@ + /// and @SOCK_PATH_GROUP@ placeholders described above. + #[arg(long, value_name = "PATH")] + plist_template: Option, + }, + /// Restart the privileged daemon (launchctl kickstart -k) + Restart, + /// Stop and unregister the privileged daemon (keeps binary, group, logs) + Uninstall, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum HookEventArg { Ifup, @@ -200,8 +233,8 @@ pub enum WgconfCommand { #[cfg(test)] mod tests { use super::{ - Cli, ConnectProviderCommand, HookBuiltinArg, HookCommand, ProviderArg, TopCommand, - WgconfCommand, + Cli, ConnectProviderCommand, HookBuiltinArg, HookCommand, LaunchdCommand, ProviderArg, + TopCommand, WgconfCommand, }; use clap::Parser; @@ -377,4 +410,55 @@ mod tests { _ => panic!("expected hook run command"), } } + + #[test] + fn parse_launchd_subcommands() { + for (arg, want) in [ + ( + "install", + std::mem::discriminant(&LaunchdCommand::Install { + plist_template: None, + }), + ), + ("restart", std::mem::discriminant(&LaunchdCommand::Restart)), + ( + "uninstall", + std::mem::discriminant(&LaunchdCommand::Uninstall), + ), + ] { + let cli = Cli::try_parse_from(["tunmux", "launchd", arg]).expect("parse launchd"); + match cli.command { + TopCommand::Launchd { command } => { + assert_eq!(std::mem::discriminant(&command), want) + } + _ => panic!("expected launchd command"), + } + } + } + + #[test] + fn parse_launchd_install_with_template() { + use std::path::Path; + + let cli = Cli::try_parse_from([ + "tunmux", + "launchd", + "install", + "--plist-template", + "/tmp/custom.plist", + ]) + .expect("parse launchd install with template"); + + match cli.command { + TopCommand::Launchd { + command: + LaunchdCommand::Install { + plist_template: Some(p), + }, + } => { + assert_eq!(p, Path::new("/tmp/custom.plist")); + } + _ => panic!("expected launchd install with template"), + } + } } diff --git a/src/launchd.rs b/src/launchd.rs new file mode 100644 index 0000000..ff90ee6 --- /dev/null +++ b/src/launchd.rs @@ -0,0 +1,494 @@ +//! Pure core for the `tunmux launchd` subcommand: an installer for the +//! privileged launchd daemon plist. This module provides both the +//! plist-rendering / binary-location-validation logic and the +//! `tunmux launchd install|restart|uninstall` command handlers, porting +//! `make install/privileged`, `reload/privileged`, and the launchd parts of +//! `uninstall/privileged`. + +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use nix::unistd::{chown, geteuid, Gid, Group, Uid, User}; + +use crate::cli::LaunchdCommand; +use crate::config; + +pub const LABEL: &str = "me.pansen.tunmux.privileged"; +pub const PLIST_PATH: &str = "/Library/LaunchDaemons/me.pansen.tunmux.privileged.plist"; + +/// Group whose members may talk to the privileged daemon's control socket. +/// Must match `AUTH_GROUP_NAME` in `src/privileged/mod.rs`, which is private +/// to that module and therefore unavailable from here. +const GROUP_NAME: &str = "tunmux"; + +const PLIST_TEMPLATE: &str = include_str!("../etc/me.pansen.tunmux.privileged.plist"); +const BIN_PLACEHOLDER: &str = "@TUNMUX_BIN@"; +const SOCK_GROUP_MARKER: &str = + ""; + +/// Render the privileged daemon's launchd plist, substituting the daemon +/// binary path and the authorized-group GID into `template`. +fn render_plist_from(template: &str, daemon_binary: &str, gid: u32) -> anyhow::Result { + if !template.contains(BIN_PLACEHOLDER) { + anyhow::bail!( + "plist template is missing the {} placeholder", + BIN_PLACEHOLDER + ); + } + let rendered = template.replace(BIN_PLACEHOLDER, daemon_binary); + + let marker_line = template + .lines() + .find(|line| line.contains(SOCK_GROUP_MARKER)) + .ok_or_else(|| { + anyhow::anyhow!( + "plist template is missing the SockPathGroup marker comment: {}", + SOCK_GROUP_MARKER + ) + })?; + let indent: String = marker_line + .chars() + .take_while(|c| c.is_whitespace()) + .collect(); + let replacement = format!("{indent}SockPathGroup\n{indent}{gid}"); + let rendered = rendered.replace(marker_line, &replacement); + + Ok(rendered) +} + +/// Reject binaries in locations a regular user controls. +/// +/// The rendered plist makes launchd run this binary as root, so both the +/// path as invoked (e.g. `current_exe()`) and its canonicalized/symlink- +/// resolved target must live in a location that isn't writable by an +/// unprivileged user — otherwise a user could swap the binary out from +/// under root's launchd. +/// +/// This check is deliberately path-prefix-based, not ownership-based: +/// /opt/homebrew is user-owned by design on Apple Silicon (Homebrew installs +/// without root), so an ownership check would either reject legitimate +/// Homebrew installs or fail to catch the actual risk. Prefix-based +/// denylisting of known user-writable roots (home directories, temp dirs) +/// is the meaningful signal here. +pub fn validate_binary_location( + invoked: &std::path::Path, + resolved: &std::path::Path, + invoking_user_home: Option<&std::path::Path>, +) -> anyhow::Result<()> { + validate_one(invoked, invoking_user_home)?; + validate_one(resolved, invoking_user_home)?; + Ok(()) +} + +const REJECTED_PREFIXES: &[&str] = &[ + "/Users/", + "/tmp/", + "/private/tmp/", + "/var/folders/", + "/private/var/folders/", +]; + +fn validate_one( + path: &std::path::Path, + invoking_user_home: Option<&std::path::Path>, +) -> anyhow::Result<()> { + if !path.is_absolute() { + anyhow::bail!( + "refusing to install a launchd daemon that runs a non-absolute path ({}); \ + install a build from a system location such as /usr/local/bin or via Homebrew", + path.display() + ); + } + + let path_str = path.to_string_lossy(); + + for prefix in REJECTED_PREFIXES { + if path_str.starts_with(prefix) { + anyhow::bail!( + "refusing to install a launchd daemon that runs a binary from a user-writable \ + location ({}); place the tunmux binary in a system location such as \ + /usr/local/bin or install it via Homebrew", + path.display() + ); + } + } + + if let Some(home) = invoking_user_home { + if path.starts_with(home) { + anyhow::bail!( + "refusing to install a launchd daemon that runs a binary from the invoking \ + user's home directory ({}); place the tunmux binary in a system location \ + such as /usr/local/bin or install it via Homebrew", + path.display() + ); + } + } + + Ok(()) +} + +pub fn dispatch(command: LaunchdCommand) -> anyhow::Result<()> { + match command { + LaunchdCommand::Install { plist_template } => cmd_install(plist_template), + LaunchdCommand::Restart => cmd_restart(), + LaunchdCommand::Uninstall => cmd_uninstall(), + } +} + +fn cmd_install(plist_template: Option) -> anyhow::Result<()> { + require_root("install")?; + let user = invoking_user()?; + let gid = ensure_group_with_member(&user)?; + let bin = daemon_binary_path()?; + ensure_directories(gid)?; + let template = match plist_template.as_deref() { + Some(path) => std::fs::read_to_string(path) + .with_context(|| format!("failed to read plist template {}", path.display()))?, + None => PLIST_TEMPLATE.to_string(), + }; + let plist = render_plist_from(&template, &bin.to_string_lossy(), gid)?; + write_plist(&plist)?; + bootstrap()?; + + println!("tunmux privileged daemon installed."); + println!(" binary: {}", bin.display()); + println!(" plist: {PLIST_PATH}"); + if let Some(path) = &plist_template { + println!(" template: {}", path.display()); + } + println!( + "You may need to log out and back in (or run `newgrp tunmux`) for tunmux group \ + membership to take effect." + ); + Ok(()) +} + +fn cmd_restart() -> anyhow::Result<()> { + require_root("restart")?; + // Re-run the same location validation as install, guarding against e.g. + // `sudo ./target/debug/tunmux launchd restart` restarting a daemon that + // was installed from a different (system) location. + daemon_binary_path()?; + + run_checked( + "/bin/launchctl", + &["kickstart", "-k", &format!("system/{LABEL}")], + ) + .with_context(|| "daemon not installed? run: sudo tunmux launchd install")?; + + println!("tunmux privileged daemon restarted."); + Ok(()) +} + +fn cmd_uninstall() -> anyhow::Result<()> { + require_root("uninstall")?; + + run_ignore_failure("/bin/launchctl", &["bootout", &format!("system/{LABEL}")]); + // Parity with the old Makefile-based uninstall: leave the label + // disabled. `cmd_install`'s `launchctl enable` clears this again on + // reinstall. + run_ignore_failure("/bin/launchctl", &["disable", &format!("system/{LABEL}")]); + + remove_file_ignore_missing(Path::new(PLIST_PATH))?; + remove_file_ignore_missing(&config::privileged_socket_path())?; + + println!("tunmux privileged daemon uninstalled."); + println!("Intentionally kept (remove with `make uninstall/privileged` for a full removal):"); + println!(" the tunmux binary"); + println!(" the tunmux group"); + println!(" {}", config::root_log_dir().display()); + println!( + " the runtime directory ({})", + config::privileged_socket_dir().display() + ); + Ok(()) +} + +/// Bail unless running as root, with a hint on how to re-invoke this command. +fn require_root(cmd_hint: &str) -> anyhow::Result<()> { + if !geteuid().is_root() { + anyhow::bail!("this command must run as root; try: sudo tunmux launchd {cmd_hint}"); + } + Ok(()) +} + +/// Home directory of the user who invoked `sudo`, if any. Used only to feed +/// `validate_binary_location`'s third argument so non-standard home +/// locations are still covered; the function's static prefix denylist +/// applies regardless. +fn invoking_user_home() -> Option { + let user = std::env::var("SUDO_USER").ok()?; + User::from_name(&user).ok().flatten().map(|u| u.dir) +} + +/// The user who ran `sudo`, i.e. who should be added to the `tunmux` group. +fn invoking_user() -> anyhow::Result { + match std::env::var("SUDO_USER") { + Ok(user) if !user.is_empty() => Ok(user), + _ => anyhow::bail!( + "could not determine the invoking user (SUDO_USER is unset); run this via \ + `sudo tunmux launchd install` from your normal account, or add yourself to the \ + tunmux group manually with: sudo dseditgroup -o edit -a -t user tunmux" + ), + } +} + +/// The daemon binary path to embed in the plist's ProgramArguments, after +/// validating it isn't installed somewhere a regular user could tamper with. +/// +/// Deliberately not canonicalized: keeping the as-invoked path means a +/// Homebrew `opt` symlink stays stable across upgrades (only the symlink +/// target changes). If `current_exe()` itself ever returns an +/// already-canonicalized path on this platform, that only affects future +/// Homebrew opt-symlink stability, which the bottle packaging will need to +/// address; the current dev/Makefile flow installs straight to +/// /usr/local/bin, so it's unaffected either way. +fn daemon_binary_path() -> anyhow::Result { + let invoked = std::env::current_exe()?; + let resolved = fs::canonicalize(&invoked).unwrap_or_else(|_| invoked.clone()); + validate_binary_location(&invoked, &resolved, invoking_user_home().as_deref())?; + Ok(invoked) +} + +/// Port of Makefile:19-20: ensure the `tunmux` group exists and that `user` +/// is a member, returning its GID. +fn ensure_group_with_member(user: &str) -> anyhow::Result { + let read_ok = std::process::Command::new("/usr/sbin/dseditgroup") + .args(["-o", "read", GROUP_NAME]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .with_context(|| "failed to run /usr/sbin/dseditgroup")? + .success(); + if !read_ok { + run_checked("/usr/sbin/dseditgroup", &["-o", "create", GROUP_NAME])?; + } + + // Idempotent: re-adding an existing member is a no-op. + run_checked( + "/usr/sbin/dseditgroup", + &["-o", "edit", "-a", user, "-t", "user", GROUP_NAME], + )?; + + Group::from_name(GROUP_NAME) + .ok() + .flatten() + .map(|g| g.gid.as_raw()) + .ok_or_else(|| anyhow::anyhow!("group {GROUP_NAME} not found after creation")) +} + +/// Port of Makefile:23-27: create (or fix up) the log and runtime +/// directories with the permissions the privileged daemon expects. +fn ensure_directories(gid: u32) -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let log_dir = config::root_log_dir(); + fs::create_dir_all(&log_dir) + .with_context(|| format!("failed to create {}", log_dir.display()))?; + fs::set_permissions(&log_dir, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("failed to chmod {}", log_dir.display()))?; + + let sock_dir = config::privileged_socket_dir(); + fs::create_dir_all(&sock_dir) + .with_context(|| format!("failed to create {}", sock_dir.display()))?; + chown(&sock_dir, None, Some(Gid::from_raw(gid))) + .with_context(|| format!("failed to chown {}", sock_dir.display()))?; + fs::set_permissions(&sock_dir, fs::Permissions::from_mode(0o750)) + .with_context(|| format!("failed to chmod {}", sock_dir.display()))?; + + Ok(()) +} + +/// Write the rendered plist to `PLIST_PATH` with the ownership/permissions +/// launchd expects of a system daemon plist. +fn write_plist(contents: &str) -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + + fs::write(PLIST_PATH, contents).with_context(|| format!("failed to write {PLIST_PATH}"))?; + fs::set_permissions(PLIST_PATH, fs::Permissions::from_mode(0o644)) + .with_context(|| format!("failed to chmod {PLIST_PATH}"))?; + chown(PLIST_PATH, Some(Uid::from_raw(0)), Some(Gid::from_raw(0))) + .with_context(|| format!("failed to chown {PLIST_PATH}"))?; + Ok(()) +} + +/// Port of Makefile:36-38 (order matters): drop any existing instance, clear +/// a stale "disabled" override, then bootstrap the plist. +fn bootstrap() -> anyhow::Result<()> { + let target = format!("system/{LABEL}"); + + // Not loaded yet is fine; ignore failure. + run_ignore_failure("/bin/launchctl", &["bootout", &target]); + // Clear any stale "disabled" override left over from a previous + // uninstall — bootstrapping a disabled label fails with EIO. + run_checked("/bin/launchctl", &["enable", &target])?; + run_checked("/bin/launchctl", &["bootstrap", "system", PLIST_PATH])?; + Ok(()) +} + +fn run_checked(program: &str, args: &[&str]) -> anyhow::Result<()> { + let output = std::process::Command::new(program) + .args(args) + .output() + .with_context(|| format!("failed to run {program} {}", args.join(" ")))?; + if !output.status.success() { + anyhow::bail!( + "{program} {} failed ({}): {}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +fn run_ignore_failure(program: &str, args: &[&str]) { + match std::process::Command::new(program).args(args).output() { + Ok(output) if !output.status.success() => { + tracing::debug!( + program, + args = ?args, + status = ?output.status, + stderr = %String::from_utf8_lossy(&output.stderr).trim(), + "launchd_command_ignored_failure" + ); + } + Err(err) => { + tracing::debug!(program, args = ?args, error = %err, "launchd_command_failed_to_run"); + } + Ok(_) => {} + } +} + +fn remove_file_ignore_missing(path: &Path) -> anyhow::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(err).with_context(|| format!("failed to remove {}", path.display())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn require_root_errors_when_not_root() { + // No-op under a root test runner (e.g. CI running as root); the + // point of this test is the non-root case, which is how tests + // normally run. + if geteuid().is_root() { + return; + } + let err = require_root("install").expect_err("must not be root"); + assert!(err.to_string().contains("sudo tunmux launchd install")); + } + + #[test] + fn render_plist_substitutes_binary_and_gid() { + let rendered = render_plist_from(PLIST_TEMPLATE, "/opt/homebrew/bin/tunmux", 499) + .expect("render succeeds"); + + assert!(rendered.contains("SockPathGroup")); + assert!(rendered.contains("499")); + assert!(rendered.contains("/opt/homebrew/bin/tunmux")); + assert!(!rendered.contains(BIN_PLACEHOLDER)); + assert!(!rendered.contains("@SOCK_PATH_GROUP@")); + assert!(rendered.contains("me.pansen.tunmux.privileged")); + assert!(rendered.contains("SockPathMode")); + } + + #[test] + fn render_plist_errors_when_bin_placeholder_missing() { + let template = PLIST_TEMPLATE.replace(BIN_PLACEHOLDER, "/usr/local/bin/tunmux"); + let err = render_plist_from(&template, "/opt/homebrew/bin/tunmux", 499) + .expect_err("missing bin placeholder should error"); + assert!(err.to_string().contains(BIN_PLACEHOLDER)); + } + + #[test] + fn render_plist_errors_when_sock_group_marker_missing() { + let template = PLIST_TEMPLATE.replace(SOCK_GROUP_MARKER, ""); + let err = render_plist_from(&template, "/opt/homebrew/bin/tunmux", 499) + .expect_err("missing marker should error"); + assert!(err.to_string().contains("SockPathGroup")); + } + + #[test] + fn rejects_user_home_directory() { + assert!(validate_binary_location( + Path::new("/Users/andi/p/tunmux/target/release/tunmux"), + Path::new("/Users/andi/p/tunmux/target/release/tunmux"), + None, + ) + .is_err()); + } + + #[test] + fn rejects_tmp() { + assert!( + validate_binary_location(Path::new("/tmp/tunmux"), Path::new("/tmp/tunmux"), None) + .is_err() + ); + } + + #[test] + fn rejects_var_folders() { + assert!(validate_binary_location( + Path::new("/private/var/folders/xx/tunmux"), + Path::new("/private/var/folders/xx/tunmux"), + None, + ) + .is_err()); + } + + #[test] + fn rejects_relative_path() { + assert!(validate_binary_location(Path::new("tunmux"), Path::new("tunmux"), None).is_err()); + } + + #[test] + fn rejects_symlink_resolving_into_home_dir() { + // Invoked path looks fine (/usr/local/bin), but the symlink target + // resolves into a home directory build — must still be rejected. + assert!(validate_binary_location( + Path::new("/usr/local/bin/tunmux"), + Path::new("/Users/andi/target/release/tunmux"), + None, + ) + .is_err()); + } + + #[test] + fn rejects_non_standard_home_via_invoking_user_home() { + assert!(validate_binary_location( + Path::new("/opt/home/andi/tunmux"), + Path::new("/opt/home/andi/tunmux"), + Some(Path::new("/opt/home/andi")), + ) + .is_err()); + } + + #[test] + fn accepts_usr_local_bin() { + assert!(validate_binary_location( + Path::new("/usr/local/bin/tunmux"), + Path::new("/usr/local/bin/tunmux"), + None, + ) + .is_ok()); + } + + #[test] + fn accepts_homebrew_cellar_symlink_target() { + assert!(validate_binary_location( + Path::new("/opt/homebrew/bin/tunmux"), + Path::new("/opt/homebrew/Cellar/tunmux/0.9.0/bin/tunmux"), + None, + ) + .is_ok()); + } +} diff --git a/src/lib.rs b/src/lib.rs index d787d44..34679d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod cli; pub mod config; pub mod error; +pub mod launchd; pub mod logging; pub mod shared; diff --git a/src/main.rs b/src/main.rs index d6dfb55..3735dae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod cli; mod config; mod error; +mod launchd; mod logging; mod privileged; mod privileged_api; @@ -71,6 +72,14 @@ fn main() { } } + TopCommand::Launchd { command } => { + init_logging(cli.verbose); + if let Err(e) = launchd::dispatch(command) { + error!( command = ?"launchd", error = ?e.to_string(), "command_failed"); + std::process::exit(1); + } + } + // All other commands use the multi-threaded tokio runtime. other => { init_logging(cli.verbose); @@ -102,7 +111,10 @@ async fn run(command: TopCommand, config: config::AppConfig) -> anyhow::Result<( all, } => run_disconnect(instance, provider, all, &config).await, TopCommand::Hook { command } => run_hook_command(command), - TopCommand::Status | TopCommand::Wg | TopCommand::Privileged { .. } => { + TopCommand::Status + | TopCommand::Wg + | TopCommand::Launchd { .. } + | TopCommand::Privileged { .. } => { unreachable!() } } From 26977b50bdc3f0a7703aff17d77a82799c4ae8c9 Mon Sep 17 00:00:00 2001 From: andi Date: Sun, 19 Jul 2026 09:27:10 +0200 Subject: [PATCH 2/7] Post-review improvements --- Makefile | 7 +- etc/me.pansen.tunmux.privileged.plist | 3 +- src/launchd.rs | 161 +++++++++++++++++++------- src/main.rs | 2 +- 4 files changed, 129 insertions(+), 44 deletions(-) diff --git a/Makefile b/Makefile index 2bb64cd..6bb883a 100644 --- a/Makefile +++ b/Makefile @@ -75,8 +75,11 @@ uninstall/dns: sudo killall -HUP mDNSResponder .PHONY: uninstall/privileged -uninstall/privileged: - sudo /usr/local/bin/tunmux launchd uninstall 2>/dev/null || true +uninstall/privileged: build.release + @# Prefer the installed binary; if it was already removed, fall back to the + @# freshly compiled one so `launchd uninstall` (bootout + plist removal) still runs. + bin=/usr/local/bin/tunmux; [ -x "$$bin" ] || bin=target/release/tunmux; \ + sudo "$$bin" launchd uninstall || true sudo pkill -f '/usr/local/bin/tunmux wgconf' 2>/dev/null || true sudo rm -f /usr/local/bin/tunmux sudo rm -rf "/Library/Application Support/tunmux" diff --git a/etc/me.pansen.tunmux.privileged.plist b/etc/me.pansen.tunmux.privileged.plist index bbf923f..4150dd9 100644 --- a/etc/me.pansen.tunmux.privileged.plist +++ b/etc/me.pansen.tunmux.privileged.plist @@ -25,8 +25,7 @@ SockPathName /Library/Application Support/tunmux/run/ctl.sock - - + SockPathMode 432 diff --git a/src/launchd.rs b/src/launchd.rs index ff90ee6..8cd681d 100644 --- a/src/launchd.rs +++ b/src/launchd.rs @@ -15,8 +15,8 @@ use nix::unistd::{chown, geteuid, Gid, Group, Uid, User}; use crate::cli::LaunchdCommand; use crate::config; -pub const LABEL: &str = "me.pansen.tunmux.privileged"; -pub const PLIST_PATH: &str = "/Library/LaunchDaemons/me.pansen.tunmux.privileged.plist"; +pub(crate) const LABEL: &str = "me.pansen.tunmux.privileged"; +pub(crate) const PLIST_PATH: &str = "/Library/LaunchDaemons/me.pansen.tunmux.privileged.plist"; /// Group whose members may talk to the privileged daemon's control socket. /// Must match `AUTH_GROUP_NAME` in `src/privileged/mod.rs`, which is private @@ -25,39 +25,61 @@ const GROUP_NAME: &str = "tunmux"; const PLIST_TEMPLATE: &str = include_str!("../etc/me.pansen.tunmux.privileged.plist"); const BIN_PLACEHOLDER: &str = "@TUNMUX_BIN@"; -const SOCK_GROUP_MARKER: &str = - ""; +const SOCK_GROUP_MARKER: &str = "@SOCK_PATH_GROUP@"; /// Render the privileged daemon's launchd plist, substituting the daemon /// binary path and the authorized-group GID into `template`. fn render_plist_from(template: &str, daemon_binary: &str, gid: u32) -> anyhow::Result { if !template.contains(BIN_PLACEHOLDER) { - anyhow::bail!( - "plist template is missing the {} placeholder", - BIN_PLACEHOLDER - ); + anyhow::bail!("plist template is missing the {BIN_PLACEHOLDER} placeholder"); } - let rendered = template.replace(BIN_PLACEHOLDER, daemon_binary); let marker_line = template .lines() .find(|line| line.contains(SOCK_GROUP_MARKER)) .ok_or_else(|| { - anyhow::anyhow!( - "plist template is missing the SockPathGroup marker comment: {}", - SOCK_GROUP_MARKER - ) + anyhow::anyhow!("plist template is missing the {SOCK_GROUP_MARKER} marker comment") })?; let indent: String = marker_line .chars() .take_while(|c| c.is_whitespace()) .collect(); - let replacement = format!("{indent}SockPathGroup\n{indent}{gid}"); - let rendered = rendered.replace(marker_line, &replacement); + let group_kv = format!("{indent}SockPathGroup\n{indent}{gid}"); + + // Replace the marker line on the raw template first (so it matches regardless + // of where BIN_PLACEHOLDER sits), then substitute the escaped binary path. + let rendered = template + .replace(marker_line, &group_kv) + .replace(BIN_PLACEHOLDER, &xml_escape(daemon_binary)); + + // Fail closed: neither placeholder may survive, and the daemon Label the + // restart/uninstall paths target must be present (guards a bad custom template). + anyhow::ensure!( + !rendered.contains(BIN_PLACEHOLDER), + "rendered plist still contains {BIN_PLACEHOLDER}" + ); + anyhow::ensure!( + !rendered.contains(SOCK_GROUP_MARKER), + "rendered plist still contains the {SOCK_GROUP_MARKER} marker" + ); + anyhow::ensure!( + rendered.contains(LABEL), + "rendered plist is missing the expected launchd Label `{LABEL}` (custom template?)" + ); Ok(rendered) } +/// Escape the five XML special characters so a path with e.g. `&` in it +/// still produces a well-formed plist. +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + /// Reject binaries in locations a regular user controls. /// /// The rendered plist makes launchd run this binary as root, so both the @@ -73,9 +95,9 @@ fn render_plist_from(template: &str, daemon_binary: &str, gid: u32) -> anyhow::R /// denylisting of known user-writable roots (home directories, temp dirs) /// is the meaningful signal here. pub fn validate_binary_location( - invoked: &std::path::Path, - resolved: &std::path::Path, - invoking_user_home: Option<&std::path::Path>, + invoked: &Path, + resolved: &Path, + invoking_user_home: Option<&Path>, ) -> anyhow::Result<()> { validate_one(invoked, invoking_user_home)?; validate_one(resolved, invoking_user_home)?; @@ -88,12 +110,11 @@ const REJECTED_PREFIXES: &[&str] = &[ "/private/tmp/", "/var/folders/", "/private/var/folders/", + "/var/tmp/", + "/private/var/tmp/", ]; -fn validate_one( - path: &std::path::Path, - invoking_user_home: Option<&std::path::Path>, -) -> anyhow::Result<()> { +fn validate_one(path: &Path, invoking_user_home: Option<&Path>) -> anyhow::Result<()> { if !path.is_absolute() { anyhow::bail!( "refusing to install a launchd daemon that runs a non-absolute path ({}); \ @@ -137,18 +158,36 @@ pub fn dispatch(command: LaunchdCommand) -> anyhow::Result<()> { } } -fn cmd_install(plist_template: Option) -> anyhow::Result<()> { +fn cmd_install(plist_template: Option) -> anyhow::Result<()> { require_root("install")?; + + // Validate everything that can refuse the install BEFORE mutating any + // system state (group creation, membership, directories, plist). let user = invoking_user()?; - let gid = ensure_group_with_member(&user)?; let bin = daemon_binary_path()?; - ensure_directories(gid)?; + let bin_str = bin.to_str().ok_or_else(|| { + anyhow::anyhow!("tunmux binary path is not valid UTF-8: {}", bin.display()) + })?; let template = match plist_template.as_deref() { - Some(path) => std::fs::read_to_string(path) + Some(path) => fs::read_to_string(path) .with_context(|| format!("failed to read plist template {}", path.display()))?, None => PLIST_TEMPLATE.to_string(), }; - let plist = render_plist_from(&template, &bin.to_string_lossy(), gid)?; + // Cheap fail-closed pre-check so a malformed template can't leave a + // half-created group behind (render_plist_from re-checks below). + anyhow::ensure!( + template.contains(BIN_PLACEHOLDER), + "plist template is missing the {BIN_PLACEHOLDER} placeholder" + ); + anyhow::ensure!( + template.lines().any(|l| l.contains(SOCK_GROUP_MARKER)), + "plist template is missing the {SOCK_GROUP_MARKER} marker comment" + ); + + // --- system mutation begins here --- + let gid = ensure_group_with_member(&user)?; + ensure_directories(gid)?; + let plist = render_plist_from(&template, bin_str, gid)?; write_plist(&plist)?; bootstrap()?; @@ -170,7 +209,7 @@ fn cmd_restart() -> anyhow::Result<()> { // Re-run the same location validation as install, guarding against e.g. // `sudo ./target/debug/tunmux launchd restart` restarting a daemon that // was installed from a different (system) location. - daemon_binary_path()?; + let _ = daemon_binary_path()?; run_checked( "/bin/launchctl", @@ -246,8 +285,10 @@ fn invoking_user() -> anyhow::Result { /// address; the current dev/Makefile flow installs straight to /// /usr/local/bin, so it's unaffected either way. fn daemon_binary_path() -> anyhow::Result { - let invoked = std::env::current_exe()?; - let resolved = fs::canonicalize(&invoked).unwrap_or_else(|_| invoked.clone()); + let invoked = + std::env::current_exe().context("failed to determine the running tunmux binary path")?; + let resolved = fs::canonicalize(&invoked) + .with_context(|| format!("failed to resolve {}", invoked.display()))?; validate_binary_location(&invoked, &resolved, invoking_user_home().as_deref())?; Ok(invoked) } @@ -301,17 +342,28 @@ fn ensure_directories(gid: u32) -> anyhow::Result<()> { Ok(()) } -/// Write the rendered plist to `PLIST_PATH` with the ownership/permissions -/// launchd expects of a system daemon plist. +/// Write the rendered plist to `PLIST_PATH` atomically (temp file + rename) +/// with the ownership/permissions launchd expects of a system daemon plist. fn write_plist(contents: &str) -> anyhow::Result<()> { use std::os::unix::fs::PermissionsExt; - fs::write(PLIST_PATH, contents).with_context(|| format!("failed to write {PLIST_PATH}"))?; - fs::set_permissions(PLIST_PATH, fs::Permissions::from_mode(0o644)) - .with_context(|| format!("failed to chmod {PLIST_PATH}"))?; - chown(PLIST_PATH, Some(Uid::from_raw(0)), Some(Gid::from_raw(0))) - .with_context(|| format!("failed to chown {PLIST_PATH}"))?; - Ok(()) + let tmp = PathBuf::from(format!("{PLIST_PATH}.tmp")); + + let write_result = (|| -> anyhow::Result<()> { + fs::write(&tmp, contents).with_context(|| format!("failed to write {}", tmp.display()))?; + fs::set_permissions(&tmp, fs::Permissions::from_mode(0o644)) + .with_context(|| format!("failed to chmod {}", tmp.display()))?; + chown(&tmp, Some(Uid::from_raw(0)), Some(Gid::from_raw(0))) + .with_context(|| format!("failed to chown {}", tmp.display()))?; + fs::rename(&tmp, PLIST_PATH).with_context(|| format!("failed to install {PLIST_PATH}"))?; + Ok(()) + })(); + + if write_result.is_err() { + // Best-effort cleanup; ignore errors. + let _ = fs::remove_file(&tmp); + } + write_result } /// Port of Makefile:36-38 (order matters): drop any existing instance, clear @@ -414,7 +466,28 @@ mod tests { let template = PLIST_TEMPLATE.replace(SOCK_GROUP_MARKER, ""); let err = render_plist_from(&template, "/opt/homebrew/bin/tunmux", 499) .expect_err("missing marker should error"); - assert!(err.to_string().contains("SockPathGroup")); + assert!(err.to_string().contains(SOCK_GROUP_MARKER)); + } + + #[test] + fn render_rejects_template_without_label() { + let modified = PLIST_TEMPLATE.replace(LABEL, "me.pansen.tunmux.evil"); + let err = render_plist_from(&modified, "/usr/local/bin/tunmux", 20) + .expect_err("missing expected Label should error"); + assert!(err.to_string().contains("Label")); + } + + #[test] + fn xml_escape_escapes_specials() { + assert_eq!(xml_escape("/a&b/"), "/a&b/<c>"); + } + + #[test] + fn render_escapes_binary_path() { + let rendered = + render_plist_from(PLIST_TEMPLATE, "/opt/t&t/bin/tunmux", 20).expect("render succeeds"); + assert!(rendered.contains("/opt/t&t/bin/tunmux")); + assert!(!rendered.contains("t&t/bin")); } #[test] @@ -445,6 +518,16 @@ mod tests { .is_err()); } + #[test] + fn rejects_var_tmp() { + assert!(validate_binary_location( + Path::new("/var/tmp/tunmux"), + Path::new("/private/var/tmp/tunmux"), + None, + ) + .is_err()); + } + #[test] fn rejects_relative_path() { assert!(validate_binary_location(Path::new("tunmux"), Path::new("tunmux"), None).is_err()); diff --git a/src/main.rs b/src/main.rs index 3735dae..fd09569 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,7 +75,7 @@ fn main() { TopCommand::Launchd { command } => { init_logging(cli.verbose); if let Err(e) = launchd::dispatch(command) { - error!( command = ?"launchd", error = ?e.to_string(), "command_failed"); + error!(command = ?"launchd", error = %format!("{e:#}"), "command_failed"); std::process::exit(1); } } From 6e0a7cb7b375a6d6feea56504557a1fcb0f07940 Mon Sep 17 00:00:00 2001 From: andi Date: Sun, 19 Jul 2026 09:33:07 +0200 Subject: [PATCH 3/7] Cleanup uninstall --- Makefile | 12 +++++++++++- src/launchd.rs | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 6bb883a..0757220 100644 --- a/Makefile +++ b/Makefile @@ -76,10 +76,17 @@ uninstall/dns: .PHONY: uninstall/privileged uninstall/privileged: build.release + @# Unregister the daemon only (bootout + plist/socket removal). Keeps the + @# binary, tunmux group, and logs — see purge/privileged for full teardown. @# Prefer the installed binary; if it was already removed, fall back to the - @# freshly compiled one so `launchd uninstall` (bootout + plist removal) still runs. + @# freshly compiled one so `launchd uninstall` still runs. bin=/usr/local/bin/tunmux; [ -x "$$bin" ] || bin=target/release/tunmux; \ sudo "$$bin" launchd uninstall || true + +.PHONY: purge/privileged +purge/privileged: uninstall/privileged + @# Destructive: after unregistering the daemon, remove the binary, all data, + @# logs, and the tunmux group. sudo pkill -f '/usr/local/bin/tunmux wgconf' 2>/dev/null || true sudo rm -f /usr/local/bin/tunmux sudo rm -rf "/Library/Application Support/tunmux" @@ -89,6 +96,9 @@ uninstall/privileged: build.release .PHONY: uninstall uninstall: uninstall/autostart uninstall/privileged uninstall/dns +.PHONY: purge +purge: uninstall purge/privileged + .PHONY: check/privileged check/privileged: diff --git a/src/launchd.rs b/src/launchd.rs index 8cd681d..1566188 100644 --- a/src/launchd.rs +++ b/src/launchd.rs @@ -234,7 +234,7 @@ fn cmd_uninstall() -> anyhow::Result<()> { remove_file_ignore_missing(&config::privileged_socket_path())?; println!("tunmux privileged daemon uninstalled."); - println!("Intentionally kept (remove with `make uninstall/privileged` for a full removal):"); + println!("Intentionally kept (remove with `make purge/privileged` for a full removal):"); println!(" the tunmux binary"); println!(" the tunmux group"); println!(" {}", config::root_log_dir().display()); From aab797cacb3d04e94b996791420748706172d111 Mon Sep 17 00:00:00 2001 From: andi Date: Sun, 19 Jul 2026 10:14:06 +0200 Subject: [PATCH 4/7] `tunmux status` shows connections from the privileged daemon --- src/main.rs | 96 +++++++++++++++++++++++++++++++++--------- src/wgconf/handlers.rs | 30 +++++++++++++ 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/src/main.rs b/src/main.rs index fd09569..6548662 100644 --- a/src/main.rs +++ b/src/main.rs @@ -362,33 +362,91 @@ async fn dispatch_provider_disconnect( } fn cmd_status() -> anyhow::Result<()> { - let connections = ConnectionState::load_all()?; + let mut connections: Vec = ConnectionState::load_all()? + .into_iter() + .filter(|c| match c.backend { + wireguard::backend::WgBackend::Userspace => c.is_live(), + _ => true, + }) + .collect(); + + let have_wgconf_direct = connections + .iter() + .any(|c| c.provider == "wgconf" && c.interface_name == "wgconf0"); + if !have_wgconf_direct + && privileged_client::PrivilegedClient::new() + .interface_active("wgconf0") + .unwrap_or(false) + { + connections.push(wireguard::connection::ConnectionState { + instance_name: wireguard::connection::DIRECT_INSTANCE.to_string(), + provider: "wgconf".to_string(), + interface_name: "wgconf0".to_string(), + backend: wireguard::backend::WgBackend::Userspace, + server_endpoint: "(unknown)".to_string(), + server_display_name: "(unknown)".to_string(), + dns_servers: Vec::new(), + source_path: None, + }); + } if connections.is_empty() { println!("No active connections."); return Ok(()); } + let headers = ["Instance", "Provider", "Server", "Exit", "Backend"]; + let rows: Vec<[String; 5]> = connections + .iter() + .map(|conn| { + let exit = conn + .server_display_name + .split('#') + .next() + .unwrap_or("") + .chars() + .filter(|c| c.is_ascii_alphabetic()) + .collect::(); + [ + conn.instance_name.clone(), + conn.provider.clone(), + conn.server_display_name.clone(), + exit, + conn.backend.to_string(), + ] + }) + .collect(); + + // Size each column to the widest of its header and cells so long values + // (e.g. a `.conf` filename in Server) don't push the table out of alignment. + let mut widths = headers.map(str::len); + for row in &rows { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell.len()); + } + } + + let render_row = |cells: &[String]| { + cells + .iter() + .enumerate() + .map(|(i, c)| format!("{:>() + .join(" | ") + }; + + let header_cells: Vec = headers.iter().map(|h| (*h).to_string()).collect(); + println!("{}", render_row(&header_cells).trim_end()); println!( - "{:<12} {:<9} {:<10} {:<5} {:<9}", - "Instance", "Provider", "Server", "Exit", "Backend" + "{}", + widths + .iter() + .map(|w| "-".repeat(*w)) + .collect::>() + .join("-+-") ); - println!("{}", "-".repeat(50)); - - for conn in &connections { - let exit = conn - .server_display_name - .split('#') - .next() - .unwrap_or("") - .chars() - .filter(|c| c.is_ascii_alphabetic()) - .collect::(); - - println!( - "{:<12} {:<9} {:<10} {:<5} {:<9}", - conn.instance_name, conn.provider, conn.server_display_name, exit, conn.backend, - ); + for row in &rows { + println!("{}", render_row(row).trim_end()); } Ok(()) diff --git a/src/wgconf/handlers.rs b/src/wgconf/handlers.rs index d4fd477..a8a2b84 100644 --- a/src/wgconf/handlers.rs +++ b/src/wgconf/handlers.rs @@ -205,6 +205,36 @@ fn connect_direct( if wireguard::wg_quick::is_interface_active(INTERFACE_NAME) || wireguard::userspace::is_interface_active(INTERFACE_NAME) { + // The exclusive `wgconf0` slot is live but no saved state describes it — + // a desync (state pruned during a daemon idle window while the userspace + // helper kept running). Re-adopt the live interface by saving state for + // the config we were asked to bring up, so `status`/`disconnect` work + // again instead of every `--if-missing` autoconnect wedging here. + // A live `wgconf0` is always the userspace slot (wg-quick/kernel use utunN). + tracing::warn!( + interface = INTERFACE_NAME, + "adopting live wgconf interface with no saved state (state/daemon desync)" + ); + let adopted = wireguard::connection::ConnectionState { + instance_name: DIRECT_INSTANCE.to_string(), + provider: PROVIDER.dir_name().to_string(), + interface_name: INTERFACE_NAME.to_string(), + backend: wireguard::backend::WgBackend::Userspace, + server_endpoint: routed + .map(|cfg| format_endpoint(&cfg.server_ip, cfg.server_port)) + .unwrap_or_else(|| best_effort_endpoint(&source.config_text)), + server_display_name: source.display_name.clone(), + dns_servers: wireguard::config::parse_config(&source.config_text) + .map(|parsed| parsed.dns_servers) + .unwrap_or_default(), + source_path: source.source_path.clone(), + }; + adopted.save()?; + + if if_missing { + println!("Already connected to {}.", source.display_name); + return Ok(()); + } anyhow::bail!("Already connected. Run `tunmux disconnect --provider wgconf` first."); } From 7cd8d260fced85e6d1440526c00ddd191a731a8c Mon Sep 17 00:00:00 2001 From: andi Date: Sun, 19 Jul 2026 10:22:13 +0200 Subject: [PATCH 5/7] `tunmux status` sanding --- src/main.rs | 12 ++---------- src/wireguard/userspace.rs | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 6548662..d82fccc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -395,23 +395,15 @@ fn cmd_status() -> anyhow::Result<()> { return Ok(()); } - let headers = ["Instance", "Provider", "Server", "Exit", "Backend"]; + let headers = ["Instance", "Provider", "Server", "Endpoint", "Backend"]; let rows: Vec<[String; 5]> = connections .iter() .map(|conn| { - let exit = conn - .server_display_name - .split('#') - .next() - .unwrap_or("") - .chars() - .filter(|c| c.is_ascii_alphabetic()) - .collect::(); [ conn.instance_name.clone(), conn.provider.clone(), conn.server_display_name.clone(), - exit, + conn.server_endpoint.clone(), conn.backend.to_string(), ] }) diff --git a/src/wireguard/userspace.rs b/src/wireguard/userspace.rs index 7d5aa81..b391378 100644 --- a/src/wireguard/userspace.rs +++ b/src/wireguard/userspace.rs @@ -71,7 +71,37 @@ pub fn down_raw(interface_name: &str) -> Result<()> { /// semantics). #[must_use] pub fn is_interface_active(interface_name: &str) -> bool { - PrivilegedClient::new() - .interface_active(interface_name) - .unwrap_or(false) + let client = PrivilegedClient::new(); + // The privileged daemon is on-demand and idle-exits; the first probe after + // an idle exit can race its cold start and return a *transport error*. A + // bare `unwrap_or(false)` there reports a live tunnel as down, which then + // wrongly clears saved connection state (see `direct_connection_active`) + // and drives a needless disconnect / re-adopt churn every autoconnect + // cycle. An authoritative `Ok(active)` from the daemon is returned + // immediately (so a genuinely-down tunnel stays fast); only a transport + // error is retried briefly so the probe settles on the true state. + const ATTEMPTS: usize = 4; + const BACKOFF: std::time::Duration = std::time::Duration::from_millis(200); + for attempt in 0..ATTEMPTS { + match client.interface_active(interface_name) { + Ok(active) => return active, + Err(err) if attempt + 1 < ATTEMPTS => { + tracing::debug!( + interface = interface_name, + error = %err, + "interface_active probe errored; retrying (daemon cold start?)" + ); + std::thread::sleep(BACKOFF); + } + Err(err) => { + tracing::debug!( + interface = interface_name, + error = %err, + "interface_active probe failed after retries; reporting inactive" + ); + return false; + } + } + } + false } From 24f3efcd9253fc0774d6d59e4f82931610688846 Mon Sep 17 00:00:00 2001 From: andi Date: Sun, 19 Jul 2026 10:55:21 +0200 Subject: [PATCH 6/7] Add userspace `tunmux autoconnect` subcommand --- Makefile | 15 +- etc/me.pansen.tunmux.autoconnect.plist | 10 +- src/autoconnect.rs | 446 +++++++++++++++++++++++++ src/cli.rs | 142 +++++++- src/launchctl.rs | 88 +++++ src/launchd.rs | 59 +--- src/lib.rs | 2 + src/main.rs | 11 + 8 files changed, 696 insertions(+), 77 deletions(-) create mode 100644 src/autoconnect.rs create mode 100644 src/launchctl.rs diff --git a/Makefile b/Makefile index 0757220..93fa446 100644 --- a/Makefile +++ b/Makefile @@ -23,14 +23,7 @@ install/privileged: .PHONY: install/autostart install/autostart: - mkdir -p $$HOME/Library/LaunchAgents - sed -e "s|__HOME__|$$HOME|g" -e "s|__PROFILE__|$(TUNMUX_PROFILE)|g" etc/me.pansen.tunmux.autoconnect.plist > $$HOME/Library/LaunchAgents/me.pansen.tunmux.autoconnect.plist - launchctl bootout gui/$$(id -u)/me.pansen.tunmux.autoconnect 2>/dev/null || true - launchctl bootstrap gui/$$(id -u) $$HOME/Library/LaunchAgents/me.pansen.tunmux.autoconnect.plist - @# test now - launchctl kickstart -k gui/$$(id -u)/me.pansen.tunmux.autoconnect - @# inspect - launchctl print gui/$$(id -u)/me.pansen.tunmux.autoconnect + /usr/local/bin/tunmux autoconnect install --file $(TUNMUX_PROFILE) --force .PHONY: install @@ -47,8 +40,7 @@ reload/connections: .PHONY: reload/autostart reload/autostart: - launchctl kickstart -k gui/$$(id -u)/me.pansen.tunmux.autoconnect - launchctl print gui/$$(id -u)/me.pansen.tunmux.autoconnect + /usr/local/bin/tunmux autoconnect reload .PHONY: reload reload: reload/privileged reload/connections reload/autostart @@ -56,8 +48,7 @@ reload: reload/privileged reload/connections reload/autostart .PHONY: uninstall/autostart uninstall/autostart: - launchctl bootout gui/$$(id -u)/me.pansen.tunmux.autoconnect 2>/dev/null || true - rm -f $$HOME/Library/LaunchAgents/me.pansen.tunmux.autoconnect.plist + /usr/local/bin/tunmux autoconnect uninstall .PHONY: uninstall/dns uninstall/dns: diff --git a/etc/me.pansen.tunmux.autoconnect.plist b/etc/me.pansen.tunmux.autoconnect.plist index 5a15cac..123e1e6 100644 --- a/etc/me.pansen.tunmux.autoconnect.plist +++ b/etc/me.pansen.tunmux.autoconnect.plist @@ -11,12 +11,12 @@ call the binary directly with no wrapper script. --> ProgramArguments - /usr/local/bin/tunmux + @TUNMUX_BIN@ --debug wgconf connect - --file - __PROFILE__ + @CONNECT_FLAG@ + @CONNECT_VALUE@ --if-missing @@ -32,8 +32,8 @@ Aqua StandardOutPath - __HOME__/Library/Logs/tunmux-autoconnect.out.log + @TUNMUX_HOME@/Library/Logs/tunmux-autoconnect.out.log StandardErrorPath - __HOME__/Library/Logs/tunmux-autoconnect.err.log + @TUNMUX_HOME@/Library/Logs/tunmux-autoconnect.err.log diff --git a/src/autoconnect.rs b/src/autoconnect.rs new file mode 100644 index 0000000..ae00546 --- /dev/null +++ b/src/autoconnect.rs @@ -0,0 +1,446 @@ +//! Pure core for the `tunmux autoconnect` subcommand: an installer for the +//! per-user (GUI-domain) autoconnect LaunchAgent plist. This module provides +//! both the plist-rendering logic and the +//! `tunmux autoconnect install|reload|uninstall` command handlers, porting +//! `make install/autostart`, `reload/autostart`, and `uninstall/autostart`. + +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use anyhow::Context; +use nix::unistd::{geteuid, getuid}; + +use crate::cli::AutoconnectCommand; +use crate::launchctl::{ + remove_file_ignore_missing, run_checked, run_ignore_failure, xml_escape, xml_unescape, +}; + +pub(crate) const LABEL: &str = "me.pansen.tunmux.autoconnect"; + +const PLIST_TEMPLATE: &str = include_str!("../etc/me.pansen.tunmux.autoconnect.plist"); +const BIN_PLACEHOLDER: &str = "@TUNMUX_BIN@"; +const HOME_PLACEHOLDER: &str = "@TUNMUX_HOME@"; +const FLAG_PLACEHOLDER: &str = "@CONNECT_FLAG@"; +const VALUE_PLACEHOLDER: &str = "@CONNECT_VALUE@"; + +/// The WireGuard config source the autoconnect agent should connect with, +/// mirroring the file/profile mutual exclusion of `WgconfConnectArgs`. +enum ConnectSource { + File(String), + Profile(String), +} + +impl ConnectSource { + fn flag(&self) -> &'static str { + match self { + ConnectSource::File(_) => "--file", + ConnectSource::Profile(_) => "--profile", + } + } + + fn value(&self) -> &str { + match self { + ConnectSource::File(v) | ConnectSource::Profile(v) => v, + } + } +} + +/// Render the autoconnect agent's launchd plist, substituting the tunmux +/// binary path, the invoking user's home directory, and the connect source +/// (file/profile flag + value) into `template`. +fn render_plist( + template: &str, + bin: &str, + home: &str, + source: &ConnectSource, +) -> anyhow::Result { + for placeholder in [ + BIN_PLACEHOLDER, + HOME_PLACEHOLDER, + FLAG_PLACEHOLDER, + VALUE_PLACEHOLDER, + ] { + if !template.contains(placeholder) { + anyhow::bail!("plist template is missing the {placeholder} placeholder"); + } + } + + let rendered = template + .replace(BIN_PLACEHOLDER, &xml_escape(bin)) + .replace(HOME_PLACEHOLDER, &xml_escape(home)) + .replace(FLAG_PLACEHOLDER, source.flag()) + .replace(VALUE_PLACEHOLDER, &xml_escape(source.value())); + + // Fail closed: none of the placeholders may survive, and the Label the + // reload/uninstall paths target must be present (guards a bad custom + // template). + for placeholder in [ + BIN_PLACEHOLDER, + HOME_PLACEHOLDER, + FLAG_PLACEHOLDER, + VALUE_PLACEHOLDER, + ] { + anyhow::ensure!( + !rendered.contains(placeholder), + "rendered plist still contains {placeholder}" + ); + } + anyhow::ensure!( + rendered.contains(LABEL), + "rendered plist is missing the expected launchd Label `{LABEL}` (custom template?)" + ); + + Ok(rendered) +} + +pub fn dispatch(command: AutoconnectCommand) -> anyhow::Result<()> { + match command { + AutoconnectCommand::Install { + file, + profile, + force, + } => { + let source = connect_source(file, profile)?; + cmd_install(source, force) + } + AutoconnectCommand::List => cmd_list(), + AutoconnectCommand::Reload => cmd_reload(), + AutoconnectCommand::Uninstall => cmd_uninstall(), + } +} + +/// Build a `ConnectSource` from the CLI's file/profile options. clap already +/// guarantees exactly one is `Some` via `required_unless_present` / +/// `conflicts_with`; bail with a clear error in the (unreachable in +/// practice) case neither is set. +fn connect_source(file: Option, profile: Option) -> anyhow::Result { + match (file, profile) { + (Some(file), None) => Ok(ConnectSource::File(file)), + (None, Some(profile)) => Ok(ConnectSource::Profile(profile)), + _ => anyhow::bail!("exactly one of --file or --profile must be given"), + } +} + +fn cmd_install(source: ConnectSource, force: bool) -> anyhow::Result<()> { + refuse_if_root()?; + + let home = std::env::var("HOME").context("could not determine $HOME")?; + let uid = getuid().as_raw(); + let bin = + std::env::current_exe().context("failed to determine the running tunmux binary path")?; + let bin_str = bin.to_str().ok_or_else(|| { + anyhow::anyhow!("tunmux binary path is not valid UTF-8: {}", bin.display()) + })?; + + // Existing-install guard. Mutate nothing before this check. + let plist_path = launch_agents_dir(&home).join(format!("{LABEL}.plist")); + if plist_path.exists() && !force { + anyhow::bail!( + "autoconnect agent already installed at {}; re-run with --force to overwrite and reload", + plist_path.display() + ); + } + + let plist = render_plist(PLIST_TEMPLATE, bin_str, &home, &source)?; + + let dir = launch_agents_dir(&home); + fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; + write_plist(&plist_path, &plist)?; + + let plist_path_str = plist_path.to_str().ok_or_else(|| { + anyhow::anyhow!( + "autoconnect plist path is not valid UTF-8: {}", + plist_path.display() + ) + })?; + + run_ignore_failure("/bin/launchctl", &["bootout", &domain_target(uid)]); + run_checked( + "/bin/launchctl", + &["bootstrap", &gui_domain(uid), plist_path_str], + )?; + run_checked("/bin/launchctl", &["kickstart", "-k", &domain_target(uid)])?; + + println!("tunmux autoconnect agent installed."); + println!(" plist: {}", plist_path.display()); + println!(" binary: {}", bin.display()); + println!(" {}: {}", source.flag(), source.value()); + Ok(()) +} + +fn cmd_reload() -> anyhow::Result<()> { + refuse_if_root()?; + let uid = getuid().as_raw(); + + run_checked("/bin/launchctl", &["kickstart", "-k", &domain_target(uid)]).with_context( + || "autoconnect agent not installed? run: tunmux autoconnect install --file ", + )?; + + println!("tunmux autoconnect agent reloaded."); + Ok(()) +} + +fn cmd_uninstall() -> anyhow::Result<()> { + refuse_if_root()?; + + let home = std::env::var("HOME").context("could not determine $HOME")?; + let uid = getuid().as_raw(); + let plist_path = launch_agents_dir(&home).join(format!("{LABEL}.plist")); + + run_ignore_failure("/bin/launchctl", &["bootout", &domain_target(uid)]); + remove_file_ignore_missing(&plist_path)?; + + println!("tunmux autoconnect agent uninstalled."); + println!(" plist: {}", plist_path.display()); + Ok(()) +} + +/// Read-only listing of the installed autoconnect LaunchAgent plist(s) in the +/// invoking user's `~/Library/LaunchAgents`, each with its launchd load state +/// and the connect source it was rendered with. No root check: this only reads. +fn cmd_list() -> anyhow::Result<()> { + let home = std::env::var("HOME").context("could not determine $HOME")?; + let uid = getuid().as_raw(); + let dir = launch_agents_dir(&home); + + let mut plists: Vec = match fs::read_dir(&dir) { + Ok(entries) => entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(LABEL) && name.ends_with(".plist")) + }) + .collect(), + Err(err) if err.kind() == ErrorKind::NotFound => Vec::new(), + Err(err) => return Err(err).with_context(|| format!("failed to read {}", dir.display())), + }; + plists.sort(); + + if plists.is_empty() { + println!("No autoconnect agent installed."); + return Ok(()); + } + + for (idx, path) in plists.iter().enumerate() { + if idx > 0 { + println!(); + } + let label = path.file_stem().and_then(|s| s.to_str()).unwrap_or(LABEL); + // 🟢 active (launchd has it loaded), 🔘 inactive (installed but not loaded). + let marker = if is_loaded(uid, label) { + "🟢" + } else { + "🔘" + }; + println!("{marker} {}", path.display()); + if let Some(source) = fs::read_to_string(path) + .ok() + .and_then(|contents| parse_connect_source(&contents)) + { + println!(" {source}"); + } + } + Ok(()) +} + +/// Whether launchd currently has `label` bootstrapped in the user's GUI domain. +fn is_loaded(uid: u32, label: &str) -> bool { + std::process::Command::new("/bin/launchctl") + .args(["print", &domain_target_for(uid, label)]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +/// Best-effort extraction of the `connect` source (flag + value) from a +/// rendered autoconnect plist, for display in `list`. Returns `None` when the +/// ProgramArguments don't have the expected `… connect …` shape. +fn parse_connect_source(plist: &str) -> Option { + let strings: Vec = plist + .lines() + .filter_map(|line| { + let line = line.trim(); + line.strip_prefix("") + .and_then(|rest| rest.strip_suffix("")) + .map(xml_unescape) + }) + .collect(); + let idx = strings.iter().position(|s| s == "connect")?; + let flag = strings.get(idx + 1)?; + let value = strings.get(idx + 2)?; + Some(format!("{flag} {value}")) +} + +/// Write the rendered plist to `path` atomically (temp file + rename), user +/// owned mode 0644. Unlike `launchd::write_plist`, no chown: the user +/// installing this already owns the file. +fn write_plist(path: &Path, contents: &str) -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let tmp = PathBuf::from(format!("{}.tmp", path.display())); + + let write_result = (|| -> anyhow::Result<()> { + fs::write(&tmp, contents).with_context(|| format!("failed to write {}", tmp.display()))?; + fs::set_permissions(&tmp, fs::Permissions::from_mode(0o644)) + .with_context(|| format!("failed to chmod {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("failed to install {}", path.display()))?; + Ok(()) + })(); + + if write_result.is_err() { + // Best-effort cleanup; ignore errors. + let _ = fs::remove_file(&tmp); + } + write_result +} + +fn launch_agents_dir(home: &str) -> PathBuf { + Path::new(home).join("Library/LaunchAgents") +} + +fn gui_domain(uid: u32) -> String { + format!("gui/{uid}") +} + +fn domain_target(uid: u32) -> String { + domain_target_for(uid, LABEL) +} + +fn domain_target_for(uid: u32, label: &str) -> String { + format!("gui/{uid}/{label}") +} + +/// Bail if running as root: the autoconnect agent is per-user (GUI domain), +/// and must not be installed via `sudo`. +fn refuse_if_root() -> anyhow::Result<()> { + if geteuid().is_root() { + anyhow::bail!( + "run `tunmux autoconnect install` as your normal user, not with sudo (the autoconnect agent is per-user)" + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refuse_if_root_when_root() { + // Unlike `launchd::require_root` (which errors when NOT root), + // `refuse_if_root` errors only when running AS root. Test runners + // normally run unprivileged, so assert the no-op branch there and + // only assert the refusal itself under a root test runner (e.g. CI + // running as root). + if geteuid().is_root() { + let err = refuse_if_root().expect_err("must refuse when root"); + assert!(err.to_string().contains("not with sudo")); + } else { + refuse_if_root().expect("must be a no-op when not root"); + } + } + + #[test] + fn render_plist_substitutes_all() { + let source = ConnectSource::File("/tmp/x.conf".to_string()); + let rendered = render_plist( + PLIST_TEMPLATE, + "/opt/homebrew/bin/tunmux", + "/Users/andi", + &source, + ) + .expect("render succeeds"); + + assert!(rendered.contains("/opt/homebrew/bin/tunmux")); + assert!(rendered.contains("/Users/andi")); + assert!(rendered.contains("--file")); + assert!(rendered.contains("/tmp/x.conf")); + assert!(!rendered.contains(BIN_PLACEHOLDER)); + assert!(!rendered.contains(HOME_PLACEHOLDER)); + assert!(!rendered.contains(FLAG_PLACEHOLDER)); + assert!(!rendered.contains(VALUE_PLACEHOLDER)); + assert!(rendered.contains(LABEL)); + } + + #[test] + fn render_plist_uses_profile_flag() { + let source = ConnectSource::Profile("work".to_string()); + let rendered = render_plist( + PLIST_TEMPLATE, + "/opt/homebrew/bin/tunmux", + "/Users/andi", + &source, + ) + .expect("render succeeds"); + + assert!(rendered.contains("--profile")); + assert!(rendered.contains("work")); + assert!(!rendered.contains("--file")); + } + + #[test] + fn render_escapes_special_chars() { + let source = ConnectSource::File("/tmp/a&b.conf".to_string()); + let rendered = render_plist( + PLIST_TEMPLATE, + "/opt/homebrew/bin/tunmux", + "/Users/andi", + &source, + ) + .expect("render succeeds"); + + assert!(rendered.contains("/tmp/a&b.conf")); + assert!(!rendered.contains("a&b.conf")); + } + + #[test] + fn render_errors_when_placeholder_missing() { + let template = PLIST_TEMPLATE.replace(BIN_PLACEHOLDER, "/usr/local/bin/tunmux"); + let source = ConnectSource::File("/tmp/x.conf".to_string()); + let err = render_plist( + &template, + "/opt/homebrew/bin/tunmux", + "/Users/andi", + &source, + ) + .expect_err("missing bin placeholder should error"); + assert!(err.to_string().contains(BIN_PLACEHOLDER)); + } + + #[test] + fn parse_connect_source_round_trips_through_rendered_plist() { + let source = ConnectSource::File("/tmp/a&b.conf".to_string()); + let rendered = render_plist( + PLIST_TEMPLATE, + "/opt/homebrew/bin/tunmux", + "/Users/andi", + &source, + ) + .expect("render succeeds"); + assert_eq!( + parse_connect_source(&rendered).as_deref(), + Some("--file /tmp/a&b.conf") + ); + } + + #[test] + fn parse_connect_source_reads_profile_flag() { + let source = ConnectSource::Profile("work".to_string()); + let rendered = render_plist( + PLIST_TEMPLATE, + "/opt/homebrew/bin/tunmux", + "/Users/andi", + &source, + ) + .expect("render succeeds"); + assert_eq!( + parse_connect_source(&rendered).as_deref(), + Some("--profile work") + ); + } +} diff --git a/src/cli.rs b/src/cli.rs index 4312c67..0a7d1e5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -62,6 +62,12 @@ pub enum TopCommand { command: LaunchdCommand, }, + /// Manage the per-user autoconnect LaunchAgent (GUI domain) + Autoconnect { + #[command(subcommand)] + command: AutoconnectCommand, + }, + /// Internal privileged service mode (hidden) #[command(hide = true)] Privileged { @@ -136,6 +142,31 @@ pub enum LaunchdCommand { Uninstall, } +#[derive(Subcommand)] +pub enum AutoconnectCommand { + /// Install and start the per-user autoconnect LaunchAgent (run WITHOUT sudo) + Install { + /// WireGuard .conf file path + #[arg(long, required_unless_present = "profile", conflicts_with = "profile")] + file: Option, + + /// Saved profile name + #[arg(long, required_unless_present = "file", conflicts_with = "file")] + profile: Option, + + /// Overwrite and reload an existing installation + #[arg(short = 'f', long)] + force: bool, + }, + /// List installed autoconnect LaunchAgent files (alias: ls) + #[command(visible_alias = "ls")] + List, + /// Reload (kickstart) the autoconnect LaunchAgent + Reload, + /// Stop and unregister the autoconnect LaunchAgent + Uninstall, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum HookEventArg { Ifup, @@ -233,8 +264,8 @@ pub enum WgconfCommand { #[cfg(test)] mod tests { use super::{ - Cli, ConnectProviderCommand, HookBuiltinArg, HookCommand, LaunchdCommand, ProviderArg, - TopCommand, WgconfCommand, + AutoconnectCommand, Cli, ConnectProviderCommand, HookBuiltinArg, HookCommand, + LaunchdCommand, ProviderArg, TopCommand, WgconfCommand, }; use clap::Parser; @@ -461,4 +492,111 @@ mod tests { _ => panic!("expected launchd install with template"), } } + + #[test] + fn parse_autoconnect_install_with_file() { + let cli = + Cli::try_parse_from(["tunmux", "autoconnect", "install", "--file", "/tmp/x.conf"]) + .expect("parse autoconnect install --file"); + + match cli.command { + TopCommand::Autoconnect { + command: + AutoconnectCommand::Install { + file, + profile, + force, + }, + } => { + assert_eq!(file.as_deref(), Some("/tmp/x.conf")); + assert!(profile.is_none()); + assert!(!force); + } + _ => panic!("expected autoconnect install command"), + } + } + + #[test] + fn parse_autoconnect_install_with_profile_and_force() { + let cli = Cli::try_parse_from([ + "tunmux", + "autoconnect", + "install", + "--profile", + "work", + "--force", + ]) + .expect("parse autoconnect install --profile --force"); + + match cli.command { + TopCommand::Autoconnect { + command: + AutoconnectCommand::Install { + file, + profile, + force, + }, + } => { + assert!(file.is_none()); + assert_eq!(profile.as_deref(), Some("work")); + assert!(force); + } + _ => panic!("expected autoconnect install command"), + } + } + + #[test] + fn parse_autoconnect_install_requires_exactly_one_source() { + let missing = Cli::try_parse_from(["tunmux", "autoconnect", "install"]); + assert!(missing.is_err()); + + let both = Cli::try_parse_from([ + "tunmux", + "autoconnect", + "install", + "--file", + "/tmp/a.conf", + "--profile", + "a", + ]); + assert!(both.is_err()); + } + + #[test] + fn parse_autoconnect_reload_and_uninstall() { + for (arg, want) in [ + ( + "reload", + std::mem::discriminant(&AutoconnectCommand::Reload), + ), + ( + "uninstall", + std::mem::discriminant(&AutoconnectCommand::Uninstall), + ), + ] { + let cli = + Cli::try_parse_from(["tunmux", "autoconnect", arg]).expect("parse autoconnect"); + match cli.command { + TopCommand::Autoconnect { command } => { + assert_eq!(std::mem::discriminant(&command), want) + } + _ => panic!("expected autoconnect command"), + } + } + } + + #[test] + fn parse_autoconnect_list_and_ls_alias() { + for arg in ["list", "ls"] { + let cli = Cli::try_parse_from(["tunmux", "autoconnect", arg]) + .expect("parse autoconnect list"); + match cli.command { + TopCommand::Autoconnect { command } => assert_eq!( + std::mem::discriminant(&command), + std::mem::discriminant(&AutoconnectCommand::List) + ), + _ => panic!("expected autoconnect list command"), + } + } + } } diff --git a/src/launchctl.rs b/src/launchctl.rs new file mode 100644 index 0000000..421421e --- /dev/null +++ b/src/launchctl.rs @@ -0,0 +1,88 @@ +//! Shared `launchctl`/plist-rendering helpers used by both the privileged +//! (system-domain) launchd daemon installer (`src/launchd.rs`) and the +//! per-user (GUI-domain) autoconnect agent installer (`src/autoconnect.rs`). +//! Generic — no privileged/GUI-domain assumptions. + +use std::fs; +use std::io::ErrorKind; +use std::path::Path; + +use anyhow::Context; + +pub(crate) fn run_checked(program: &str, args: &[&str]) -> anyhow::Result<()> { + let output = std::process::Command::new(program) + .args(args) + .output() + .with_context(|| format!("failed to run {program} {}", args.join(" ")))?; + if !output.status.success() { + anyhow::bail!( + "{program} {} failed ({}): {}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +pub(crate) fn run_ignore_failure(program: &str, args: &[&str]) { + match std::process::Command::new(program).args(args).output() { + Ok(output) if !output.status.success() => { + tracing::debug!( + program, + args = ?args, + status = ?output.status, + stderr = %String::from_utf8_lossy(&output.stderr).trim(), + "launchd_command_ignored_failure" + ); + } + Err(err) => { + tracing::debug!(program, args = ?args, error = %err, "launchd_command_failed_to_run"); + } + Ok(_) => {} + } +} + +pub(crate) fn remove_file_ignore_missing(path: &Path) -> anyhow::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(err).with_context(|| format!("failed to remove {}", path.display())), + } +} + +/// Escape the five XML special characters so a path with e.g. `&` in it +/// still produces a well-formed plist. +pub(crate) fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// Inverse of [`xml_escape`], for reading a value back out of a rendered +/// plist. `&` is decoded last so an escaped `&lt;` round-trips. +pub(crate) fn xml_unescape(s: &str) -> String { + s.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xml_escape_escapes_specials() { + assert_eq!(xml_escape("/a&b/"), "/a&b/<c>"); + } + + #[test] + fn xml_unescape_reverses_escape() { + let raw = "/a&b/\"d'e"; + assert_eq!(xml_unescape(&xml_escape(raw)), raw); + } +} diff --git a/src/launchd.rs b/src/launchd.rs index 1566188..11f89e2 100644 --- a/src/launchd.rs +++ b/src/launchd.rs @@ -6,7 +6,6 @@ //! `uninstall/privileged`. use std::fs; -use std::io::ErrorKind; use std::path::{Path, PathBuf}; use anyhow::Context; @@ -14,6 +13,7 @@ use nix::unistd::{chown, geteuid, Gid, Group, Uid, User}; use crate::cli::LaunchdCommand; use crate::config; +use crate::launchctl::{remove_file_ignore_missing, run_checked, run_ignore_failure, xml_escape}; pub(crate) const LABEL: &str = "me.pansen.tunmux.privileged"; pub(crate) const PLIST_PATH: &str = "/Library/LaunchDaemons/me.pansen.tunmux.privileged.plist"; @@ -70,16 +70,6 @@ fn render_plist_from(template: &str, daemon_binary: &str, gid: u32) -> anyhow::R Ok(rendered) } -/// Escape the five XML special characters so a path with e.g. `&` in it -/// still produces a well-formed plist. -fn xml_escape(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - /// Reject binaries in locations a regular user controls. /// /// The rendered plist makes launchd run this binary as root, so both the @@ -380,48 +370,6 @@ fn bootstrap() -> anyhow::Result<()> { Ok(()) } -fn run_checked(program: &str, args: &[&str]) -> anyhow::Result<()> { - let output = std::process::Command::new(program) - .args(args) - .output() - .with_context(|| format!("failed to run {program} {}", args.join(" ")))?; - if !output.status.success() { - anyhow::bail!( - "{program} {} failed ({}): {}", - args.join(" "), - output.status, - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -fn run_ignore_failure(program: &str, args: &[&str]) { - match std::process::Command::new(program).args(args).output() { - Ok(output) if !output.status.success() => { - tracing::debug!( - program, - args = ?args, - status = ?output.status, - stderr = %String::from_utf8_lossy(&output.stderr).trim(), - "launchd_command_ignored_failure" - ); - } - Err(err) => { - tracing::debug!(program, args = ?args, error = %err, "launchd_command_failed_to_run"); - } - Ok(_) => {} - } -} - -fn remove_file_ignore_missing(path: &Path) -> anyhow::Result<()> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => Err(err).with_context(|| format!("failed to remove {}", path.display())), - } -} - #[cfg(test)] mod tests { use super::*; @@ -477,11 +425,6 @@ mod tests { assert!(err.to_string().contains("Label")); } - #[test] - fn xml_escape_escapes_specials() { - assert_eq!(xml_escape("/a&b/"), "/a&b/<c>"); - } - #[test] fn render_escapes_binary_path() { let rendered = diff --git a/src/lib.rs b/src/lib.rs index 34679d5..54f3142 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,9 +5,11 @@ // config-file (`wgconf`) path. // Provider-agnostic infrastructure +pub mod autoconnect; pub mod cli; pub mod config; pub mod error; +pub mod launchctl; pub mod launchd; pub mod logging; pub mod shared; diff --git a/src/main.rs b/src/main.rs index d82fccc..df822ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ +mod autoconnect; mod cli; mod config; mod error; +mod launchctl; mod launchd; mod logging; mod privileged; @@ -80,6 +82,14 @@ fn main() { } } + TopCommand::Autoconnect { command } => { + init_logging(cli.verbose); + if let Err(e) = autoconnect::dispatch(command) { + error!(command = ?"autoconnect", error = %format!("{e:#}"), "command_failed"); + std::process::exit(1); + } + } + // All other commands use the multi-threaded tokio runtime. other => { init_logging(cli.verbose); @@ -114,6 +124,7 @@ async fn run(command: TopCommand, config: config::AppConfig) -> anyhow::Result<( TopCommand::Status | TopCommand::Wg | TopCommand::Launchd { .. } + | TopCommand::Autoconnect { .. } | TopCommand::Privileged { .. } => { unreachable!() } From fa251f60943dd74242d4324ec95d23615760a723 Mon Sep 17 00:00:00 2001 From: andi Date: Sun, 19 Jul 2026 11:30:12 +0200 Subject: [PATCH 7/7] Review comments --- src/main.rs | 16 +++++++--------- src/privileged_client/mod.rs | 8 ++++++++ src/wireguard/userspace.rs | 8 +++++--- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main.rs b/src/main.rs index df822ed..f3558a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -373,22 +373,20 @@ async fn dispatch_provider_disconnect( } fn cmd_status() -> anyhow::Result<()> { + // Probe liveness across every backend, not just userspace: a reboot/crash + // leaves stale kernel/wg-quick state behind too, and `is_live()` already + // knows the right probe per backend. let mut connections: Vec = ConnectionState::load_all()? .into_iter() - .filter(|c| match c.backend { - wireguard::backend::WgBackend::Userspace => c.is_live(), - _ => true, - }) + .filter(ConnectionState::is_live) .collect(); let have_wgconf_direct = connections .iter() .any(|c| c.provider == "wgconf" && c.interface_name == "wgconf0"); - if !have_wgconf_direct - && privileged_client::PrivilegedClient::new() - .interface_active("wgconf0") - .unwrap_or(false) - { + // Use the higher-level probe (not the raw client call) so a cold-starting + // privileged daemon doesn't make a live wgconf0 tunnel look inactive. + if !have_wgconf_direct && wireguard::userspace::is_interface_active("wgconf0") { connections.push(wireguard::connection::ConnectionState { instance_name: wireguard::connection::DIRECT_INSTANCE.to_string(), provider: "wgconf".to_string(), diff --git a/src/privileged_client/mod.rs b/src/privileged_client/mod.rs index 1bd9cdd..6d0767e 100644 --- a/src/privileged_client/mod.rs +++ b/src/privileged_client/mod.rs @@ -16,6 +16,14 @@ use crate::privileged_api::{GotaTunAction, PrivilegedRequest, PrivilegedResponse use self::transport::{is_transport_error, StdioSession}; use self::util::{build_lease_token, request_kind, resolve_client_authorized_group}; +/// Whether an error from a `PrivilegedClient` call is a transient +/// transport/cold-start error worth retrying, as opposed to an authoritative +/// failure. Exposed for callers outside this module (e.g. +/// `wireguard::userspace`) that implement their own retry/settle loops. +pub(crate) fn is_retryable_transport_error(err: &AppError) -> bool { + is_transport_error(err) +} + pub struct PrivilegedClient { socket_path: PathBuf, transport: PrivilegedTransport, diff --git a/src/wireguard/userspace.rs b/src/wireguard/userspace.rs index b391378..7a3d1fe 100644 --- a/src/wireguard/userspace.rs +++ b/src/wireguard/userspace.rs @@ -1,7 +1,7 @@ use crate::config; use crate::error::Result; use crate::privileged_api::GotaTunAction; -use crate::privileged_client::PrivilegedClient; +use crate::privileged_client::{is_retryable_transport_error, PrivilegedClient}; use tracing::info; use super::handshake; @@ -85,7 +85,7 @@ pub fn is_interface_active(interface_name: &str) -> bool { for attempt in 0..ATTEMPTS { match client.interface_active(interface_name) { Ok(active) => return active, - Err(err) if attempt + 1 < ATTEMPTS => { + Err(err) if attempt + 1 < ATTEMPTS && is_retryable_transport_error(&err) => { tracing::debug!( interface = interface_name, error = %err, @@ -94,10 +94,12 @@ pub fn is_interface_active(interface_name: &str) -> bool { std::thread::sleep(BACKOFF); } Err(err) => { + // Either an authoritative (non-transport) error, or the last + // attempt — report inactive without further retries. tracing::debug!( interface = interface_name, error = %err, - "interface_active probe failed after retries; reporting inactive" + "interface_active probe failed; reporting inactive" ); return false; }