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>
This commit is contained in:
1 parent
533921059a
commit
c3a6201ee2
16 files changed
+1349
-22
No files matched your search
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
# 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`
|
||||
|
||||
```sh
|
||||
sudo tsunagi up
|
||||
```
|
||||
|
||||
In another terminal, the control commands talk to it over the system control
|
||||
socket and do **not** need `sudo`:
|
||||
|
||||
```sh
|
||||
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`](cy.hexor.tsunagi.plist).
|
||||
|
||||
```sh
|
||||
# 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:
|
||||
|
||||
```sh
|
||||
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.
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
LaunchDaemon for the tsunagi agent.
|
||||
|
||||
A daemon, so it runs as root (creating a utun interface and writing
|
||||
/etc/resolver both need it) and comes up at boot before anyone logs in.
|
||||
Install it at /Library/LaunchDaemons/cy.hexor.tsunagi.plist, owned by
|
||||
root:wheel with mode 0644 — launchd refuses a daemon plist that is
|
||||
group- or world-writable. See README.md in this directory.
|
||||
|
||||
Edit the binary path if tsunagi is not at /usr/local/bin/tsunagi.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>cy.hexor.tsunagi</string>
|
||||
|
||||
<!-- `up` starts the agent and keeps it in the foreground, which is what a
|
||||
launchd daemon expects: launchd owns the lifecycle, so the process
|
||||
must not daemonise itself. -->
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/tsunagi</string>
|
||||
<string>up</string>
|
||||
</array>
|
||||
|
||||
<!-- Start at boot and bring it back if it ever exits. -->
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
|
||||
<!-- Back off rather than spin if it fails to start repeatedly. -->
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
|
||||
<!-- Logs. launchd creates these as root; rotate them with newsyslog if you
|
||||
keep the daemon around. -->
|
||||
<key>StandardOutPath</key>
|
||||
<string>/var/log/tsunagi.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/var/log/tsunagi.log</string>
|
||||
|
||||
<!-- The agent uses the fixed macOS system paths by default:
|
||||
state /var/db/tsunagi
|
||||
socket /var/run/tsunagi/agent.sock
|
||||
so nothing here has to point at a user's home directory. -->
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in new issue
Block a user