# TailMux - Architecture

Date: 2026-05-05

## Current Architecture

TailMux is a Go CLI and local router. The recommended profile backend is embedded `tsnet`.

```text
Safari / browser / curl / git / ssh ProxyCommand
        |
        | PAC / HTTP_PROXY / HTTPS_PROXY / SOCKS5 / tailmux connect
        v
TailMux router
127.0.0.1:43100
        |
        | classify original hostname by suffix
        v
selected profile
        |
        | backend: tsnet
        v
embedded tsnet.Server dialer
        |
        v
selected tailnet
```

Compatibility backend:

```text
TailMux router -> profile HTTP/SOCKS proxy -> external tailscaled userspace daemon -> selected tailnet
```

The two backends share the same security contract: one profile owns one or more suffixes, has independent state, and never receives traffic classified for another profile.

## Why Proxy/PAC Still Exists With tsnet

Embedding `tsnet` removes an internal local proxy hop. It does not make Safari or arbitrary apps route by tailnet suffix automatically.

The browser boundary still needs one of:

- PAC URL
- explicit HTTP/HTTPS proxy
- SOCKS5 domain proxy
- command wrapper such as `tailmux connect`

This is intentional. TailMux must see the original hostname before system DNS and kernel routing remove the profile ownership signal.

## Per-Host Commands Reuse a Running Service

A running TailMux service (`tailmux gui serve` under the menu-bar app, or `tailmux up`) owns every tsnet profile's state directory. A CLI command that starts its own embedded instance at the same time creates a second tsnet node on the same state dirs: the backend reports `NoState` and MagicDNS resolution fails.

The CLI commands therefore detect and reuse the running service (helpers in `cmd/tailmux/router_client.go`):

- `tailmux connect` (and `tailmux ssh`, which wraps it as an SSH ProxyCommand) first classifies the host locally, then dials through the shared router SOCKS5 listener (`router.socks5_proxy`) if something is listening there. The router re-classifies the host under its live rules, so all isolation invariants hold on that path. Only when nothing is listening does the command fall back to starting the owning profile embedded in-process.
- `tailmux tunnel` makes the same choice once at startup and then relays every client connection through the router SOCKS5 listener. Because the router classifies per connection, a raw-IP target must be covered by an `ip_route` there; an explicit `--profile` pin cannot be forwarded through the shared listener.
- `tailmux status`, `tailmux profile status`, `tailmux diag path`, and `tailmux tui` read the service's live view from the control API (`GET /api/profiles`, `GET /api/reports/path` on the `gui serve` listener, `127.0.0.1:43200` by default) and only fall back to probing the embedded backend when no control endpoint answers.
- `tailmux profile login`/`logout` post to `POST /api/profiles/{name}/{action}` with the per-run control token (`~/Library/Application Support/TailMux/control.token`); once a service answered, a mutation failure is an error, never an embedded retry.
- `tailmux dns query` resolves through `GET /api/dns/query`. An older service without that endpoint is reported as such instead of silently falling back into the state-dir conflict.
- `tailmux diag fetch` marks its `embedded` mode as skipped while a service runs (the sample would measure the conflict, not the network); the `router` mode already measures through the running data plane.
- `tailmux up` refuses to start while a router listener or control API is already answering, instead of spinning up duplicate tsnet nodes before failing on the port bind.

Once a router listener has accepted the connection, its answer is authoritative: a denied or failed dial is reported as an error and the CLI never falls back to a duplicate embedded profile.

## Package Model

### `cmd/tailmux`

CLI entrypoint and user-facing commands:

- `init`
- `config validate`
- `doctor`
- `profile list/login/logout/status`
- `up`
- `status`
- `pac print/serve`
- `env`
- `test`
- `connect`
- `tunnel`
- `diag path`
- `diag fetch`
- `gui serve`
- `tui`
- `version`
- shell completion commands

The CLI loads config once per command, validates it, and wires the appropriate backend manager.

### `internal/config`

Responsibilities:

- load YAML
- normalize paths
- validate profile ownership
- enforce loopback listeners
- validate backend names
- validate device hostnames
- reject duplicate ports and unsafe settings

Default config path:

```text
~/.config/tailmux/config.yaml
```

Default state root:

```text
~/Library/Application Support/TailMux/
```

### `internal/rules`

Owns suffix classification.

Rules:

- normalize hostnames to lowercase without trailing dot
- normalize suffixes to leading-dot form
- support root matching only when `match_root: true`
- reject overlapping suffixes assigned to different profiles
- deny unknown hosts by returning a classification error

Examples:

- `claudios-mac-mini-1.tail2c15f0.ts.net` matches a profile owning `.tail2c15f0.ts.net`
- `tail2c15f0.ts.net` matches only when `match_root: true`
- `evil-tail2c15f0.ts.net` does not match `.tail2c15f0.ts.net`
- `100.100.100.100` is not a suffix-routable hostname

### `internal/embedded`

Runs profile-scoped Tailscale nodes inside TailMux with `tailscale.com/tsnet`.

Responsibilities:

- start one `tsnet.Server` per profile configured with `backend: "tsnet"`
- use `state_dir` as the tsnet state directory
- use `hostname` as the device name
- read optional auth key from `auth_key_env`
- honor optional `control_url`
- accept advertised subnet/app-connector routes only when a profile explicitly sets `accept_routes: true`
- expose profile-scoped status, ping, login/up, and logout behavior needed by shared managers
- provide dialers for the router and `tailmux connect`
- resolve non-IP dial targets through the selected profile's Tailscale LocalAPI DNS before opening the TCP connection
- watch each profile's IPN bus for peer-set changes and force an engine/DNS
  reconfig when a node joins or leaves the tailnet (`peerwatch.go`). This works
  around a tailscale.com v1.100.0 bug where a peer delivered as an incremental
  netmap delta updates `status` but never triggers `authReconfig`, leaving
  MagicDNS returning NXDOMAIN and dials failing for the new node until the
  router restarts. After each forced reconfig the watcher also invokes
  Tailscale's version-pinned `force-netmap-update` LocalAPI action, which pushes
  the backend's current netmap into wgengine under the backend lock. That keeps
  LocalAPI ping — and therefore `tailmux diag path` — from reporting
  `no matching peer` for the newly added node.

Important limit: embedded profile login does not support `--reset`. Use logout and controlled state rotation for clean development re-registration.

### `internal/supervisor`

Compatibility backend for `backend: "tailscaled"`.

Responsibilities:

- start external `tailscaled` in userspace networking mode
- use per-profile state dir, socket, HTTP proxy, SOCKS5 proxy, and UDP port
- run `tailscale --socket <profile.sock> ...` commands
- capture profile logs
- avoid touching the official GUI client

This backend remains useful for comparison with documented daemon behavior and for cases where embedded tsnet is not acceptable.

### `internal/proxy`

Local router.

Responsibilities:

- listen on loopback
- parse HTTP CONNECT
- proxy absolute-form HTTP requests
- handle SOCKS5 domain CONNECT
- classify the target host
- reject raw IP literals unless they match an explicit profile-owned IP route
- dial exactly one selected profile
- stream bytes bidirectionally
- flush dynamic, chunked, and unknown-length responses immediately
- log profile, host, status, timing, bytes, and streaming mode

No TLS interception is performed. HTTPS remains opaque after CONNECT.

For embedded profiles, non-IP dial targets are resolved through the selected profile's Tailscale DNS view. This is required for app connectors, split DNS zones, and subnet-routed services behind private names. The resolver is not global: it is bound to one profile and is only reached after the suffix classifier has selected that profile.

### `internal/pac`

Generates PAC JavaScript from config.

Behavior:

- matching profile-owned suffixes return `PROXY 127.0.0.1:43100`
- localhost and non-matching public hosts return `DIRECT`
- generated rules preserve suffix ownership exactly

### `internal/pathdiag`

Path diagnostics.

For a target host or URL it reports:

- normalized target
- selected profile
- matched profile backend
- profile-scoped DNS resolution for embedded `tsnet` targets
- profile socket/proxy/UDP config
- profile status JSON summary
- peer found in status JSON
- Tailscale ping path: direct, DERP, peer relay, or missing
- optional netcheck
- optional HTTP timing through the TailMux router
- recommendations

The message `profile is in userspace networking mode: PAC/proxy routing is expected; TUN mode requires a separate route manager phase` applies only to non-`tsnet` profiles. It means the profile is intentionally not installing kernel routes.

### `internal/manager`

Shared service used by GUI and TUI.

Responsibilities:

- list profile summaries concurrently
- login/logout profiles
- save generated config through the GUI service
- run path diagnostics

## Data Directories

Recommended layout:

```text
~/Library/Application Support/TailMux/
  logs/
    router.log
    work.tailscaled.log              # only for external backend
    personal.tailscaled.log          # only for external backend
  run/
    work.sock                        # external backend socket
    personal.sock                    # external backend socket
  tailnets/
    work/                            # tsnet or tailscaled state
    personal/
```

Directories containing identity state or sockets should be mode `0700`.

## Routing Rule Semantics

Given:

```yaml
profiles:
  work:
    suffixes:
      - ".taild63bef.ts.net"
      - ".work.example.com"
  personal:
    suffixes:
      - ".tail2c15f0.ts.net"
      - ".home.example.com"
```

Expected decisions:

- `banihashemi-mac.taild63bef.ts.net` -> `work`
- `grafana.work.example.com` -> `work`
- `claudios-mac-mini-1.tail2c15f0.ts.net` -> `personal`
- `nas.home.example.com` -> `personal`
- `unknown.ts.net` -> deny
- `192.168.1.10` -> deny by default

Overlap examples rejected by validation:

- `.ts.net` and `.tail2c15f0.ts.net` assigned to different profiles
- `.example.com` and `.work.example.com` assigned to different profiles

## Runtime Modes

### Foreground

```bash
tailmux up
```

Starts selected profile backends, router, and PAC server until interrupted.

### GUI Service

```bash
tailmux gui serve
```

Serves the local management page and JSON API. The CLI remains the source of truth.

### TUI

```bash
tailmux tui --target <host-or-url> --netcheck --watch
```

Renders profile summaries and optional path reports in the terminal.

### Future Launchd

Launchd installation is planned but not current source of truth. It must preserve the same stop/start semantics and must not mutate system network settings silently.

## Technology Decisions

### Go

Chosen for core runtime because it matches the Tailscale ecosystem, has strong networking primitives, supports small static binaries, and gives simple concurrency for proxying.

### tsnet

Preferred current backend. It reduces local hop count and lets TailMux own profile dialers directly while still preserving separate profile identity and state.

### tailscaled userspace backend

Compatibility path. It mirrors documented Tailscale CLI daemon behavior and remains useful for debugging, but it is no longer the recommended new-profile default.

### Swift/macOS native app

Future UI path. A native app should wrap the CLI/service contracts instead of reimplementing routing.

### Network Extension

Separate future route-manager phase. It requires a new design because transparent routing must prove hostname-to-flow attribution, user consent, entitlements, signing, and no cross-profile fallback.
