201 lines
12 KiB
Markdown
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.
|