This repository has been archived on 2026-09-17. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
nixos-config/docs/pinix-tailnet-dns.md

201 lines
12 KiB
Markdown

# Pinix public and private DNS bootstrap
## Boundaries
| Component | Address / name | Role |
| --- | --- | --- |
| Cloudflare | `dex.lukasrosier.com`, `head.lukasrosier.com`, `lldap.lukasrosier.com` | DNS-only CNAMEs to `ipv6-test.0ff.space`; public bootstrap |
| BIND on pinix | existing loopback / LAN IPv6 listeners | ACME challenge zone and candidate public zone; no recursive service |
| Existing Unbound | pinix `100.96.0.4`, `fd7a:115c:a1e0:96::4` | Resolver for the shared pinix Tailnet |
| New Unbound | new tailnet `100.96.0.1`, `fd7a:115c:a1e0:96::1` | Private DNS inside network namespace `pinix-dns` |
| Headscale MagicDNS | `hn.lukasrosier.com` | New tailnet device names |
The main nodes currently use one Headscale user, `lukas`:
| Node | IPv4 | Headscale user |
| --- | --- | --- |
| `pinix` | `100.96.0.4` | `lukas` |
| `tnix` | `100.96.0.5` | `lukas` |
| `blunix` | `100.96.0.6` | `lukas` |
| `pinix-dns` | `100.96.0.1` | `pinix-dns` (technical) |
The iPhone is registered as `localhost-1` under `lukas` at `100.96.0.2`.
The former `localhost` node under `admin` was removed after the migration.
The new private service zone is `int.lukasrosier.com`. Initially only
`dns.int.lukasrosier.com` exists. Unknown names in this zone return NXDOMAIN
locally instead of falling through to the public wildcard on blunix.
Other private services must be added with their actual **new** tailnet
addresses and appropriate listener/firewall/access policy. The DNS node is
not a subnet router, exit node or proxy for the old tailnet. Since
2026-09-14 it forwards exactly two ports from its own tailnet address to the
host: TCP 443 to Traefik `web-internal` on `[fd42:7069:6e69:78::1]:8443` and
TCP 22 to sshd (for `git@`). The first such service is
`git.lukasrosier.com` (AAAA only, `hosts/pinix/localServices/gitserver/`,
`docs/intent/gitserver-pinix.md`).
The existing `*.lukasrosier.com` certificate does not cover `*.int.lukasrosier.com`;
HTTPS services in this subdomain will need their own certificate coverage.
## Isolation and IPv6-only uplink
`tailnet-dns.nix` adds `tailscaled-pinix` and `unbound-pinix` in an isolated
network namespace, with separate Tailscale state and control socket. The
main `tailscaled` service now belongs to the shared pinix Tailnet as the
`pinix` node; the technical namespace keeps its separate `pinix-dns`
identity. Headscale allocates `100.96.0.0/24` and
`fd7a:115c:a1e0:96::/64` (live since 2026-09-14). `headscale nodes
backfillips` (0.29.3) does **not** move existing addresses outside these
prefixes, it only fills missing families; migrating a node means deleting and
re-registering it. These remain subsets of the Tailscale address ranges.
The old blunix control plane remains enabled for legacy and recovery nodes.
A veth pair uses `fd42:7069:6e69:78::1/64` (host) and `::2` (namespace).
Host forwarding permits this namespace's IPv6 egress only through `wan0`,
masquerades that single source, and permits only established return traffic.
Connections to host TCP 443 allow Headscale/Dex bootstrap without Mango
hairpinning. No forwarding to the old tailscale interface is allowed.
Inside the namespace, IPv6 forwarding is enabled for the two DNAT'd ports
only: FORWARD policy is DROP, SNAT to `::2` keeps host replies off the host's
own `fd7a:115c:a1e0::/48` route on `tailscale0`. IPv4 forwarding stays off.
UDP 41642 (the node's WireGuard port) is DNAT'd from every local address of
pinix into the namespace, like a router port forward, and replies of those
flows may leave `pdns-host` towards any interface. Without it the node only
appears as pinix's masqueraded public IPv6, packets from the LAN to that
address never reached the namespace, and peers stayed on DERP. Measured
2026-09-14 from tnix: `via DERP(fra)` ~100 ms before, `direct` 1 ms after.
Host `wan0.accept_ra=2` preserves router advertisements with host forwarding
enabled. No static address is added to `wan0`; doing so would disable
dhcpcd router solicitation. Host DNS uses the router DNS64 at `127.0.0.2`.
The new Unbound answers private records locally and forwards other queries
to Telekom's DNS64 resolver. This differs from the existing recursive Unbound:
the IPv6-only uplink cannot reliably contact IPv4-only authoritative servers
directly. Local validation of upstream-synthesized DNS64 answers is disabled;
this instance does not claim independent DNSSEC validation. Headscale
distributes this resolver via split DNS only for names it answers itself
(currently `git.lukasrosier.com`), preserving normal client DNS for other
domains -- a split for all of `lukasrosier.com` would make `head.` and `dex.`
unresolvable for clients whenever this resolver is down. The DNS node itself uses `--accept-dns=false`.
## Public records
`scripts/configure-pinix-identity-dns.sh` defaults to read-only `--check`.
With `--apply` it creates only missing records after preflighting all three names,
and refuses to replace conflicting records. Run it with the existing agenix
Cloudflare EnvironmentFile using systemd, with curl and jq on PATH; never
pass or print the token on the command line.
Created on 2026-09-13, TTL 300, `proxied=false`:
- `dex.lukasrosier.com`: record ID `ecfe4bc08f65904f34cf0c4631d4990b`.
- `head.lukasrosier.com`: record ID `f502f15c92924d4707efbdbaf7bdf456`.
- `lldap.lukasrosier.com`: record ID `ce66f3caba1101c839c4985be413fdb1`.
Both target `ipv6-test.0ff.space`. The target, wildcard, mail records,
delegation and `0ff.space` control-plane records were not changed.
Public TLS-verified discovery and health checks passed from blunix.
## Enrolment and validation
After activation, run `scripts/enrol-pinix-dns.sh` as root on pinix with
headscale, tailscale and jq on PATH. It creates only the dedicated
`pinix-dns` machine user if missing and a single-use, ten-minute pre-auth
key. The key exists only in a root-only temporary file in `/run`, removed
on exit. Persistent reconnects use `/var/lib/tailscale-pinix/tailscaled.state`;
no old auth key is reused and no plaintext credential enters the repository.
The script verifies both expected node addresses and fails on a mismatch.
Do not advertise this resolver until enrolment, TCP/UDP DNS and isolation
checks have passed. User OIDC login remains a separate test; machine-key
enrolment does not prove LDAP group membership or the login callback.
Rollback: activate the previously recorded system generation. That removes
the namespace services and scoped forwarding rules while preserving the
old tailnet. Keep the new persistent state for recovery. The two public
CNAMEs are independent of this namespace and need not be removed to roll
back private DNS; any public-DNS rollback should target only the IDs above.
## Read-only audit: 2026-09-13, 18:16 UTC
> Historical. Superseded by the activation on 2026-09-14 (prefix change,
> enrolment of `pinix-dns`, migration of `tnix`, removal of the orphan
> `end0` masquerade rule); see `docs/intent/gitserver-pinix.md`.
Checkout: `/home/builder/platform/nixos-config`, HEAD `dc1595a`, including
existing uncommitted DNS work. The historical `/home/drone/nixos-config`
does not exist on tnix. **The DNS candidate is not ready for activation.**
- Pinix running system and persistent profile both point to
`/nix/store/pbssq8gmmnv34mjqq1zsw9dp8pha0pfw-nixos-system-pinix-26.11.20260910.8ce4ef6`.
`tailscaled`, `unbound`, `pinix-router-dns`, `dex-ldap-env`, `dex`,
`headscale`, `lldap`, `traefik`, and `bind` are active; no failed units.
- `pinix-dns-netns`, `tailscaled-pinix`, and `unbound-pinix` are not found.
Neither a pinix network namespace nor `pdns-host` exists. New private
DNS TCP/UDP, private-zone NXDOMAIN and pinix namespace isolation remain
untested; they must not be reported as passing.
- Live Headscale uses `100.64.0.0/10` and `fd7a:115c:a1e0::/48` with three
nodes: `tnix-pinixnet` (.1, offline), `localhost` (.2, online), and `tnix`
(.3, online). No dedicated DNS node exists. The candidate prefix change
is now a migration. The enrolment helper refuses first-node bootstrap
on a populated database before creating users or keys.
- Live and evaluated Headscale DNS have `override_local_dns=false`, no
global resolvers and an empty split map. The private zone is not yet
distributed. Add the scoped resolver only after approved address planning,
activation, enrolment and successful private DNS tests.
- From tnix's host namespace, UDP and TCP queries to old-tailnet
`100.64.0.3` resolve both identity names to `100.64.0.3`. Queries to LAN
`192.168.50.1` return their public CNAMEs to `ipv6-test.0ff.space`.
TLS-verified Dex discovery over the old-tailnet address passes.
- On pinix, UDP/TCP queries to `127.0.0.2` synthesize the expected
`64:ff9b::c000:aa` and `64:ff9b::c000:ab` for `ipv4only.arpa`.
BIND at `127.0.0.1` serves the Dex CNAME authoritatively. Old Unbound
responds over IPv4 and IPv6 transport; `head` AAAA returns NOERROR/NODATA
because its local override defines only an A record.
- Original pinix Tailscale is Running/online at `100.64.0.3` and
`fd7a:115c:a1e0::3`, controlled by `https://head.0ff.space`.
Both tnix Tailscale services and `tnix-pinixnet-netns` are active.
Inside that namespace `.3` is local and old-pinix HTTPS at that address
fails locally; the same request succeeds outside. This verifies that
overlap is isolated, not all possible paths. Namespace IPv4/IPv6
forwarding is 1 as declared for SSH DNAT; host IPv6 rules allow its uplink
and reject other forwarding. SSH to tnix failed host-key verification;
its read-only namespace checks were performed locally with sudo instead.
- TLS-verified Dex discovery and Headscale health pass from pinix and
publicly from blunix. Issuer: `https://dex.lukasrosier.com/dex`.
No actual user login, LDAP credential validation or OIDC callback was
performed. Registered nodes alone are not login evidence.
### Known drift and approval boundary
Pinix has orphan `pinix-dns-forward` rules and two scoped masquerade rules
for `fd42:7069:6e69:78::2/128`, through obsolete `end0` and through `wan0`,
although the DNS namespace is absent. The candidate declares only `wan0`;
its stop script does not remove the old `end0` rule. The origin of these
live imperative rules is not established. No cleanup was performed.
After explicit approval, remove the obsolete rule with
`ip6tables -t nat -D POSTROUTING -s fd42:7069:6e69:78::2/128 -o end0 -j MASQUERADE`.
A full rollback also requires removing both namespace FORWARD jumps, the
custom chain and its `wan0` masquerade rule after confirming they remain
unused. Alternatively reconcile intended rules through reviewed DNS
activation. Neither action is authorized by this audit.
Resolve address allocation and migration of the three registrations before
any switch. A passing evaluation does not establish deployment readiness.
No remote files, services, registrations or firewall rules were changed.
No full-system build, dry activation or activation was run.
### Local validation
- `nix flake check path:. --no-build`: passed, existing deprecation warnings.
- Focused build of `pinix-dns-netns`, `tailscaled-pinix`, `unbound-pinix`,
`unbound` and `bind` units/configuration dependencies: passed.
- `sudo -n bash scripts/test-dex-ldap-env.sh` on tnix: all three synthetic
systemd EnvironmentFile round trips passed; not a user login test.
- Shell syntax checks and `git diff --check`: passed.
- Mocked populated-database enrolment test: refused before any mutation.
- `unbound-checkconf` on the generated candidate, changing only its absent
runtime directory to `/tmp` in a temporary copy: passed. The actual
systemd runtime directory and namespace still need post-activation tests.