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.
┌─────────────────────────────────────────────┐
│ 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:
tsnet.Server (each VM is its own Tailscale node)192.168.72.2, gateway/DNS at 192.168.72.1),
and ICMP echoAutoExitNode = "any"), and pins
tsdial.UseNetstackForIP on so no dial can fall back to the host's
network stack.LocalClient().Status(), everything
else forwarded through tsnet's MagicDNS (100.100.100.100:53 dialed via
tsnet.Dial, which is routed through netstack and answered by the VM's
own tailnet DNS config)tsnet.Dialssh 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
# 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 into libnetd.aswift build — compiles Swift CLI, links libnetd.acodesign — signs with the virtualization entitlementThe 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).
# 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
# 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.
# 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 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.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
~/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
| 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 |
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)tsnet.Dial("udp", ...) on the same routing100.100.100.100:53 via tsnet.Dial, handled by tsnet's netstack
MagicDNS and scoped to the VM's tailnet DNS configThe 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 listeningThe Swift side (swift/) handles VM lifecycle via Virtualization.framework,
the GUI window with VZVirtualMachineView, and the CLI via
swift-argument-parser.
Swift
64.3%
Go
32.4%
Makefile
2.1%
C
1.2%
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.
┌─────────────────────────────────────────────┐
│ 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:
tsnet.Server (each VM is its own Tailscale node)192.168.72.2, gateway/DNS at 192.168.72.1),
and ICMP echoAutoExitNode = "any"), and pins
tsdial.UseNetstackForIP on so no dial can fall back to the host's
network stack.LocalClient().Status(), everything
else forwarded through tsnet's MagicDNS (100.100.100.100:53 dialed via
tsnet.Dial, which is routed through netstack and answered by the VM's
own tailnet DNS config)tsnet.Dialssh 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
# 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 into libnetd.aswift build — compiles Swift CLI, links libnetd.acodesign — signs with the virtualization entitlementThe 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).
# 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
# 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.
# 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 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.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
~/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
| 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 |
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)tsnet.Dial("udp", ...) on the same routing100.100.100.100:53 via tsnet.Dial, handled by tsnet's netstack
MagicDNS and scoped to the VM's tailnet DNS configThe 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 listeningThe Swift side (swift/) handles VM lifecycle via Virtualization.framework,
the GUI window with VZVirtualMachineView, and the CLI via
swift-argument-parser.
Swift
64.3%
Go
32.4%
Makefile
2.1%
C
1.2%