Files
tsunagi/dist/macos/README.md
T
UltradesuandClaude Opus 4.8 c3a6201ee2 Bring the macOS stack up: utun interface, resolver files, system paths
macOS was TUN-less: the agent ran only with `--no-tun`, so tunnels formed but
nothing reached the operating system. The provisioner, host rules and DNS
publisher had Linux and Windows implementations and fell through to the
unsupported/erroring paths on macOS. This adds the macOS half so `tsunagi up`
carries real traffic there. The shared decision logic (plan_changes,
rules_summary, Published) is untouched — only the per-platform mechanics are new.

What it does on macOS (all root-only; macOS has no per-capability grant):
- Interface: a `utun` device opened over the in-kernel utun control socket via
  the existing `tun` dependency (no kext, no Network Extension). The kernel
  assigns the `utunN` name, so a name derived from the network id is not a legal
  utun name; `open_tun` leaves the name unset on macOS and reads the real one
  back, and `Interface::start` adopts `device.name()` as the name everything
  downstream configures (host rules, reconfigure). A utun is torn down with the
  process, so a crash never leaks one. Addresses, MTU, up and the overlay subnet
  route are applied with `/sbin/ifconfig` and `/sbin/route` (absolute paths, args
  built only from locally-derived values), the same role netlink plays on Linux
  and netsh on Windows. Observation is `netdev` (getifaddrs).
- DNS: a resolver-dir publisher writes `/etc/resolver/<network>` files pointing
  the system resolver at the overlay DNS server for each zone. The files carry a
  `port`, so the server needs no privileged socket, and a marker line, so the
  publisher only ever replaces or removes files it wrote and never clobbers a
  user's own. Removed on revert.
- Broadcast host rules: the `255.255.255.255` route is installed with `route`.
  The inbound-UDP firewall allowance (a pf anchor) is deferred and reported as
  the incomplete half, so `status` says what to allow by hand; the route still
  goes in. Failure stays contained, exactly as on the other platforms.
- Privilege: probe is optimistic (like Windows); the honest answer comes from
  the utun open, whose permission error now says to run with sudo or as a daemon.

Deployment:
- Default paths on macOS are fixed system locations rather than per-user Library
  dirs, so they do not depend on whose shell ran sudo: state `/var/db/tsunagi`,
  cache `/var/db/tsunagi/cache` (StoragePaths::user_default), control socket
  `/var/run/tsunagi/agent.sock` (default_control_socket_path, mirroring the
  Windows %ProgramData% branch). The local control socket already uses the
  shared `unix` adapter, so no IPC changes were needed — macOS is Unix.
- dist/macos/ carries a root LaunchDaemon plist and install notes; README's
  deployment section now documents the macOS path instead of "not implemented".

Decisions (agreed up front): utun auto-assign with the real name adopted;
ifconfig/route rather than raw PF_ROUTE/ioctl (which would need unsafe);
/etc/resolver files rather than scutil; route-now/firewall-deferred; root via
sudo or a LaunchDaemon rather than Network Extension (which would require a
signed sandboxed app bundle, not a Rust CLI).

Checks: on macOS (Darwin 27), `cargo fmt --all --check`, `cargo clippy --locked
--workspace --all-targets -D warnings`, and `cargo test --locked --workspace
--all-targets` all pass (0 failures); the new macos-gated unit tests run under
the standard workspace command via feature unification. Not yet exercised live:
actual utun creation, ifconfig/route application and /etc/resolver writes need a
root run, which has not been done in this session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 11:15:59 +01:00

3.0 KiB

Running tsunagi on macOS

The agent needs root on macOS: creating a utun interface and writing /etc/resolver are both root-only, and macOS has no per-capability grant like Linux. There are two ways to run it.

Quick start — sudo

sudo tsunagi up

In another terminal, the control commands talk to it over the system control socket and do not need sudo:

tsunagi status
tsunagi join <network-name> <tsn1…secret>

The agent uses fixed system paths, so the commands find it regardless of whose shell started sudo:

what path
mandatory state /var/db/tsunagi
disposable cache /var/db/tsunagi/cache
control socket /var/run/tsunagi/agent.sock

Native deployment — root LaunchDaemon

For an always-on agent that starts at boot, install the bundled cy.hexor.tsunagi.plist.

# Put the binary where the plist expects it (edit the plist for another path).
sudo install -m 0755 target/release/tsunagi /usr/local/bin/tsunagi

# Install the daemon. launchd refuses a daemon plist that is not owned by
# root:wheel or that is group/world-writable.
sudo install -m 0644 -o root -g wheel \
    dist/macos/cy.hexor.tsunagi.plist \
    /Library/LaunchDaemons/cy.hexor.tsunagi.plist

# Load and start it.
sudo launchctl bootstrap system /Library/LaunchDaemons/cy.hexor.tsunagi.plist
sudo launchctl enable system/cy.hexor.tsunagi

Manage it afterwards with:

sudo launchctl kickstart -k system/cy.hexor.tsunagi   # restart
sudo launchctl bootout system/cy.hexor.tsunagi        # stop and unload
tail -f /var/log/tsunagi.log                          # logs

The control commands (status, join, network, dns) work the same against the daemon as against a sudo tsunagi up, over the same socket.

What the agent does to the system

Everything it installs is tagged to its own interface, computed from local state, and reversible — it is removed when a network leaves, broadcast is turned off, or the agent stops:

  • a utun interface, configured with ifconfig (addresses, MTU, up) and a subnet route for the overlay range. The kernel assigns the utunN name; the agent adopts whatever it is given. A utun is torn down with the process, so a crash never leaves one behind.
  • /etc/resolver/<network> files pointing the system resolver at the overlay DNS server for each network's zone, with a port so the server stays off 53. Each file carries a marker line; the agent only ever replaces or removes files it wrote, never one you created by hand.
  • while broadcast is on, a 255.255.255.255 route through the overlay interface. The matching inbound-UDP firewall allowance is not implemented yet (it needs a pf anchor); tsunagi status reports that half as incomplete and names what to allow by hand if your host firewall drops discovery replies.

Nothing taken from a remote peer ever becomes a path, a command argument or an OS setting: the interface name comes from the kernel, addresses and ranges from signed state the agent already verified.