macOS and Linux VM sandbox with a Tailscale network identity. Runs guest VMs using Apple's Virtualization.framework where all networking is routed through a tsnet node on the host. The VM appears on your tailnet as its own device — no Tailscale installed inside the guest.
How it works
┌─────────────────────────────────────────────┐
│ Tailvisor │
│ │
│ Swift (Virtualization.framework) │
│ VM lifecycle, GUI window │
│ macOS or Linux guest │
│ │ │
│ │ VZFileHandleNetworkDevice │
│ │ (Unix dgram socket) │
│ ▼ │
│ Go (linked as c-archive) │
│ ARP / DHCP / DNS / ICMP │
│ gVisor TCP stack → tsnet.Dial │
│ UDP proxy → tsnet.Dial │
│ │ │
└─────────────────────────────────────────────┘
│
│ WireGuard
▼
your tailnet
This is a single binary. The Go networking code is compiled as a C archive
(go build -buildmode=c-archive) and linked into the Swift executable.
The Go layer:
- Connects to Tailscale via
tsnet.Server(each VM is its own Tailscale node) - Reads raw ethernet frames from the VM via a Unix datagram socket
- Handles ARP, DHCP (assigns
192.168.72.2, gateway/DNS at192.168.72.1), and ICMP echo - Forces all VM egress that isn't a direct peer through an auto-selected
exit node on the VM's tailnet (
AutoExitNode = "any"), and pinstsdial.UseNetstackForIPon so no dial can fall back to the host's network stack. - Resolves DNS: Tailscale peer names via
LocalClient().Status(), everything else forwarded through tsnet's MagicDNS (100.100.100.100:53dialed viatsnet.Dial, which is routed through netstack and answered by the VM's own tailnet DNS config) - Intercepts all TCP via a gVisor userspace stack and proxies each connection
through
tsnet.Dial - Proxies UDP similarly
- Provides the tailnet's MagicDNS suffix as a DHCP search domain so bare
peer names resolve (e.g.
ssh myhost)
The VM's tailnet must have at least one usable exit node. If none is available,
AutoExitNodeinstalls a blackhole route and all non-peer traffic from the guest is dropped — the guest can still reach its own tailnet peers but has no internet. To explicitly override this, use --unsafe-allow-host-network
Requirements
- Apple Silicon Mac (arm64) — Virtualization.framework is arm64-only
- macOS 14+ (Sonoma)
- Xcode (for Swift compilation)
- Go 1.24+
Building
# Build the CLI tool make all # Build the GUI tool make app
This runs three steps:
go build -buildmode=c-archive— compiles Go networking code intolibnetd.aswift build— compiles Swift CLI, linkslibnetd.acodesign— signs with the virtualization entitlement
The single binary ends up in bin/tailvisor.
If swift build can't find Xcode, set DEVELOPER_DIR:
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer make all
Swift-only changes don't rebuild the Go archive (Make tracks .go file timestamps).
Usage
macOS VMs
# Create a VM (downloads macOS IPSW automatically on first run, ~13 GB) bin/tailvisor create # Run with a Tailscale auth key on first start bin/tailvisor run --auth-key tskey-auth-... # Later, just: bin/tailvisor run
Linux VMs
# Create a Linux VM (requires an ARM64 installer ISO, e.g. Ubuntu Server) bin/tailvisor create --os linux --name mylinux --iso ~/Downloads/ubuntu-24.04-live-server-arm64.iso # Run — the first boot will start the ISO installer bin/tailvisor run --name mylinux --auth-key tskey-auth-... # After installation, subsequent boots go straight to the installed system bin/tailvisor run --name mylinux
Linux VMs use UEFI boot (VZEFIBootLoader). On first boot with an empty
disk, the firmware boots from the installer ISO (attached as USB mass
storage). After installation, GRUB takes priority in the UEFI boot order
and the installed system boots directly. The installer ISO is
automatically ejected when the guest shuts down, so subsequent boots go
straight to the installed system.
Common options
# Named VMs bin/tailvisor create --name foo bin/tailvisor run --name foo bin/tailvisor ls bin/tailvisor delete --name myvm # Port forwarding bin/tailvisor run --forward 2222:22 # Shared directories (virtiofs) bin/tailvisor run --dir /path/to/share,tag=myshare
The VM window supports full keyboard and trackpad/mouse input. Closing the
window (or Ctrl-C in CLI mode) saves VM state for fast resume on next run.
Shared directories
Shared directories use virtiofs. The mount command differs by guest OS:
macOS guest:
sudo mkdir -p /some/dire mount_virtiofs myshare /some/dir
Note that directories may need to be remounted after a full stop and inodes will go stale after a pause/restart.
Linux guest:
sudo mkdir -p /mnt/myshare sudo mount -t virtiofs myshare /mnt/myshare
VM storage
macOS VM layout
~/VM.bundle/
├── RestoreImage.ipsw # shared macOS restore image
└── myvm/
├── config.json # VM configuration (guestOS: "macos")
├── Disk.img # 128 GB sparse disk
├── AuxiliaryStorage
├── HardwareModel
├── MachineIdentifier
├── SaveFile.vzvmsave # saved state (when stopped)
└── tsnet/ # tsnet state (own Tailscale identity)
└── tailscaled.state
Linux VM layout
~/VM.bundle/
├── LinuxInstaller.iso # shared Linux installer ISO
└── mylinux/
├── config.json # VM configuration (guestOS: "linux")
├── Disk.img # 64 GB sparse disk
├── EFIVariableStore # UEFI NVRAM
├── SaveFile.vzvmsave # saved state (when stopped)
└── tsnet/ # tsnet state (own Tailscale identity)
└── tailscaled.state
Network details
| Item | Value |
|---|---|
| VM IP | 192.168.72.2 |
| Gateway / DNS | 192.168.72.1 |
| Subnet | 192.168.72.0/24 |
| Router MAC | 52:ee:ee:ee:ee:01 |
| DNS search domain | your tailnet's MagicDNS suffix |
- TCP: intercepted via gVisor, proxied through
tsnet.Dial("tcp", ...)(routed via the VM's own WireGuard tunnel — to a peer if it matches, or otherwise via the auto-selected exit node; never via the host stack) - UDP: proxied through
tsnet.Dial("udp", ...)on the same routing - DNS: Tailscale peers resolved from status; everything else forwarded
to
100.100.100.100:53viatsnet.Dial, handled by tsnet's netstack MagicDNS and scoped to the VM's tailnet DNS config - ICMP: echo reply for the gateway only; ping to external hosts is not proxied
- IPv6: not yet supported
Architecture
The Go networking code lives in netd/ and is compiled as a C archive
(-buildmode=c-archive), producing libnetd.a. This is linked into the
Swift executable via a SPM C target (CNetd). Three functions are exported
across the boundary:
goNetdStart(socketPath, tsnetDir, hostname, authKey, ephemeral)— starts the networking daemon in a background goroutinegoNetdStop()— shuts it downgoNetdReady()— returns 1 when the socket is listening
The Swift side (swift/) handles VM lifecycle via Virtualization.framework,
the GUI window with VZVirtualMachineView, and the CLI via
swift-argument-parser.