# TailMux - System-Wide Access And CLI Routing

Date: 2026-06-17

Status: active. `tailmux run`, `tailmux env`, and `tailmux tunnel` ship today. DIRECT pass-through for `tailmux run` is a documented, **not-yet-implemented** option recorded here for future memory.

## Summary

TailMux routes profile-owned traffic without mutating system DNS. "System-wide" access is delivered through three distinct paths, because HTTP and raw TCP cannot share a single transparent switch on macOS without breaking the per-profile isolation model.

| Client | Mechanism | Notes |
|---|---|---|
| Browser / CFNetwork apps | system PAC (auto-proxy URL) | already active; PAC returns the profile proxy for owned suffixes, `DIRECT` otherwise |
| CLI HTTP clients (curl, git, npm, wget) | `tailmux run` / `tailmux env` | sets `HTTP(S)_PROXY` / `ALL_PROXY` for one command (or the shell) |
| Raw TCP (SMB, RDP, SSH, databases) | `tailmux tunnel` | loopback-only TCP tunnel through the owning profile |

## Why there is no single global switch

- Tools that do not honor the macOS PAC (`curl` and most CLIs) bypass the system proxy entirely and then fail DNS resolution, because TailMux deliberately never mutates the system resolver and `.ts.net` names resolve only inside a tailnet.
- Raw-TCP protocols (SMB/445, RDP/3389, SSH/22, database ports) are not HTTP and ignore proxy environment variables, so they need an explicit loopback tunnel rather than a proxy.

## CLI HTTP: `tailmux run` and `tailmux env`

`tailmux run <command>` runs a single command with `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` pointed at the local proxies, without polluting the surrounding shell. It propagates the wrapped command's exit code and forwards stdin/stdout/stderr.

```bash
tailmux run curl https://grid.tail2c15f0.ts.net/
```

`tailmux env` prints the same exports for `eval "$(tailmux env)"` when an entire shell should route through TailMux.

### Caveat: deny-by-default and mixed traffic

The local proxy is fail-closed: it routes profile-owned suffixes and denies everything else. Unclassified `.ts.net` hostnames, raw IP literals, and any non-owned host return an error (HTTP 502 / 403 on the proxy path). Because proxy environment variables send **all** of a command's traffic to the proxy, this matters:

- A command that talks **only** to owned `.ts.net` hosts works:
  `tailmux run curl https://grid.tail2c15f0.ts.net/` -> 200.
- A command that **also** reaches the public internet gets its non-`.ts.net` calls denied with 502, e.g. `tailmux run npm install` (public registry + a private host).

This is intentional today: the proxy keeps its job narrow and never carries arbitrary traffic.

### Future option: DIRECT pass-through (not implemented)

To make `tailmux run` robust for mixed-traffic commands, the proxy could `DIRECT`-tunnel unclassified hosts that are **not** `.ts.net` straight to their origin, while still:

- **denying unclassified `.ts.net` hostnames** (hard constraint: never route an unclassified `.ts.net`), and
- **denying raw IP literals** that are not configured in a profile's `ip_routes`.

Routing semantics with pass-through enabled:

| Target | Behaviour |
|---|---|
| owned suffix (`*.tail2c15f0.ts.net`, custom suffix) | routed to the owning profile (unchanged) |
| unclassified `*.ts.net` | DENIED (unchanged - preserves the isolation invariant) |
| raw IP not in `ip_routes` | DENIED (unchanged) |
| any other public host (`registry.npmjs.org`, ...) | `DIRECT` tunnel to origin (**new**) |

Security reasoning: a `DIRECT` tunnel for a public host grants the local caller nothing it could not already reach directly (the proxy binds loopback-only and dials with the host's own network stack), and it never bridges tailnets. PAC users are unaffected, since the PAC already returns `DIRECT` for non-owned hosts so that traffic never reaches the proxy.

Recommended shape if/when implemented: gate it behind an explicit, default-off config flag (for example `router.direct_passthrough`) so the strict default posture is preserved and the behaviour is opt-in. Add regression tests proving unclassified `.ts.net` and unowned raw IPs are still denied with the flag on.

## Raw TCP: `tailmux tunnel`

For protocols that are not HTTP, open a loopback TCP tunnel through the owning profile and point the client at the local port. The tunnel selects the profile from the hostname suffix (or `--profile` for raw-IP targets owned by exactly one profile), resolves the name inside that tailnet, and relays raw TCP.

```bash
# SMB
tailmux tunnel --listen 127.0.0.1:1445 nas.tail2c15f0.ts.net:445
mount_smbfs //user@127.0.0.1:1445/Share /Volumes/Share   # or Finder: smb://127.0.0.1:1445

# RDP
tailmux tunnel --profile work --listen 127.0.0.1:13389 10.10.20.15:3389
```

Notes and caveats:

- Put the port in the client URL (`smb://127.0.0.1:1445`). Binding privileged local ports (445) needs root and clashes with the OS's own SMB service.
- Authentication that binds to the real hostname (Kerberos / AD SSO ties the service ticket to the SPN) typically falls back to username/password when the server is reached via `127.0.0.1`. NTLM / password / guest work as-is. SMB signing is unaffected (the tunnel is transparent end-to-end).
- The tunnel is a foreground process and must stay running while the connection or mount is open. It runs fine alongside the menu bar app.

## Related

- `docs/02-architecture.md` - routing and proxy model
- `docs/04-security-isolation.md` - isolation invariants and fail-closed behaviour
- `docs/07-custom-domain-dns-plan.md` - custom domains, raw IP / RDP tunnel guidance
