# TailMux - Custom Domain Routing And DNS Plan

Date: 2026-05-05

Status: active feature, routing model supported through `suffixes`; explicit IP/CIDR routes are supported for profile-owned raw TCP destinations.

Primary source: https://willnorris.com/2023/tailscale-custom-domain/

## Executive Summary

TailMux supports custom domains as profile-owned routing aliases first, not as a built-in DNS server.

The current router already understands arbitrary suffixes. A profile can own both its generated Tailscale suffix and one or more custom suffixes:

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

  personal:
    suffixes:
      - ".tail2c15f0.ts.net"
      - ".home.example.com"
```

TailMux's job is to preserve profile ownership:

```text
https://grafana.work.example.com
        |
        | CONNECT keeps hostname intact
        v
TailMux router matches .work.example.com
        |
        v
work profile tsnet dialer
        |
        v
work tailnet
```

DNS publication is separate infrastructure. TailMux may later generate DNS artifacts, but it should not become one resolver that can see multiple tailnets unless every zone is profile-owned and fail-closed.

For Tailscale app connectors and subnet routers, TailMux preserves the original application domain whenever possible. Tailscale app connectors discover and advertise routes from DNS lookups for configured app domains; subnet routers expose approved IP ranges. TailMux's role is to select exactly one profile before dialing, then let that profile's Tailscale identity perform DNS, app connector route discovery, and subnet routing inside its own tailnet. A `tsnet` profile must set `accept_routes: true` before TailMux accepts those advertised routes.

For embedded `tsnet` profiles, TailMux now performs profile-scoped DNS resolution through the selected profile's Tailscale LocalAPI before opening the TCP connection. That means custom app domains and split-DNS names are resolved by the selected tailnet, not by macOS global DNS and not by another TailMux profile. Private CNAME aliases are followed inside the same profile-scoped resolver with a bounded hop limit; TailMux does not fall back to system DNS if the alias chain fails. IP literals skip DNS and must match explicit `ip_routes`.

## What The Reference Article Shows

Will Norris describes publishing Tailscale devices under a controlled custom domain by:

- delegating a DNS sub-zone to CoreDNS
- running CoreDNS with a Tailscale-aware plugin
- joining that DNS server to the tailnet
- optionally creating public CNAMEs to the delegated names

Important caveats:

- custom DNS does not recreate all MagicDNS behavior
- short-name search behavior is not the same as MagicDNS
- Tailscale HTTPS certificates do not automatically cover the custom domain
- Serve/Funnel remain tied to Tailscale-supported names and policies
- public DNS can reveal device names or Tailscale IPs
- device visibility depends on the DNS server's tailnet identity and ACLs

## Product Decision

Build custom-domain routing aliases before managed DNS.

### Include Now

- Document non-`ts.net` suffixes in `profiles.<name>.suffixes`.
- Generate PAC rules for custom suffixes.
- Route custom suffixes through the same classifier as tailnet suffixes.
- Reject overlapping ownership.
- Diagnose selected profile for custom-domain targets.
- Document DNS and TLS caveats.
- Support explicit profile-owned `ip_routes` for raw TCP and SOCKS5 IP destinations.
- Reject overlapping `ip_routes` across profiles.

### Exclude Now

- No built-in authoritative DNS server.
- No DNS provider writes.
- No local macOS DNS mutation.
- No resolver that combines multiple tailnets into one view.
- No Serve/Funnel custom-domain promise.
- No claim that `*.ts.net` certificates cover custom names.
- No automatic classification of raw IPs that are not configured in `ip_routes`.
- No support for the same raw IP/subnet being owned by two TailMux profiles.

## DNS Setup Patterns

### Pattern A - Public DNS Alias

Use when public disclosure of the name is acceptable.

Examples:

```text
grafana.work.example.com. CNAME grafana.taild63bef.ts.net.
nas.home.example.com.     CNAME nas.tail2c15f0.ts.net.
```

or:

```text
grafana.work.example.com. A 100.x.y.z
```

TailMux responsibility:

- classify `.work.example.com` to `work`
- forward the original hostname through the work profile
- fail closed if work is unavailable
- if the work profile is embedded `tsnet`, resolve the hostname through the selected profile's Tailscale LocalAPI DNS, then dial the resolved address through that same profile

Limitations:

- HTTPS needs a certificate for `grafana.work.example.com`
- CNAME to a `*.ts.net` name does not make a `*.ts.net` certificate valid for the custom host
- public DNS can reveal internal naming

### Pattern B - Split DNS

Use when custom names should resolve only inside the related tailnet.

Setup outside TailMux:

1. Run an authoritative resolver for `work.example.com`.
2. Make it reachable from the work tailnet.
3. Configure work tailnet DNS with a restricted nameserver for `work.example.com`.
4. Repeat separately for personal zones.

TailMux responsibility:

- route `.work.example.com` to work
- route `.home.example.com` to personal
- avoid system DNS mutation
- keep DNS resolution profile-scoped by using the selected profile's Tailscale LocalAPI DNS, not by using the macOS global resolver first

Limitations:

- the resolver sees only what its tailnet identity and ACLs allow
- one resolver identity per tailnet is safer than one process connected to multiple tailnets

### Pattern C - CoreDNS With Tailscale Awareness

Use when automatic records from tailnet device names are needed.

Recommendation:

- treat CoreDNS as an external component
- run one CoreDNS/Tailscale identity per tailnet or per zone
- use ACLs/tags to limit visibility
- do not have one DNS process read multiple TailMux profiles unless profile-to-zone ownership is explicit and tested

TailMux responsibility in this phase:

- document the pattern
- optionally generate a Corefile/zone snippet later

## TLS Guidance

TailMux routes traffic; it does not make certificates valid.

Options for custom HTTPS names:

- public ACME DNS-01 for the custom zone
- private CA trusted by the client devices
- reverse proxy such as Caddy using a supported DNS provider
- plain HTTP only for low-risk internal services

Tailscale-managed certificates are for the tailnet DNS names, not arbitrary custom domains.

## Raw IP And RDP Guidance

Raw IP routing is intentionally more restrictive than hostname routing.

Hostname routing is safe because the request still carries a profile-owned name:

```text
erp.apps.work.example.com -> suffix .apps.work.example.com -> work profile
```

Raw IP routing does not carry that ownership signal. Two separate tailnets can expose the same private subnet, and two subnet routers or app connectors can advertise overlapping routes inside their own tailnets. TailMux cannot infer the correct tailnet from `10.0.0.15` or `100.x.y.z` without extra configuration.

Use `ip_routes` only when the route is uniquely owned by one profile:

```yaml
profiles:
  work:
    suffixes:
      - ".taild63bef.ts.net"
      - ".apps.work.example.com"
    accept_routes: true
    ip_routes:
      - "10.10.20.0/24"
      - "100.64.0.10"
```

Rules:

- unconfigured IP literals are denied
- overlapping `ip_routes` across profiles are rejected at config validation
- default routes such as `0.0.0.0/0` are rejected
- loopback, multicast, and unspecified ranges are rejected
- no failed IP route falls back to another profile

For RDP, prefer a DNS name such as `rdp-host.apps.work.example.com` when possible. If the RDP client can use SOCKS5, a configured `ip_routes` entry can route the raw IP through the selected profile. For GUI RDP clients that only accept host/port, use an explicit loopback tunnel:

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

Then connect the RDP client to `127.0.0.1:13389`. This keeps the profile decision explicit and avoids pretending that `10.10.20.15` can be globally classified when the same private IP may exist in more than one tailnet.

## Implementation Plan

### Milestone 1 - First-Class Custom Suffix And IP Route Tests

Tasks:

- Add tests proving `grafana.work.example.com` selects work.
- Add tests proving `nas.home.example.com` selects personal.
- Add tests proving `evilwork.example.com` does not match `.work.example.com`.
- Add tests proving `.example.com` and `.work.example.com` cannot be owned by different profiles.
- Ensure PAC output includes custom suffixes.
- Show matched suffix/profile in diagnostics where useful.
- Add tests proving configured IP routes select the owning profile.
- Add tests proving raw unowned IPs remain denied.
- Add tests proving overlapping IP routes across profiles are rejected.

Acceptance criteria:

- browser traffic for configured custom domains goes through the expected profile
- non-matching public domains remain direct in PAC mode
- no system DNS or route settings are changed
- SOCKS5/raw TCP traffic for configured IP routes goes through the expected profile
- overlapping subnets cannot start the router
- `tailmux dns query --profile <name> <host>` resolves through the selected embedded profile's Tailscale DNS view
- private CNAME aliases are followed only through the selected profile's Tailscale DNS view
- `tailmux diag path <url>` includes a profile DNS report for embedded `tsnet` custom-domain targets

### Milestone 2 - DNS Export

Proposed command:

```bash
tailmux dns export --profile work --zone work.example.com --format coredns
tailmux dns export --profile work --zone work.example.com --format bind
```

Rules:

- read status data only from the selected profile
- include only peers visible to that profile
- write to stdout by default
- never write provider DNS records by default
- one profile per export invocation

Example output:

```text
grafana 300 IN CNAME grafana.taild63bef.ts.net.
```

or, when explicitly requested:

```text
grafana 300 IN A 100.x.y.z
```

Acceptance criteria:

- work export cannot include personal machines
- personal export cannot include work machines
- output is deterministic and reviewable

### Milestone 3 - Optional Provider Integration

Only after export semantics are proven:

- `tailmux dns export --format cloudflare-json`
- `tailmux dns push --provider cloudflare --profile work --zone work.example.com`
- explicit plan/confirm/apply flow
- provider tokens outside plain YAML

## Security Impact

Risks and controls:

- public DNS reveals hostnames or Tailscale IPs: document split DNS and warn during export
- broad suffix captures too much: keep overlap rejection and add broad-domain warnings
- DNS resolves before TailMux sees hostname: keep PAC/CONNECT/SOCKS5 domain routing and raw IP denial
- one DNS service sees both tailnets: require one profile per export and one resolver identity per tailnet in docs
- failed route falls back to another profile: preserve current fail-closed router behavior

## Open Questions

- Which custom zones should CQ Fabrication use in practice?
- Are custom names public, private split-DNS, or both?
- Do target tailnets permit DNS service identities with enough ACL visibility?
- Is custom-domain HTTPS required for the next iteration?
- Should DNS export default to CNAMEs or A/AAAA records?

## Source Links

- Will Norris, "Tailscale devices with a custom domain": https://willnorris.com/2023/tailscale-custom-domain/
- Tailscale MagicDNS: https://tailscale.com/docs/features/magicdns
- Tailscale DNS reference: https://tailscale.com/docs/reference/dns-in-tailscale
- Tailscale HTTPS certificates: https://tailscale.com/docs/how-to/set-up-https-certificates
- Tailscale Serve: https://tailscale.com/docs/features/tailscale-serve
- Tailscale Funnel: https://tailscale.com/docs/features/tailscale-funnel
- Tailscale custom domains with Kubernetes Gateway API: https://tailscale.com/docs/solutions/kubernetes-operator-byod-gateway-api
- coredns-tailscale: https://github.com/damomurf/coredns-tailscale
