A cross-platform C# / Avalonia client for Tailscale that drives the locally-
installed tailscaled daemon via its LocalAPI. No re-implementation of the
WireGuard data plane — just a GUI shell that talks to the official daemon.
Runs on Windows, macOS, and Linux from a single net9.0 build.
┌──────────────────────────┐ HTTP over per-platform transport ┌────────────────────┐
│ TailscaleClient.UI │ ─────────────────────────────────▶ │ tailscaled │
│ Avalonia 11, .NET 9 │ Win: named pipe + impersonation │ (system service / │
│ │ macOS: TCP + sameuserproof token │ App Store app) │
│ │ Linux: Unix domain socket │ │
└──────────────────────────┘ └────────────────────┘
- .NET 9 SDK
- Official Tailscale installed and running:
- Windows —
tailscaled.exeWindows service - macOS — Tailscale.app from the App Store (preferred) or self-built
tailscaled - Linux —
tailscaledstarted via systemd (sudo systemctl start tailscaled)
- Windows —
| Project | Purpose |
|---|---|
src/TailscaleClient.Core |
LocalAPI client, DTOs, IPN bus event stream |
src/TailscaleClient.UI |
Avalonia UI, MVVM, tray, services |
tests/TailscaleClient.Smoke |
Console end-to-end probe against tailscaled |
dotnet build TailscaleClient.slnx
dotnet run --project src/TailscaleClient.UI # GUI
dotnet run --project tests/TailscaleClient.Smoke # smoke
dotnet run --project tests/TailscaleClient.Smoke -- --watch # stream IPN bus
dotnet run --project tests/TailscaleClient.Smoke -- --ping 100.x # diagnose pingdotnet publish src/TailscaleClient.UI -r osx-arm64 -c Release \
-p:PublishSingleFile=true --self-contained trueApple Silicon only — Intel Macs aren't built in CI. If you need an Intel build,
swap osx-arm64 for osx-x64 and build from source.
For a proper double-clickable .app bundle, wrap the published binary with
dotnet-bundle or hand-author
Contents/Info.plist.
dotnet publish src/TailscaleClient.UI -r linux-x64 -c ReleaseYou'll likely need to be a member of the group that owns
/var/run/tailscale/tailscaled.sock (usually tailscale), or run the binary
with sudo.
- Connection state (Connected / Disconnected / NeedsLogin) with live IPN-bus updates
- Sign in / log out (opens auth URL in browser)
- Connect / disconnect (toggles
WantRunningviaMaskedPrefs) - Tailnet device list with online state, IP, OS, last-seen, ping, copy IP
- Exit node picker + "Don't route LAN through exit node" toggle
- MagicDNS / Accept-routes / Shields-up / Tailscale-SSH toggles
- Advertise subnet routes
- Taildrop: list / save / delete received files, send to a peer (via
StorageProvider) - System-tray icon with state color + context menu, close-to-tray
Transport layer (LocalApiHttpFactory.cs)
A single factory picks the right transport per OS:
| Platform | Path | Authentication |
|---|---|---|
| Windows | named pipe \\.\pipe\ProtectedPrefix\Administrators\Tailscale\tailscaled |
TokenImpersonationLevel.Impersonation on the client pipe |
| macOS (App Store / sys-extension) | /Library/Tailscale/sameuserproof-{port}-{token} → 127.0.0.1:{port} |
HTTP Basic with empty user + token as password |
| macOS (standalone) | Unix socket /var/run/tailscaled.socket |
OS-level peer creds |
| Linux | Unix socket /var/run/tailscale/tailscaled.sock |
OS-level peer creds |
The non-obvious Windows detail: opening the pipe with the default Anonymous
impersonation level causes tailscaled's ImpersonateNamedPipeClient to fail
with 401 authentication failed: Unable to impersonate using a named pipe….
Use TokenImpersonationLevel.Impersonation. The pipe ACL itself is permissive —
no Administrator elevation needed.
LocalApiClient.WatchIpnBusAsync
returns an IAsyncEnumerable<IpnNotify> reading line-delimited JSON from
/localapi/v0/watch-ipn-bus. TailscaleService runs this on a background task,
reconnects on failure, and raises INotifyPropertyChanged for the UI to bind to.
PATCH /localapi/v0/prefs requires a MaskedPrefs body with a *Set mask
bit beside every value. Strongly-typed factories in
MaskedPrefs.cs (SetWantRunning,
SetExitNode, …) prevent the easy mistake of changing a value without flipping
its mask.
.github/workflows/build.yml runs on every
push and PR to main and on v* tags:
windows-latest→TailscaleClient-{ver}-win-x64.zipmacos-latest(Apple Silicon) →TailscaleClient-{ver}-osx-arm64.zip(a.appbundle)
All builds are self-contained single-file (no .NET runtime install needed on the
target machine). The macOS bundle is ad-hoc-signed so it launches without the
right-click-Open Gatekeeper dance, but it's not notarized — push it through
codesign --force --deep --sign 'Developer ID Application: …' and
xcrun notarytool submit for real distribution.
Cut a release with:
git tag v0.1.0 && git push origin v0.1.0The workflow attaches both zips to a GitHub release tagged that version.
- Single profile only — no multi-user
Switch profileUI - No Tailnet Lock / TKA UI
- No Taildrive
DriveShareseditor - No traffic chart; we only display
Engine.RxBytes/TxBytesaggregates - macOS tray icon is a colored circle (no template-image variant for menu-bar dark/light)