diff --git a/Makefile b/Makefile
index bc55860..93fa446 100644
--- a/Makefile
+++ b/Makefile
@@ -16,39 +16,14 @@ 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
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
@@ -57,7 +32,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:
@@ -65,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
@@ -74,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:
@@ -93,11 +66,19 @@ uninstall/dns:
sudo killall -HUP mDNSResponder
.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
+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` 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 /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
@@ -106,6 +87,9 @@ uninstall/privileged:
.PHONY: uninstall
uninstall: uninstall/autostart uninstall/privileged uninstall/dns
+.PHONY: purge
+purge: uninstall purge/privileged
+
.PHONY: check/privileged
check/privileged:
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.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/etc/me.pansen.tunmux.privileged.plist b/etc/me.pansen.tunmux.privileged.plist
index 4410629..4150dd9 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,7 @@
SockPathName
/Library/Application Support/tunmux/run/ctl.sock
-
+
SockPathMode
432
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 3e4ca24..0a7d1e5 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,18 @@ pub enum TopCommand {
command: HookCommand,
},
+ /// Manage the privileged launchd daemon (system domain)
+ Launchd {
+ #[command(subcommand)]
+ command: LaunchdCommand,
+ },
+
+ /// Manage the per-user autoconnect LaunchAgent (GUI domain)
+ Autoconnect {
+ #[command(subcommand)]
+ command: AutoconnectCommand,
+ },
+
/// Internal privileged service mode (hidden)
#[command(hide = true)]
Privileged {
@@ -103,6 +116,57 @@ 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(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,
@@ -200,8 +264,8 @@ pub enum WgconfCommand {
#[cfg(test)]
mod tests {
use super::{
- Cli, ConnectProviderCommand, HookBuiltinArg, HookCommand, ProviderArg, TopCommand,
- WgconfCommand,
+ AutoconnectCommand, Cli, ConnectProviderCommand, HookBuiltinArg, HookCommand,
+ LaunchdCommand, ProviderArg, TopCommand, WgconfCommand,
};
use clap::Parser;
@@ -377,4 +441,162 @@ 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"),
+ }
+ }
+
+ #[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 `<` 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
new file mode 100644
index 0000000..11f89e2
--- /dev/null
+++ b/src/launchd.rs
@@ -0,0 +1,520 @@
+//! 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::path::{Path, PathBuf};
+
+use anyhow::Context;
+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";
+
+/// 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 = "@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 {BIN_PLACEHOLDER} placeholder");
+ }
+
+ let marker_line = template
+ .lines()
+ .find(|line| line.contains(SOCK_GROUP_MARKER))
+ .ok_or_else(|| {
+ 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 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)
+}
+
+/// 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: &Path,
+ resolved: &Path,
+ invoking_user_home: Option<&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/",
+ "/var/tmp/",
+ "/private/var/tmp/",
+];
+
+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 ({}); \
+ 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")?;
+
+ // Validate everything that can refuse the install BEFORE mutating any
+ // system state (group creation, membership, directories, plist).
+ let user = invoking_user()?;
+ let bin = daemon_binary_path()?;
+ 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) => fs::read_to_string(path)
+ .with_context(|| format!("failed to read plist template {}", path.display()))?,
+ None => PLIST_TEMPLATE.to_string(),
+ };
+ // 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()?;
+
+ 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.
+ let _ = 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 purge/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().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)
+}
+
+/// 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` 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;
+
+ 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
+/// 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(())
+}
+
+#[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(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 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]
+ 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_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());
+ }
+
+ #[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..54f3142 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -5,9 +5,12 @@
// 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 d6dfb55..f3558a0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,9 @@
+mod autoconnect;
mod cli;
mod config;
mod error;
+mod launchctl;
+mod launchd;
mod logging;
mod privileged;
mod privileged_api;
@@ -71,6 +74,22 @@ fn main() {
}
}
+ TopCommand::Launchd { command } => {
+ init_logging(cli.verbose);
+ if let Err(e) = launchd::dispatch(command) {
+ error!(command = ?"launchd", error = %format!("{e:#}"), "command_failed");
+ std::process::exit(1);
+ }
+ }
+
+ 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);
@@ -102,7 +121,11 @@ 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::Autoconnect { .. }
+ | TopCommand::Privileged { .. } => {
unreachable!()
}
}
@@ -350,33 +373,81 @@ async fn dispatch_provider_disconnect(
}
fn cmd_status() -> anyhow::Result<()> {
- let connections = ConnectionState::load_all()?;
+ // 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(ConnectionState::is_live)
+ .collect();
+
+ let have_wgconf_direct = connections
+ .iter()
+ .any(|c| c.provider == "wgconf" && c.interface_name == "wgconf0");
+ // 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(),
+ 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", "Endpoint", "Backend"];
+ let rows: Vec<[String; 5]> = connections
+ .iter()
+ .map(|conn| {
+ [
+ conn.instance_name.clone(),
+ conn.provider.clone(),
+ conn.server_display_name.clone(),
+ conn.server_endpoint.clone(),
+ 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/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/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.");
}
diff --git a/src/wireguard/userspace.rs b/src/wireguard/userspace.rs
index 7d5aa81..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;
@@ -71,7 +71,39 @@ 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 && is_retryable_transport_error(&err) => {
+ tracing::debug!(
+ interface = interface_name,
+ error = %err,
+ "interface_active probe errored; retrying (daemon cold start?)"
+ );
+ 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; reporting inactive"
+ );
+ return false;
+ }
+ }
+ }
+ false
}