Status: Implemented
Date: 2026-07-23
tailmix should manage its configured profile set through a daemon-owned local
control API. The tailmix CLI should use that API for profile lifecycle
commands while continuing to send profile-local Tailscale commands to the
selected profile's existing LocalAPI socket.
The proposed command shape is:
tailmix profiles list
tailmix profiles show <name>
tailmix profiles add <name> [options]
tailmix profiles rename <name> <new-name>
tailmix profiles set <name> [options]
tailmix profiles enable <name>
tailmix profiles disable <name>
tailmix profiles restart <name>
tailmix profiles remove <name> [--purge --yes]
tailmix routes list [--available]
tailmix routes bind --profile <name> <prefix>...
tailmix routes unbind <prefix>...
tailmix routes set --profile <name> --accept-all=<true|false>
tailmix exit-node list [--filter <country>]
tailmix exit-node set --profile <name> <peer>
tailmix exit-node clear
tailmix dns routes list [--available]
tailmix dns routes bind --profile <name> <domain>...
tailmix dns routes unbind <domain>...
tailmix dns routes set --profile <name> --accept-all=<true|false>
tailmix dns search list
tailmix dns search set <domain>...
tailmix dns search add <domain>...
tailmix dns search remove <domain>...
tailmix dns search clear
Profile-local Tailscale commands have their own explicit namespace and select a profile by option:
tailmix tailscale --profile work status
tailmix ts --profile home ping peer.home.example
ts is an exact shortcut for tailscale; neither form changes the delegated
arguments or behavior.
Profile changes are persisted and applied to the running daemon. Restarting one profile is allowed when its engine configuration changes, but adding, changing, or removing a profile must not restart the daemon or disrupt other profiles.
- Add and remove profiles without restarting
tailmixd. - Start, stop, and restart one profile without affecting the others.
- Persist every successful requested configuration change.
- Give tailmix commands, local profile names, advertised hostnames, and tailnet-provided DNS names distinct naming domains.
- Accept selected advertised IP routes and pin each destination prefix to one profile.
- Pin selected DNS suffixes to one profile's MagicDNS or split-DNS route.
- Optionally follow every current and future IP or DNS route advertised by a selected profile.
- Change the ordered OS search-domain policy without restarting the daemon.
- Report profiles that are disabled, starting, awaiting login, running, or failed without blocking on them.
- Isolate profile startup, authentication, and watcher failures.
- Apply live changes in both TUN and SOCKS modes.
- Keep auth keys out of daemon state, command-line arguments, and logs.
- Allow the daemon to run with zero configured or enabled profiles.
- Replace Tailscale's profile-local LocalAPI or CLI.
- Merge Tailscale login profiles inside one
tsnet.Server. - Automatically delete a removed profile's identity or effective-IP leases.
- Provide remote profile administration.
- Make global address-pool changes live in the first version.
- Infer application or app-connector routes from DNS answers. A DNS route binding selects where DNS queries go; returned traffic still follows the IP route table.
- Guarantee uninterrupted flows through the profile being stopped or restarted. Other profiles must remain uninterrupted.
The first positional token always names a command namespace, never a profile:
tailmix version
tailmix status [--json]
tailmix profiles <lifecycle-command> [arguments]
tailmix warp register <name> [flags]
tailmix routes <policy-command> [arguments]
tailmix exit-node <policy-command> [arguments]
tailmix dns routes <policy-command> [arguments]
tailmix dns search <policy-command> [arguments]
tailmix {tailscale|ts} [--profile <name>] <tailscale-subcommand> [arguments]
version prints the client and running daemon semantic versions, source
revisions, and full embedded Tailscale module versions. It still prints the
client version when the daemon is unavailable. status is the daemon-wide
overview: active profiles, configured or imported IP routes, the selected exit
node, effective DNS routes, and configured DNS search domains. It omits
advertised-but-unaccepted choices; the corresponding list commands show those.
profiles owns tailmix lifecycle operations. warp owns WARP registration,
its only kind-specific entry point; every other WARP operation uses the
ordinary profile commands. tailscale delegates to the
selected profile's upstream Tailscale CLI, and ts is its exact alias. A
profile name appears only as an operand or the value of --profile. routes
owns daemon-wide IP route policy, and exit-node owns the one daemon-wide
default-route selection. dns routes owns DNS query routing, while dns search owns the ordered OS search list. Prefixes and DNS domains therefore
occupy only resource positions and cannot collide with profile or command
names.
tailmixd links Tailscale's SSH server feature on supported Unix platforms.
Users enable it independently for each embedded node through the delegated
CLI, for example tailmix ts --profile work set --ssh; the tailnet's SSH access
policy remains authoritative.
tailscale may omit --profile when exactly one Tailscale profile exists;
raw WireGuard and removed profiles are not counted. With several, it fails and
lists their names. With none, up and login first add a profile named
default and report it on standard error, so a first-time setup is one
command (sudo tailmix ts login). Other subcommands fail instead of creating
state.
Every tailmix --profile option also accepts the short form -p. The
tailmixd startup profile flag accepts the same alias.
The current tailmix work status form is intentionally not part of the target
grammar. A release may recognize it solely to print a deprecation error with
the exact replacement, but new functionality must not extend that ambiguous
grammar:
tailmix work status
# error: use "tailmix ts --profile work status"
Global CLI options precede the namespace; command-specific options follow their command:
tailmix --socket-dir /var/run/tailmix profiles list
tailmix --socket-dir /var/run/tailmix profiles show work --json
TAILMIX_SOCKET_DIR remains supported. --socket-dir takes precedence and
selects both the daemon control socket and per-profile LocalAPI sockets.
The CLI and API use five deliberately separate names:
- Profile ID is an opaque, stable value generated by the daemon. It keys runtime state, leases, identity storage, and LocalAPI sockets. It is returned in JSON for correlation but is not normally typed by a user.
- Profile name is the locally unique operator-chosen handle, such as
work. It is the only CLI selector and is never sent to a tailnet. - Device hostname is the name advertised by that profile's Tailscale
engine. It is configured with
--hostnameand is never a profile selector. - Tailnet DNS suffix and node DNS names are observed from the profile's control-plane state. They are read-only status and routing data.
- Command names belong only to the CLI grammar.
There is no alias in the new model. The profile name is the one user-facing
local name and may be changed without changing the profile ID, identity,
leases, state directory, or socket. Existing state keeps its Profile.ID as
the stable ID and initializes the new name from that ID. An old Alias is
retained only for backwards-compatible decoding and is not a selector.
Profile names use a local CLI/storage grammar, not a DNS grammar. New names
match [A-Za-z0-9][A-Za-z0-9._-]{0,63} and are compared exactly. They are not
DNS-normalized, appended to a tailnet suffix, or used to derive the advertised
hostname. Default state directories and socket paths derive from the opaque
profile ID, so a filesystem name is not another implicit user-facing identity.
An IP route binding says both which advertised subnet route to accept and which
profile must carry it. There is no daemon-wide accept-routes boolean:
tailmix routes list [--available] [--json]
tailmix routes bind --profile <name> <prefix>... [--replace] [--json]
tailmix routes unbind <prefix>... [--profile <name>] [--json]
tailmix routes set --profile <name> --accept-all=<true|false> [--json]
bind atomically records every normalized prefix against the selected
profile's stable ID. Repeating the same binding is a successful no-op. Binding
an already-bound prefix to another profile returns a conflict unless
--replace is present, making a re-pin intentional. unbind is idempotent;
its optional profile is a precondition that prevents removing a binding that
has since been re-pinned.
set --accept-all=true adds a persistent profile-wide import policy. It
accepts every current approved primary subnet route from that profile and
automatically follows routes added later. Setting it to false stops following
the profile without removing any exact bindings. Direct peer reachability is
unchanged, and exit-node default routes remain separate.
list reports desired and observed state. With --available, it instead shows
the approved, primary subnet routes currently observed in each profile's
netmap, including routes that have not been bound:
PREFIX PROFILE ADVERTISED BY STATE MATCHED ROUTE
10.20.0.0/16 work router-a ✓ 10.20.0.0/16
10.30.0.0/16 lab waiting:route_not_advertised
10.40.0.0/16 work router-b ✓ 10.40.0.0/16
A binding becomes enabled only while the selected profile is active and its netmap has an approved primary subnet route that contains the requested prefix. Allowing a more-specific prefix lets an operator accept only part of an advertised route. Default routes are excluded because exit-node selection is a separate policy. Prefixes that overlap tailmix's effective-IP pools, host NAT addresses, or other daemon-reserved ranges are rejected.
ADVERTISED BY uses the subnet router's readable Tailscale node name rather
than its opaque stable node ID. The PROFILE column identifies the tailnet
profile where that advertiser was observed. STATE combines the active marker
and exceptional runtime status in one column. MATCHED ROUTE shows the
approved advertised prefix that covers a configured binding.
An exact prefix has only one binding, but different bindings may overlap.
Longest-prefix match is deterministic, so 10.0.0.0/8 may use work while
10.20.0.0/16 uses lab. Within the selected profile, the Tailscale control
plane still chooses the primary subnet-router peer and may fail over between
eligible routers. The binding pins a tailnet profile, not an individual peer.
Exact bindings are authoritative overrides of profile-wide imports. Lookup
first uses longest-prefix match among desired exact bindings. If that binding
is waiting, the destination fails closed; only when no exact binding matches
does lookup use longest-prefix match among imported routes. Imported routes
from multiple accept-all profiles may overlap, but an identical prefix from
more than one profile is left uninstalled as ambiguous-route until an exact
binding selects one.
If the route is withdrawn or the profile becomes unavailable, tailmix removes
the host route and packet mapping but retains the desired binding as
waiting-for-route. It is restored automatically when the same stable profile
can serve it again. A binding implies acceptance: tailmix ensures the profile
engine accepts the covering route while it is needed. That internal preference
is only a transport mechanism and does not expose any unbound route to the
host.
Exit-node policy is daemon-wide because a host can have only one selected default path:
tailmix exit-node list [--filter <country>] [--json]
tailmix exit-node set --profile <name> <peer> [--json]
tailmix exit-node clear [--json]
The default human-readable list follows Tailscale's CLI display mechanics.
For peers with location metadata, it shows the highest-priority peer per city,
keeps the current selection visible, and adds an Any row containing the
highest-priority peer when a country has multiple cities. Peers without
location metadata are all shown. --filter <country> performs a
case-insensitive country-name match and shows the complete, uncollapsed list
for that country. JSON output preserves the exit-node resource schema; when a
country filter is present, its available list and optional selected object
are restricted to that country.
set requires both naming domains: the profile selects one tailnet, and the
peer selector identifies one exit node inside it. The peer may be a full DNS
name, short hostname, stable node ID, or canonical Tailscale IP. Ambiguous short
names fail instead of selecting arbitrarily.
The daemon persists the profile's stable ID, the peer's stable node ID, and a
canonical peer IP. It applies that peer IP to the selected profile engine and
clears exit-node preferences from the other active engines. Profile renames
therefore do not disturb the selection. Every profile engine keeps
Tailscale route acceptance enabled so its internal WireGuard configuration has
the routes tailmix may select; this does not expose those routes on the host
unless they are separately selected by tailmix policy. Tailmix selects the
peer through the profile's Tailscale ExitNodeIP preference and clears the
previous ExitNodeID, matching tailscale set --exit-node; Tailscale resolves
that IP back to the peer's stable ID. Tailmix waits until the profile reports
the expected resolved ID before installing host default routes; while the
preference is pending, the selection reports waiting with reason
exit_node_not_applied. Clearing an exit node clears both exit-node preference
fields and leaves route acceptance enabled.
In TUN mode, tailmix installs split IPv4 and IPv6 defaults so it can withdraw only its own routes and leave the host's original defaults intact. On Linux, the defaults use a dedicated policy table so Tailscale's marked transport sockets continue to use the ordinary underlay route. Direct effective-peer routes, explicit subnet bindings, and accept-all subnet imports remain more specific and keep their selected profiles.
In SOCKS mode, the selected exit node is the fallback for otherwise-unmatched IP literals and DNS names. Explicit IP and DNS policy still wins. Clearing the selection removes the fallback in both modes.
Disabling the selected profile withdraws its default route but retains the desired selection, which resumes when the same profile is enabled. Removing the selected profile clears the selection. There is no automatic failover to an exit node in another profile.
A DNS route binding sends queries for a suffix through one selected profile:
tailmix dns routes list [--available] [--json]
tailmix dns routes bind --profile <name> <domain>... [--replace] [--json]
tailmix dns routes unbind <domain>... [--profile <name>] [--json]
tailmix dns routes set --profile <name> --accept-all=<true|false> [--json]
The mutation and precondition behavior matches IP route bindings. Domains are
normalized to lowercase suffixes without a trailing dot. An exact suffix has
one binding, nested suffixes may bind different profiles, and DNS
longest-suffix match selects the most-specific binding.
The root suffix . is a valid DNS route binding for a profile's default
resolver route, but it is not a valid search domain.
Validation and containment use Tailscale's util/dnsname parser and FQDN
semantics, so DNS labels are not incorrectly restricted to hostname syntax.
set --accept-all=true follows the profile's complete current and future DNS
route configuration: its MagicDNS suffix, split-DNS routes, and Tailscale's
configured default/fallback resolver behavior. It does not import the profile's
search domains; tailmix dns search remains the only way to change the OS
search list. Setting the flag to false removes only the profile-wide import
policy and retains exact suffix bindings.
Each active profile contributes observed DNS capabilities from its netmap:
- Its MagicDNS suffix, which tailmix can answer from that profile's node data.
- Its split-DNS routes and resolver descriptors, which tailmix can forward through that profile.
A binding is installed only when the selected profile currently has an equal
or covering DNS route. A more-specific binding can therefore select a subtree
of an advertised split-DNS suffix. If the capability disappears, the binding
remains desired with waiting-for-route state and no other profile is chosen.
Human-readable status distinguishes policy, source, and realization:
DOMAIN PROFILE SOURCE POLICY STATE
work.example.ts.net work magicdns automatic installed
corp.example.com work split-dns bound installed
internal.example.com work split-dns accept-all installed
lab.example.com lab bound waiting-for-route
DNS selection first uses longest-suffix match across every route. For entries
with the same suffix, desired exact bindings take precedence over routes
imported from accept-all profiles, which take precedence over automatic
MagicDNS and exit-node routes. A waiting exact binding fails closed at that
suffix. Identical imported suffixes from multiple profiles are
ambiguous-route and are not installed until an exact binding selects one.
For compatibility, an otherwise unbound and unimported MagicDNS suffix is routed automatically only when exactly one active profile is authoritative for it. Ambiguous MagicDNS suffixes require an exact binding or one unambiguous accept-all import. Split-DNS and default resolver routes are never imported automatically.
Forwarding must use a profile-scoped DNS path. Installing a private resolver IP directly in a shared host resolver is insufficient because identical resolver addresses may be reachable in several tailnets. The aggregate DNS service therefore selects the binding by suffix and invokes a resolver/forwarder whose dials enter the selected profile engine. It must preserve upstream UDP, TCP, and DNS-over-HTTPS behavior rather than assuming every resolver is a plain UDP address.
This policy routes DNS queries, not arbitrary application connections by hostname. MagicDNS node answers contain effective IPs. Addresses returned by split DNS follow the host's IP routing policy; reaching one through a tailnet requires a corresponding installed IP route binding or accept-all import.
Search domains are daemon-wide ordered policy, not profile names or aliases. The default list is empty, preserving fully qualified MagicDNS behavior.
tailmix dns search list [--json]
tailmix dns search set <domain>... [--json]
tailmix dns search add <domain>... [--json]
tailmix dns search remove <domain>... [--json]
tailmix dns search clear [--json]
set atomically replaces the desired list in the given order. add appends
domains that are not already present, remove is an idempotent removal, and
clear restores the empty default. DNS names are case-insensitive; input is
normalized to lowercase fully qualified suffixes stored without a trailing
dot. Duplicates are removed while preserving the first occurrence.
A desired search domain is installed only while it is equal to or below one
effective DNS route, whether that route is an explicit binding or an
unambiguous automatic MagicDNS route. This matters because on systems such as
Linux with systemd-resolved, a search domain also routes matching queries to
tailmix's DNS service. tailmix must not capture an arbitrary suffix without a
known query path.
Desired domains that are not currently covered remain persisted but have
waiting-for-route state. They are installed automatically if a profile later
provides the required route and withdrawn if that profile is disabled,
removed, or loses its usable netmap. Profile lifecycle changes do not silently
edit the desired search-domain list.
Human-readable status distinguishes desired policy from observed installation:
ORDER DOMAIN PROFILE STATE
1 work.example.ts.net work installed
2 lab.example.ts.net waiting-for-route
The configured order is passed to the OS unchanged after filtering unavailable domains. Short-name expansion and collision behavior then follow the host resolver's normal search-list semantics. tailmix does not inspect a short name and choose a profile itself.
tailmix profiles list [--all] [--json]
The human-readable output is stable enough for people, not scripts:
PROFILE KIND ENABLED RUNTIME BACKEND DEFAULT PEERS LAST CONNECTED TAILNET ERROR
home tailscale yes running Running no 14 2h ago home.ts.net
edge warp yes running Connected yes 0 4s ago
lab wireguard no disabled no 3 lab.example
work tailscale yes needs-login NeedsLogin no 0 work.ts.net
KIND is tailscale, wireguard, or warp. BACKEND is the state the
profile's engine reports, so a WARP profile shows its tunnel state there. For a
WARP profile without usable credentials, RUNTIME is error and BACKEND is
NeedsReRegistration. DEFAULT names the profile that holds the default
route, which for a WARP profile means it is the selected exit node. LAST CONNECTED is the time since the profile's last successful
connection, and stays empty when the kind does not report one. A WARP profile
has no peers, so its peer count is 0.
JSON is the scripting interface. Profiles are sorted by name in both formats.
Listing is a non-blocking snapshot and never waits for a profile to become
online. Removed profile tombstones are included only with --all.
tailmix profiles show <name> [--json]
The result includes:
- Persisted configuration: stable profile ID, profile name, profile kind, state directory, hostname, control URL, and enabled state.
- Runtime state, last lifecycle error, and whether the profile holds the default route.
- Tailscale backend state, login URL, tailnet suffix, self DNS name and addresses, peer count, and shields-up state when available.
- For a WARP profile: the kind, the tunnel addresses, the time since the last successful connection, and an empty peer list.
- Approved primary subnet routes and observed DNS routes, plus the IP and DNS bindings pinned to the profile.
- The profile LocalAPI socket path when the engine is running.
An auth key is never returned.
tailmix profiles add <name> \
[--hostname <hostname>] \
[--state-dir <directory>] \
[--auth-key-env <name> | --auth-key-file <path|->] \
[--disabled] \
[--json]
Defaults are:
- The daemon generates a stable opaque profile ID.
- State directory defaults to a persisted path under
<daemon-state-dir>/profilesderived from that ID. - Hostname defaults to an independently generated, persisted DNS hostname. It is not derived from the profile name.
- The profile is enabled unless
--disabledis supplied.
The CLI, not the daemon, resolves --auth-key-env. --auth-key-file - reads
the key from standard input. The resolved key is sent in the one local API
request, used for the initial engine start, and then discarded. It is not
persisted or echoed. There should be no raw --auth-key option because process
arguments and shell history are poor secret transports.
Adding an enabled profile returns after its engine, watcher, packet path, and profile LocalAPI listener have either been attached or have failed. It does not wait indefinitely for interactive login. If login is required, output points the user at the normal profile-local command:
Profile "work" added; login required.
Run: tailmix ts --profile work up
Alternative coordination servers are selected by the profile-local upstream command and persisted in native Tailscale state:
tailmix ts --profile work login \
--login-server=https://headscale.example.com
add fails with a conflict if the name is already active or disabled.
Re-adding a previously removed name resurrects its retained profile definition,
stable ID, state directory, and effective-IP leases unless it was purged.
tailmix profiles rename <name> <new-name>
tailmix profiles set <name> \
--hostname <hostname> \
[--json]
State directory is immutable after creation. Moving identity state is a separate, offline administrative operation and should not be hidden inside a live update.
rename updates only the local control-plane index; the stable ID and running
engine do not change. set changes the Tailmix-owned hostname. Changing it
restarts only the selected profile after persisting the new desired
configuration. The command reports that brief profile-local disruption before
applying it. Native Tailscale settings, including the login server, remain in
the profile's Tailscale state and are preserved by that restart.
tailmix profiles enable <name>
tailmix profiles disable <name>
tailmix profiles restart <name>
enable persists the enabled state and starts the profile. disable persists
the disabled state, withdraws its routes and DNS records, closes its LocalAPI
socket and engine, and retains its identity directory and leases. Disabled
profiles remain visible in list and are not started after daemon restart.
restart replaces only the selected enabled profile's runtime. It is useful
for recovery and for explicitly reloading profile engine state. It does not
change whether the profile is enabled.
Repeated enable and disable requests are successful no-ops. restart on a
disabled profile returns a conflict and suggests enable.
tailmix profiles remove <name>
tailmix profiles remove <name> --purge --yes
The default operation is deliberately reversible:
- Mark the persisted profile definition as removed.
- Withdraw its live routes and DNS records.
- Stop its API listener, watcher, packet path, and engine.
- Retain its stable ID, name, identity state directory, and effective-IP leases.
Removed definitions are omitted from profiles list unless --all is used.
profiles add with the same name resurrects a removed definition rather than
creating a new identity.
If the profile is the selected exit-node profile, removal also clears that
selection. IP route bindings, DNS route bindings, and the profile's accept-all
flags retain the stable profile ID and become waiting-for-profile, so
resurrecting the profile restores its policy without a rename-sensitive
rewrite.
--purge additionally deletes only that profile's resolved state directory and
effective-IP leases. It requires an interactive confirmation, or --yes for
non-interactive use. The daemon must reject purge while any IP or DNS route
binding refers to the profile; the operator must unbind those resources first.
It must also reject purge if the state directory is empty, is a parent of the
daemon state file, is shared by another profile, or does not resolve beneath an
explicitly allowed profile-state root. Partial purge failure leaves the profile
removed but reports which retained data could not be deleted.
tailmix warp register <name> \
[--jwt-file <path> | --config <path>] \
[--dns <address>] \
[--transport quic|http2] \
[--json]
WARP is a profile kind beside tailscale and wireguard, and it uses the
ordinary profiles commands for enable, disable, rename, and removal. This
command is its only kind-specific entry point. It creates or reconfigures the
named profile, stores its credentials in the profile's state directory, and
starts it in one request; see Cloudflare WARP profiles.
Without --jwt-file or --config the daemon registers a consumer account with
Cloudflare and the CLI asks for Terms of Service consent first. --jwt-file
enrolls through ZeroTrust and reads the team token from the file so it never
appears in the argument list. --config imports a config.json written by the
usque CLI and does not contact Cloudflare. --dns defaults to 1.1.1.1, and
--transport defaults to quic.
The output and --json body are the profile's kind, transport, resolver, and
tunnel addresses. Credentials are never printed.
The CLI uses:
0: requested state is applied, including idempotent no-ops.1: daemon or profile operation failed.2: CLI usage error.
API errors have stable machine-readable codes:
{
"code": "profile_exists",
"message": "profile \"work\" already exists",
"profileId": "p_01k2x7vq3c8m",
"profileName": "work"
}Initial codes should include invalid_request, profile_exists,
profile_not_found, profile_disabled, transition_in_progress,
invalid_prefix, route_binding_conflict, invalid_dns_name,
dns_route_binding_conflict, binding_profile_mismatch,
exit_node_not_found, exit_node_ambiguous, exit_node_unavailable,
profile_unavailable,
profile_has_bindings, permission_denied, runtime_start_failed,
dns_configuration_failed, reconcile_failed, purge_failed,
warp_credentials, and warp_unsupported_platform. warp_credentials
separates a credential or registration problem from an unavailable tunnel.
The daemon serves HTTP/JSON over a dedicated Unix socket:
<socket-dir>/tailmixd.sock
This socket is separate from each profile's hashed LocalAPI socket. The CLI must never edit the state JSON directly.
Local read requests use the normal local socket access policy, so commands such
as tailmix status and the various list commands do not require root.
Mutations require the connecting UID to be root. The server checks peer
credentials rather than relying on filesystem mode alone. On platforms without
Unix peer credentials, the socket remains owner-only. A future daemon-wide
operator setting can widen mutation access without changing the API.
GET /v1/version
GET /v1/profiles
POST /v1/profiles
GET /v1/profiles/by-name/{escaped-profile-name}
PATCH /v1/profiles/by-name/{escaped-profile-name}
POST /v1/profiles/by-name/{escaped-profile-name}/enable
POST /v1/profiles/by-name/{escaped-profile-name}/disable
POST /v1/profiles/by-name/{escaped-profile-name}/restart
DELETE /v1/profiles/by-name/{escaped-profile-name}?purge=false
POST /v1/warp
GET /v1/routes
PUT /v1/routes
PATCH /v1/routes
DELETE /v1/routes
GET /v1/routes/available
GET /v1/exit-node
PUT /v1/exit-node
DELETE /v1/exit-node
GET /v1/dns/routes
PUT /v1/dns/routes
PATCH /v1/dns/routes
DELETE /v1/dns/routes
GET /v1/dns/routes/available
GET /v1/dns/search-domains
PUT /v1/dns/search-domains
PATCH /v1/dns/search-domains
DELETE /v1/dns/search-domains
Mutations are serialized by the daemon lifecycle loop. A response is sent only after the desired state has been persisted and the corresponding live transition has completed or failed.
The profile resource separates desired state from observed state:
{
"id": "p_01k2x7vq3c8m",
"name": "work",
"kind": "tailscale",
"stateDir": "/var/db/tailmix/profiles/p_01k2x7vq3c8m",
"hostname": "tailmix-a2c123",
"enabled": true,
"removed": false,
"acceptAllRoutes": true,
"acceptAllDnsRoutes": true,
"runtimeState": "running",
"backendState": "Running",
"magicDnsSuffix": "example.ts.net",
"selfDnsName": "tailmix-a2c123.example.ts.net",
"selfIps": ["100.64.0.10", "fd7a:115c:a1e0::10"],
"peerCount": 12,
"shieldsUp": false,
"authUrl": "",
"localApiSocket": "/var/run/tailmix/p_01k2x7vq3c8m.sock",
"lastError": ""
}runtimeState is one of disabled, starting, needs-login, running,
stopping, or error. It is tailmix lifecycle state; backendState is the
profile's upstream state. For the warp kind that upstream state is the tunnel
state and includes NeedsReRegistration when the stored credentials cannot be
used. lastConnected is the last successful connection the kind reports, and
defaultRoute is true while the profile holds the default route.
IP and DNS route resources report the persisted binding and its current realization together:
{
"acceptAllProfiles": [{
"profileId": "p_01k2x7vq3c8m",
"profileName": "work",
"state": "installed"
}],
"bindings": [{
"prefix": "10.20.0.0/16",
"profileId": "p_01k2x7vq3c8m",
"profileName": "work",
"state": "installed",
"coveringRoute": "10.20.0.0/16",
"primaryRouter": "router-a"
}]
}{
"acceptAllProfiles": [{
"profileId": "p_01k2x7vq3c8m",
"profileName": "work",
"state": "installed"
}],
"bindings": [{
"domain": "corp.example.com",
"profileId": "p_01k2x7vq3c8m",
"profileName": "work",
"state": "installed",
"coveringRoute": "corp.example.com",
"source": "split-dns"
}]
}Waiting bindings use state: "waiting" and a stable reason, including
profile_unavailable, route_not_advertised, dns_route_not_advertised, or
host_route_conflict. The available resources are observational snapshots
grouped by profile and do not mutate policy.
An accept-all or automatic row shadowed completely by a higher policy tier uses
state: "overridden" and reports overriddenBy, overrideProfileId, and
overrideProfileName. A partially shadowed row remains installed and reports
reason: "partially_overridden". Identical accept-all imports from different
profiles share the same resolvers and install from the first configured profile.
A single MagicDNS import wins over the other profiles. Imports that disagree on
resolvers remain waiting with
reason: "ambiguous_route". Route and search
resources also expose reconcileError when persisted desired state could not
be fully applied to the live aggregate.
For both binding resources, PUT atomically replaces the complete desired
binding table and accept-all profile set. PATCH accepts atomic bind and
unbind arrays, an explicit replace flag, and an acceptAll map from profile
name to boolean. DELETE clears exact bindings and accept-all policy. Mutation
entries use a profile name; the daemon resolves it under the lifecycle lock and
persists the stable profile ID. Profile renames therefore change display only.
The exit-node resource contains one optional selected object and an
observational available list. Available and selected peers include optional
location metadata with country, countryCode, city, cityCode, and
priority fields. PUT accepts profileName and peer, resolves the peer
against the profile's current approved exit nodes, and atomically replaces the
selection. DELETE clears it.
The search-domain resource reports desired and observed state separately:
{
"desired": ["work.example.ts.net", "lab.example.ts.net"],
"installed": [{
"domain": "work.example.ts.net",
"profileId": "p_01k2x7vq3c8m",
"profileName": "work"
}],
"waiting": [{
"domain": "lab.example.ts.net",
"reason": "no_active_route"
}]
}For search domains, PUT atomically replaces desired. PATCH accepts add
and remove arrays and applies them atomically to the current list. DELETE
clears the list. Every routing-policy mutation persists desired state and
completes aggregate route and DNS reconciliation before returning.
Introduce a daemon supervisor that is the sole owner of:
- Persisted desired profile configuration.
- The map of runtime profiles.
- Profile engine, watcher, TUN, and LocalAPI listener lifetimes.
- TUN or SOCKS aggregate reconciliation.
- Lifecycle command serialization and response delivery.
HTTP handlers validate and enqueue commands. Engine watcher goroutines enqueue observations. They do not mutate daemon state directly. A single lifecycle loop processes both, avoiding concurrent state-file updates and cross-component lock ordering.
Each runtime profile contains at least:
persisted profile configuration
runtime state and last error
engine
per-profile lifecycle context/cancel
update watcher
LocalAPI server/listener
ChanTUN (TUN mode)
The current TSNetEngine.Status calls tsnet.Server.Up, which waits for the
backend to reach Running. Live management requires a passive status method
that uses LocalClient.Status and current backend notifications without
bringing the profile up or blocking.
Aggregate status must return a result per profile, including a per-profile
error, instead of failing the entire snapshot when one profile is unhealthy.
Capability readiness is evaluated separately: direct peer, Tailscale Service,
and subnet routing need a usable netmap and self address, MagicDNS needs target
data and a suffix, and split DNS needs a usable netmap DNS configuration. A
profile without one capability can still contribute the others. A profile in
needs-login or error remains inspectable and does not take down healthy
profiles.
The passive snapshot must include approved primary subnet routes and the netmap's DNS route map and resolver descriptors. These observations are not persisted as policy; they are inputs used to decide whether desired bindings can currently be installed.
Zero profiles is a valid steady state. The daemon control socket and selected
networking mode remain available after the last profile is removed, with an
empty aggregate peer route and DNS configuration. Startup no longer requires
at least one --profile.
The current manager builds WatchUpdates from a fixed engine map. Replace it
with one permanent supervisor update channel. Starting a profile starts its
watcher with a profile-scoped context; stopping it cancels that watcher.
Watcher termination updates only that profile's error state.
The current profile API group is a fixed slice. Store servers by stable
profile ID, and support Start(profile) and Stop(profile). Stopping closes
the listener, waits for the serving goroutine, and removes the stale socket
path. Failure of one profile LocalAPI server marks that profile failed rather
than terminating the daemon.
The supervisor owns exact DNS route bindings, profile-wide accept-all flags, and the desired search-domain list. It builds a tiered longest-suffix-match DNS plan from exact bindings, routes imported from accept-all profiles, and unambiguous automatic MagicDNS routes. The longest suffix wins across tiers; the tier breaks ties for the same suffix. A desired exact binding acts as either an installed route or a fail-closed waiting barrier for its suffix.
MagicDNS routes terminate in tailmix's aggregate authoritative resolver. Split-DNS and accepted default/fallback resolution terminate in profile-scoped forwarders that reuse Tailscale's resolver behavior with dials bound to the selected engine. A single shared dialer cannot be used because resolver IPs can overlap between profiles.
The installed search-domain subset is derived from that effective DNS plan.
No covering DNS route produces no_active_route; an unresolved automatic
MagicDNS conflict produces ambiguous_route.
The DNS service configuration carries domains, records, and search domains
together. Its live Configure operation validates the complete next
configuration before calling the Tailscale DNS manager, so a failed search
policy update cannot partially replace routes or records. Installed domains
retain desired-list order even though authoritative route tables are otherwise
sorted for determinism.
The supervisor expands each active accept-all profile into its current approved
primary subnet routes, then validates exact IP bindings against each selected
profile's routes. It excludes direct Tailscale addresses and /0 exit routes.
An exact requested prefix is usable when it is equal to or more specific than
an observed route. Expansion happens on every relevant netmap update, so future
advertisements are followed without changing persisted state.
The installed prefix table records a policy tier and route kind:
effective peer/Service address -> profile + canonical target address
accepted subnet prefix -> profile, destination preserved
Lookup uses longest-prefix match over desired exact bindings before considering the imported tier. A waiting exact binding is a fail-closed barrier. An exact duplicate imported from multiple profiles is omitted as ambiguous rather than relying on profile iteration order.
For a subnet packet, the outbound mapper selects the profile, translates the shared host source to that profile's self address, and preserves the destination for the profile engine. On return, it preserves the subnet source and translates the profile self destination back to the shared host address. Inbound subnet packets are admitted only when longest-prefix match pins their source to the profile on which they arrived.
The selected profile engine may internally accept a covering route, but the
host receives only exact-bound prefixes and routes materialized by accept-all
policy. Those daemon policies remain authoritative even if the delegated
profile-local CLI changes its broad accept-routes preference.
The current TUN mux receives a fixed profile map and starts one inbound worker per entry. It needs:
AddProfile(profileID, tun)
RemoveProfile(profileID)
SetMapper(mapper)
Host-to-profile lookup should use an atomically replaced immutable profile map, matching the existing immutable packet mapper approach. Each inbound worker has a profile-scoped cancellation function. Removing a profile first withdraws its aggregate routes and mapper entries, then removes it from the mux and cancels the worker.
Worker errors are reported with both stable profile ID and current profile name and isolated where possible. Host TUN failure remains global.
The current SOCKS router is immutable. The SOCKS server should hold an atomic pointer to a newly validated router snapshot. New connections use the latest snapshot; established connections continue through their selected engine until they close or that profile is stopped. IP destinations consult the same effective-target and bound-prefix table as TUN mode. Domain destinations use the DNS plan for resolution, then the resolved address must still select an effective peer or Service, installed IP binding, or accept-all import; a DNS binding alone does not authorize an application route.
Every profile lifecycle change or relevant netmap update rebuilds one complete, immutable aggregate plan from:
persisted desired state + passive healthy profile snapshots + retained leases
The plan includes effective-IP leases, direct-target and bound-subnet packet mappings, host routes, DNS bindings and profile forwarders, records, installed search domains, and the SOCKS routing snapshot. It is fully validated before any live component changes.
Removing or disabling a profile follows this order:
- Persist the new desired state.
- Rebuild and apply an aggregate plan that excludes the profile.
- Detach its aggregate packet or SOCKS path.
- Stop its LocalAPI server, watcher, TUN, and engine.
Adding or enabling follows this order:
- Validate and persist the new desired state.
- Create the runtime and start the engine.
- Attach its watcher, LocalAPI server, and TUN or SOCKS runtime.
- Reconcile it into the aggregate plan once passive status is usable.
The desired state is written first so a daemon crash converges correctly after
restart. If startup fails, the enabled profile remains configured with
runtimeState: error; healthy profiles continue and a later restart or daemon
restart retries it. Removing a profile is similarly durable even if teardown
reports an error.
Live plan application may briefly drop packets during route changes, but it must never send a packet through the wrong profile.
Persist the stable identity, local name, lifecycle flags, and existing engine configuration:
type State struct {
// Existing fields...
IPRouteBindings []IPRouteBinding `json:"ipRouteBindings,omitempty"`
DNSRouteBindings []DNSRouteBinding `json:"dnsRouteBindings,omitempty"`
SearchDomains []string `json:"searchDomains,omitempty"`
}
type IPRouteBinding struct {
Prefix string `json:"prefix"`
ProfileID string `json:"profileId"`
}
type DNSRouteBinding struct {
Domain string `json:"domain"`
ProfileID string `json:"profileId"`
}
type Profile struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Disabled bool `json:"disabled,omitempty"`
Removed bool `json:"removed,omitempty"`
AcceptAllRoutes bool `json:"acceptAllRoutes,omitempty"`
AcceptAllDNSRoutes bool `json:"acceptAllDnsRoutes,omitempty"`
Warp *warpcfg.Config `json:"warp,omitempty"`
// Existing engine configuration fields...
}On migration, an existing profile keeps its ID, gets Name = ID, and ignores
the legacy Alias for selection. New profiles receive daemon-generated opaque
IDs. Using Disabled rather than Enabled makes existing stored profiles
enabled after upgrade. A removed profile remains as a tombstone until purge so
its ID, state directory, and leases can be recovered. Runtime state and errors
are observational and are not persisted.
Route binding slices are normalized desired policy keyed by stable profile ID;
profile names are never persisted in a binding. Exact prefixes and domains are
unique, and slices are serialized in canonical order for stable diffs.
Accept-all flags live on the stable profile definition and survive rename,
disable, reversible removal, resurrection, and daemon restart. They default to
false during migration, so an upgrade never begins importing subnet or
split-DNS routes.
SearchDomains is the normalized desired order. Installed, available, and
waiting state is derived from desired policy plus live profile observations, so
those observations are not persisted.
The daemon remains the only state writer after startup. Startup --profile
flags can remain as a compatibility bootstrap, but should be documented as
deprecated once profiles add is available. A flag-supplied id maps to the
legacy profile ID during migration and is merged into desired state before the
supervisor starts. The legacy alias field is not carried into the new CLI.
Auth key material is never added to state.State.
- Add control socket client/server packages.
- Add
tailmix profiles listandshow. - Add passive per-profile status and per-profile errors.
- Introduce the supervisor and permanent update stream while preserving static startup behavior.
- Make LocalAPI servers, TUN mux membership, and SOCKS router snapshots dynamic.
- Add
profiles add,enable,disable,restart, and non-purgingremove. - Reconcile incomplete and failed profiles without global failure.
- Expose approved primary subnet routes and netmap DNS routes in passive profile snapshots.
- Add IP route binding commands, resources, longest-prefix matching, and dynamic host-route installation.
- Add DNS route binding commands, resources, longest-suffix matching, and profile-scoped DNS forwarding.
- Add profile-wide accept-all flags that continuously import current and future IP or DNS routes without importing search domains.
- Add
dns searchcommands and control endpoints, deriving installation from the effective DNS route plan. - Carry all three policies through TUN, SOCKS, and DNS aggregate reconciliation without restarting a profile.
- Add
profiles renameandset. - Add guarded
remove --purge. - Deprecate routine use of startup
--profileflags. - Add a daemon-wide operator policy if non-root mutation is required.
Unit tests should cover:
- Strict separation of command namespaces from profile-name operands.
- Exact argument and behavior parity between
tailscaleand itstsalias. - Deprecation errors for the old ambiguous delegation grammar.
- Profile-name changes preserving stable ID, leases, state path, and socket.
- Profile names never being DNS-normalized or used as device hostnames.
- Human and JSON output from the same API resource.
- IP prefix normalization, atomic bind/unbind, intentional re-pinning, and optional-profile unbind preconditions.
- Enabling and disabling accept-all without removing exact bindings, including automatic import and withdrawal after netmap updates.
- Binding persistence by stable profile ID across profile rename, disable, removal, resurrection, and daemon restart.
- Installation only when a selected profile has an approved covering primary subnet route; withdrawn routes remain desired and fail closed.
- Longest-prefix selection for overlapping bindings to different profiles, including inbound source/profile validation.
- Exclusion of default routes and daemon-reserved address ranges.
- No unbound subnet route becoming host-reachable when a profile engine internally enables accept-routes.
- Exact IP bindings overriding accept-all imports, with identical imports from multiple profiles failing as ambiguous.
- DNS suffix normalization, atomic bind/unbind, and longest-suffix selection for nested bindings to different profiles.
- Automatic routing only for unambiguous MagicDNS suffixes, with ambiguous suffixes waiting for an explicit binding.
- Split-DNS routes requiring an exact binding or accept-all policy and forwarding UDP, TCP, and DoH through the selected profile even when resolver IPs overlap.
- DNS accept-all importing MagicDNS, split, and default/fallback resolution but never the profile's search-domain list.
- Exact DNS bindings overriding accept-all imports, with identical imports from multiple profiles failing as ambiguous.
- DNS bindings not creating application routes for returned IP addresses.
- Search-domain normalization, stable ordering, deduplication, and atomic set/add/remove/clear operations.
- Search-domain installation only when covered by an effective DNS route.
- Automatic withdrawal and restoration as the covering profile stops and starts, without changing desired policy.
- OS DNS configuration receiving search and match domains as separate fields.
- Auth key environment/file resolution without secret output or persistence.
- State migration from legacy ID/alias fields and an absent
disabledfield. - Idempotent enable and disable transitions.
- Add conflict, missing profile, and invalid transition error codes.
- Per-profile startup, watcher, status, and shutdown failure isolation.
- Dynamic LocalAPI socket creation and removal.
- Dynamic TUN add/remove under concurrent packet lookup, including race tests.
- Atomic SOCKS router replacement.
- Lease retention on remove and deletion only on confirmed purge.
- Removed-profile resurrection preserving the prior stable ID and leases.
- Exit-node selection clearing when its profile is removed.
- Purge path containment and shared-directory rejection.
Integration tests should start two fake profile engines and prove:
- A third profile can be added and becomes routable without restarting the daemon.
- Removing that profile withdraws only its routes and DNS records.
- Existing traffic and status for the original profiles remain available.
- A profile that needs login or fails startup does not prevent reconciliation.
- Restarting the daemon reproduces the last requested enabled/disabled set.
- Updating search domains changes OS DNS configuration without restarting the daemon or any profile.
- Binding overlapping private prefixes to different profiles routes each destination by longest prefix and withdraws only the affected binding when an advertisement disappears.
- Binding the same split-DNS suffix to one of two tailnets with overlapping resolver addresses sends queries only through the selected profile.
- Renaming a profile leaves its IP and DNS bindings installed under the new display name.
- Enabling accept-all on one profile follows newly advertised subnet and DNS routes live; disabling it withdraws only imported routes and keeps exact bindings.
The final validation target is:
go test -race ./...