Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 78 additions & 216 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,260 +1,122 @@
# tunmux

`tunmux` is a WireGuard config-file VPN CLI written in Rust for **macOS**. It
connects from a standard WireGuard `.conf` file (or a saved profile) and routes
host traffic through the tunnel (direct mode).
`tunmux` is a command-line WireGuard VPN client for macOS, written in Rust.

## What It Does
## Why

- Connect/disconnect from WireGuard `.conf` files and saved profiles
- Save, list, and remove reusable profiles
- Run connect/disconnect hooks (connectivity, external-IP, DNS-leak checks)
- Support multiple WireGuard backends: `userspace` (default), `wg-quick`, `kernel`
Install once, forget about it.

On macOS there is no in-kernel WireGuard, so every backend runs on the embedded
`gotatun` userspace engine through a built-in helper; no separate `gotatun` CLI
install is required. The `kernel` backend brings the tunnel up from a regenerated
minimal config, while `userspace`/`wg-quick` use the `.conf` as-is.
tunmux is built for one main use case: a split tunnel that permanently connects
your workstation to "home" — your LAN, your servers, your internal DNS — as a
launchd daemon that is simply always there and never gets in the way. You run
`make install` once; from then on the tunnel comes up at login, survives
network roaming and sleep/wake, and continuously reconciles routes and DNS
against whatever network you are currently on.

## Platform And Requirements
Because reconciliation is continuous, the tunnel does not need the conditional
on/off triggers ("On-Demand" rules, location-based activation) that split
tunnels usually require. It stays up everywhere and adapts instead of asking.
It is meant as a frictionless, dependable alternative to `WireGuard.app` from
the Apple App Store, for people who would rather manage the tunnel from the
command line — or not manage it at all.

- macOS (Apple Silicon or Intel)
- Rust (stable, edition 2021)
- `sudo` access for privileged operations (`tunmux privileged --serve`)
## Origins

The privileged service runs as root and performs the operations that need
elevated permissions (bringing tunnels up/down, reading the WireGuard control
socket). It can be started on demand (autostart) or via `launchd` socket
activation (see `etc/me.pansen.tunmux.privileged.plist`).
This project is a fork of [CaddyGlow/tunmux](https://github.com/CaddyGlow/tunmux).
Thanks to the original author, who pursued a different goal with the project —
it serves here as the technical base.

## Build
## Install

```bash
cargo build
```

To catch formatting issues before they hit CI, install the repo's git hooks once per clone:

```bash
make hooks
```

This points `core.hooksPath` at `scripts/hooks`, which runs `cargo fmt --all --check` on commit.

## Release CI (Tag-Based)

Pushing a `v*` tag (for example `v1.2.3`) triggers `.github/workflows/release.yml` to:
- run `cargo test --locked`
- build release binaries for macOS targets via `.github/workflows/manual-build.yml`
- upload tarballs and SHA256 files to a GitHub Release for that tag

Binary version output follows the tag in CI builds:

```bash
tunmux --version
```

## Quick Start

Connect from a config file, check status, disconnect:

```bash
tunmux connect wgconf --file ./my-tunnel.conf
tunmux status
tunmux disconnect --provider wgconf
```

Choose a backend (default is `userspace`):

```bash
tunmux connect wgconf --file ./my-tunnel.conf --backend wg-quick
```

Before testing the userspace data plane, disable WireGuard.app On-Demand and
deactivate matching tunnels. Verify `scutil --nc list` has no connected
`com.wireguard.macos` entry.

## Command Map

Top-level commands:

```bash
tunmux status
tunmux connect wgconf [flags]
tunmux disconnect [instance] [--provider wgconf] [--all]
tunmux wg
tunmux hook run <connectivity|external-ip|dns-detection>
tunmux hook debug [instance] [--provider wgconf] [--event ifup|ifdown]
tunmux wgconf <...>
make install TUNMUX_PROFILE=/path/to/your.conf
```

`wgconf` flows:

```bash
tunmux connect wgconf --file ./my-tunnel.conf --backend wg-quick
tunmux connect wgconf --file ./my-tunnel.conf --save-as office
tunmux connect wgconf --profile office
tunmux connect wgconf --file ./ipv4-only.conf --backend kernel --disable-ipv6
tunmux connect wgconf --file ./my-tunnel.conf --backend kernel --mtu 1280
tunmux wgconf save --file ./my-tunnel.conf --name backup
tunmux wgconf list
tunmux wgconf remove backup
tunmux wgconf status
tunmux disconnect --provider wgconf
```
This does three things:

Both the top-level form (`tunmux connect wgconf ...`) and the provider-prefixed
form (`tunmux wgconf connect ...`) are supported.
- Builds the release binary and installs it to `/usr/local/bin/tunmux`.
- Registers a **privileged launchd daemon** (socket-activated, runs as root)
that performs the operations needing elevation: bringing tunnels up and
down and talking to the WireGuard control interface. It starts on demand
and idles otherwise.
- Registers a **per-user login agent** that connects your profile at login
and re-checks every 60 seconds. The connect is idempotent: if the tunnel
is already up it is a no-op, if it dropped it is brought back.

Disconnect semantics:

```bash
tunmux disconnect --all # all active connections
tunmux disconnect --provider wgconf --all # wgconf only
tunmux disconnect <instance> # exact instance
tunmux disconnect --provider wgconf # provider-scoped single/list behavior

# short forms
tunmux disconnect -a
tunmux disconnect -p wgconf -a
```
Access to the privileged daemon is limited to a dedicated `tunmux` group,
which the install creates and adds you to (a re-login may be needed for the
membership to take effect).

Common short forms:
- `connect`: `-b` (backend)
- `disconnect`: `-p` (provider), `-a` (all)
After that there is nothing to babysit. `make uninstall` removes everything
cleanly, including any DNS override.

Use verbose logs when needed:
## What It Does While You Forget About It

```bash
tunmux -v connect wgconf --file ./my-tunnel.conf
tunmux --debug wgconf disconnect
RUST_LOG=debug tunmux disconnect --all
```
- Keeps the tunnel connected across network roaming (Wi-Fi → Ethernet,
Wi-Fi A → Wi-Fi B) and sleep/wake, without a manual reconnect.
- Continuously reconciles **routes** against the live network: tunnel routes
that would hijack the currently active LAN are dropped, so the split tunnel
behaves correctly whether you are at home, in the office, or tethered.
- Reconciles **DNS** as well as it can, so lookups for your internal names
keep resolving through the tunnel as the network underneath changes.
- Can confirm that a connection is doing its job: basic connectivity over
IPv4 and IPv6, traffic actually leaving through the tunnel, and DNS not
leaking to other resolvers.

Force or disable ANSI color in logs:
## How It Works

```bash
TUNMUX_LOG_COLOR=always tunmux status
TUNMUX_LOG_COLOR=never tunmux status
```
macOS has no in-kernel WireGuard. Every backend therefore runs on a bundled
userspace WireGuard engine, [gotatun](https://github.com/mullvad/gotatun),
through a built-in helper, so there is nothing extra to install.

## Flags
tunmux is split into two parts. The command you run as your normal user handles
configuration and status. The separate privileged daemon, running as root,
performs the operations that need elevated permissions. Running a root service
is a real privilege boundary, so it is kept small and does only the operations
that require it.

`--disable-ipv6` is accepted only for the `kernel` backend, and only when the
selected WireGuard config has no IPv6 interface address.
Only one connection is active at a time. While it is up, routing and DNS
follow the current network.

`--mtu` applies to the `kernel` and `userspace` backends. `wgconf` reads `MTU =`
from `[Interface]`; an explicit `--mtu` overrides it.
## Backends

`--if-missing` exits `0` without reconnecting when the same source is already the
live tunnel; a different live source still errors.
There are three ways to bring the tunnel up:

## Direct Mode Details
- `userspace` (default) and `wg-quick` use your config as written.
- `kernel` brings the tunnel up from a regenerated minimal config.

- one direct connection is active at a time
- host traffic is routed through that WireGuard tunnel
- stored internally as `_direct` connection state
- routing and DNS adapt live to network changes (roam, suspend/resume) without a
reconnect
All three run on the same embedded userspace engine; the backend only changes
how the tunnel is set up.

## Configuration

`tunmux` reads optional defaults from:
tunmux reads optional defaults from `$XDG_CONFIG_HOME/tunmux/config.toml` (typically `~/.config/tunmux/config.toml`). The file is optional; without it, sensible defaults apply. It covers the default backend, the optional checks, and how the privileged daemon is started and stopped. Anything set in the config can be overridden per command on the command line.

`~/.config/tunmux/config.toml`
## Running Alongside the WireGuard App

Example:
Do not run tunmux at the same time as the official WireGuard app with On-Demand
enabled for the same tunnel. The two will compete over the connection. Turn off
On-Demand and deactivate matching tunnels in the app first.

```toml
[general]
backend = "userspace" # userspace (default), wg-quick, or kernel
hooks = { ifup = ["builtin:connectivity", "builtin:external-ip"], ifdown = [] }
privileged_transport = "socket" # socket or stdio
privileged_autostart = true
privileged_autostart_timeout_ms = 5000
privileged_authorized_group = "tunmux"
privileged_autostop_mode = "never" # never, command, timeout
privileged_autostop_timeout_ms = 30000
## Requirements

[wgconf]
hooks = { ifup = [], ifdown = [] }
```
- macOS on Apple Silicon.
- A stable Rust toolchain to build from source.
- `sudo` access for the install and privileged operations.

CLI flags override config values.

Hook behavior:
- `general.hooks` runs first, then `wgconf` hooks run after it.
- `ifup` runs after successful connect; `ifdown` runs after successful disconnect.
- Built-ins are opt-in via hook entries:
- `builtin:connectivity`: ping IPv4 (`1.1.1.1`) and IPv6 (`2606:4700:4700::1111`)
- `builtin:external-ip`: fetch external IP via `https://ipinfo.io` and `https://v6.ipinfo.io`
- `builtin:dns-detection`: query `https://<random>-<n>.ipleak.net/dnsdetection/`
with a 40-char random host label and incrementing probe number (`-1` to `-10`),
then run reverse DNS lookup for each recovered resolver IP
- Hook commands run with env vars such as `TUNMUX_HOOK_EVENT`, `TUNMUX_PROVIDER`,
`TUNMUX_INSTANCE`, `TUNMUX_BACKEND`, `TUNMUX_INTERFACE`, `TUNMUX_SERVER`,
`TUNMUX_ENDPOINT`, plus `TUNMUX_DNS_SERVERS` when VPN DNS servers are known.
- `dns-detection` reverse DNS lookup prefers VPN-configured DNS servers first.
- Manual builtin checks: `tunmux hook run connectivity`, `tunmux hook run external-ip`,
or `tunmux hook run dns-detection`.
- Debug helper: `tunmux hook debug <instance>` prints the exact env payload used for hooks
(`--event ifup|ifdown`, default `ifup`).

## Privileged Service

Privileged operations are handled by:
## Building

```bash
sudo tunmux privileged --serve --authorized-group <group>
cargo build
```

Supported transports:
- `socket` (default): Unix socket control channel (`/Library/Application Support/tunmux/run/ctl.sock`)
- `stdio`: one-shot helper process over stdin/stdout

Autostart can launch the privileged service when needed (if enabled in config),
or `launchd` can socket-activate it via `etc/me.pansen.tunmux.privileged.plist`.
## Development

Example sudoers entries (adjust binary path for your install):
The repository includes git hooks that check formatting before each commit.
Enable them once per clone:

```bash
<user-or-group> ALL=(root) NOPASSWD: /usr/local/bin/tunmux privileged --serve --authorized-group tunmux
<user-or-group> ALL=(root) NOPASSWD: /usr/local/bin/tunmux privileged --serve --autostarted --authorized-group tunmux
<user-or-group> ALL=(root) NOPASSWD: /usr/local/bin/tunmux privileged --serve --autostarted --authorized-group tunmux --idle-timeout-ms *
```

For stdio mode:

```bash
<user-or-group> ALL=(root) NOPASSWD: /usr/local/bin/tunmux privileged --serve --stdio --autostarted --authorized-group tunmux
```

## Data Layout

User data under `~/.config/tunmux/`:

```text
~/.config/tunmux/
config.toml
connections/
_direct.json
<instance>.json
wgconf/
profiles/
<name>.conf
```

Privileged runtime state:

```text
/Library/Application Support/tunmux/
run/
ctl.sock
wg/
<provider>/<iface>.conf

/var/log/tunmux/
<iface>.log
make hooks
```

## License
Expand Down