{ config, pkgs, ... }: let ns = "pinix-dns"; hostIP = "fd42:7069:6e69:78::1"; peerIP = "fd42:7069:6e69:78::2"; dns4 = "100.96.0.1"; dns6 = "fd7a:115c:a1e0:96::1"; # Traefiks web-internal-Entrypoint (traefik.nix) auf der Host-Seite des # veth-Paars. Eigener Port, weil websecure auf :443 aller Adressen hoert. internalPort = 8443; # UDP-Port von tailscaled-pinix (--port unten), per DNAT von aussen und aus # dem LAN erreichbar, damit Peers direkt statt ueber DERP verbinden. wgPort = 41642; privateZone = "int.${config.domain2}"; resolv = pkgs.writeText "pinix-dns-resolv.conf" '' nameserver 2a01:598:7ff:0:10:74:210:221 ''; hosts = pkgs.writeText "pinix-dns-hosts" '' 127.0.0.1 localhost ::1 localhost ${hostIP} head.${config.domain2} dex.${config.domain2} ''; unboundConfig = pkgs.writeText "pinix-tailnet-unbound.conf" '' server: interface: ${dns4} interface: ${dns6} ip-freebind: yes access-control: 100.96.0.0/24 allow access-control: fd7a:115c:a1e0:96::/64 allow access-control: 0.0.0.0/0 refuse access-control: ::/0 refuse username: "" chroot: "" directory: "/run/unbound-pinix" pidfile: "" use-syslog: no do-daemonize: no hide-identity: yes hide-version: yes # Upstream is already DNS64. Local DNSSEC validation of synthesized # answers would fail, so validation stays with the upstream resolver. module-config: "iterator" local-zone: "${privateZone}." static local-data: "dns.${privateZone}. A ${dns4}" local-data: "dns.${privateZone}. AAAA ${dns6}" # Tailnet-only-Dienste auf pinix, das Gegenstueck zu den # AdGuard-Rewrites auf blunix. Nur AAAA: die Weiterleitung in den Host # laeuft ueber das IPv6-only veth-Paar (gitserver/default.nix). local-data: "git.${config.domain2}. AAAA ${dns6}" # The LLDAP board is also exposed through Traefik's web-internal # entrypoint; keep the Tailnet name on the technical node so the # namespace forwarding path is used consistently. local-data: "lldap.${config.domain2}. AAAA ${dns6}" # The annotation viewer is private as well; do not publish its address # in the public DNS zone. local-data: "ereader.${config.domain2}. AAAA ${dns6}" # Live Intent is private as well: it is published only through the # pinix Tailnet namespace and Traefik's web-internal entrypoint. local-data: "intent.${config.domain2}. AAAA ${dns6}" forward-zone: name: "." forward-addr: 2a01:598:7ff:0:10:74:210:221 ''; in { # Eigener Adressbereich des pinix-Tailnets, getrennt von blunix' # 100.64.0.x (docs/intent/gitserver-pinix.md). Bestehende Registrierungen # behalten ihre alten Adressen, bis `headscale nodes backfillips` laeuft -- # vorher pinix-dns einschreiben, damit es .1 bekommt # (scripts/enrol-pinix-dns.sh). # The old tailscaled, addresses, routes and state remain untouched. services.headscale.settings = { prefixes = { v4 = "100.96.0.0/24"; v6 = "fd7a:115c:a1e0:96::/64"; }; # Nur die Namen, die dieser Resolver wirklich selbst beantwortet. Ein # Split fuer ganz domain2 wuerde bei einem Ausfall von unbound-pinix auch # head./dex. fuer die Clients unaufloesbar machen. dns.nameservers.split."git.${config.domain2}" = [ dns6 ]; dns.nameservers.split."lldap.${config.domain2}" = [ dns6 ]; dns.nameservers.split."ereader.${config.domain2}" = [ dns6 ]; dns.nameservers.split."intent.${config.domain2}" = [ dns6 ]; }; # Only the namespace underlay is forwarded, via the IPv6-only LAN uplink. # RA reception on wan0 (accept_ra = 2) is owned by ../router-uplink.nix; # never configure a static wan0 # address, which would disable dhcpcd router solicitation on this host. boot.kernel.sysctl = { "net.ipv6.conf.all.forwarding" = 1; }; networking.firewall = { # 443: Headscale/Dex-Bootstrap des Knotens. internalPort: Tailnet-only- # Dienste. 22: git ueber SSH -- sshd ist auf pinix nur pro Interface # freigegeben (end0, wlan0, tailscale0), nicht global. interfaces.pdns-host.allowedTCPPorts = [ 22 443 internalPort ]; extraCommands = '' ip6tables -N pinix-dns-forward 2>/dev/null || true ip6tables -F pinix-dns-forward ip6tables -A pinix-dns-forward -i pdns-host -o wan0 -s ${peerIP}/128 -j ACCEPT ip6tables -A pinix-dns-forward -o pdns-host -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT ip6tables -A pinix-dns-forward -o pdns-host -d ${peerIP}/128 -p udp --dport ${toString wgPort} -m conntrack --ctstate DNAT -j ACCEPT # Antworten auf genau diese weitergeleiteten Verbindungen, auch Richtung # LAN (end0) -- die erste Regel oben erlaubt aus dem Namespace nur wan0. ip6tables -A pinix-dns-forward -i pdns-host -m conntrack --ctstate DNAT --ctdir REPLY -j ACCEPT ip6tables -A pinix-dns-forward -j REJECT ip6tables -C FORWARD -i pdns-host -j pinix-dns-forward 2>/dev/null || ip6tables -I FORWARD 1 -i pdns-host -j pinix-dns-forward ip6tables -C FORWARD -o pdns-host -j pinix-dns-forward 2>/dev/null || ip6tables -I FORWARD 1 -o pdns-host -j pinix-dns-forward ip6tables -t nat -C POSTROUTING -s ${peerIP}/128 -o wan0 -j MASQUERADE 2>/dev/null || ip6tables -t nat -A POSTROUTING -s ${peerIP}/128 -o wan0 -j MASQUERADE # WireGuard-Port des Namespace-Knotens wie eine Router-Portweiterleitung # auf jede eigene Adresse von pinix. Ohne das bleibt jede Verbindung auf # DERP: der Knoten erscheint nach aussen nur als pinix' oeffentliche # IPv6 hinter Masquerade, und ein Paket aus dem LAN an genau diese # Adresse fand nicht zurueck in den Namespace (gemessen 14.09.2026: # tnix <-> pinix-dns "via DERP(fra)", ~110 ms). -i pdns-host # ausgenommen, damit eigene Antworten des Knotens nicht umgebogen werden. ip6tables -t nat -C PREROUTING ! -i pdns-host -m addrtype --dst-type LOCAL -p udp --dport ${toString wgPort} -j DNAT --to-destination [${peerIP}]:${toString wgPort} 2>/dev/null || ip6tables -t nat -A PREROUTING ! -i pdns-host -m addrtype --dst-type LOCAL -p udp --dport ${toString wgPort} -j DNAT --to-destination [${peerIP}]:${toString wgPort} ''; extraStopCommands = '' ip6tables -D FORWARD -i pdns-host -j pinix-dns-forward 2>/dev/null || true ip6tables -D FORWARD -o pdns-host -j pinix-dns-forward 2>/dev/null || true ip6tables -F pinix-dns-forward 2>/dev/null || true ip6tables -X pinix-dns-forward 2>/dev/null || true ip6tables -t nat -D POSTROUTING -s ${peerIP}/128 -o wan0 -j MASQUERADE 2>/dev/null || true ip6tables -t nat -D PREROUTING ! -i pdns-host -m addrtype --dst-type LOCAL -p udp --dport ${toString wgPort} -j DNAT --to-destination [${peerIP}]:${toString wgPort} 2>/dev/null || true ''; }; systemd.services.pinix-dns-netns = { description = "Isolated underlay for the new pinix tailnet DNS node"; wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" "firewall.service" ]; wants = [ "network-online.target" ]; path = [ pkgs.iproute2 pkgs.procps pkgs.iptables ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; script = '' ip netns add ${ns} # IPv6-Forwarding an, weil der Namespace genau zwei Ports aus dem # Tailnet an den Host weiterreicht (unten); wie hosts/tnix/pinixnet.nix # verwirft der Kernel das DNAT-Paket sonst still. Der FORWARD-Filter # laesst nichts anderes durch -- kein Subnet-Router, kein Exit-Node. ip netns exec ${ns} sysctl -qw net.ipv4.ip_forward=0 net.ipv6.conf.all.forwarding=1 ip link add pdns-host type veth peer name pdns-peer netns ${ns} ip -6 addr add ${hostIP}/64 dev pdns-host nodad ip link set pdns-host up ip -n ${ns} link set lo up ip -n ${ns} -6 addr add ${peerIP}/64 dev pdns-peer nodad ip -n ${ns} link set pdns-peer up ip -n ${ns} -6 route add default via ${hostIP} # 443 -> Traefik web-internal, 22 -> sshd (git). SNAT auf peerIP, damit # der Host nicht ueber seine eigenen blunix-Tailnet-Routen # (fd7a:115c:a1e0::/48 auf tailscale0) antwortet. ip netns exec ${ns} ip6tables -P FORWARD DROP ip netns exec ${ns} ip6tables -A FORWARD -i tailscale-dns -o pdns-peer -d ${hostIP} -p tcp -m multiport --dports ${toString internalPort},22 -j ACCEPT ip netns exec ${ns} ip6tables -A FORWARD -i pdns-peer -o tailscale-dns -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT ip netns exec ${ns} ip6tables -t nat -A PREROUTING -i tailscale-dns -d ${dns6} -p tcp --dport 443 -j DNAT --to-destination [${hostIP}]:${toString internalPort} ip netns exec ${ns} ip6tables -t nat -A PREROUTING -i tailscale-dns -d ${dns6} -p tcp --dport 22 -j DNAT --to-destination [${hostIP}]:22 ip netns exec ${ns} ip6tables -t nat -A POSTROUTING -o pdns-peer -d ${hostIP} -p tcp -m multiport --dports ${toString internalPort},22 -j SNAT --to-source ${peerIP} ''; preStop = '' ip link delete pdns-host 2>/dev/null || true ip netns delete ${ns} ''; }; systemd.services.tailscaled-pinix = { description = "Separate Tailscale identity for pinix's new DNS network"; wantedBy = [ "multi-user.target" ]; requires = [ "pinix-dns-netns.service" ]; after = [ "pinix-dns-netns.service" "headscale.service" ]; partOf = [ "pinix-dns-netns.service" ]; serviceConfig = { NetworkNamespacePath = "/run/netns/${ns}"; BindReadOnlyPaths = [ "${resolv}:/etc/resolv.conf" "${hosts}:/etc/hosts" ]; StateDirectory = "tailscale-pinix"; StateDirectoryMode = "0700"; RuntimeDirectory = "tailscale-pinix"; RuntimeDirectoryMode = "0700"; ExecStart = "${pkgs.tailscale}/bin/tailscaled --state=/var/lib/tailscale-pinix/tailscaled.state --socket=/run/tailscale-pinix/tailscaled.sock --tun=tailscale-dns --port=${toString wgPort}"; Restart = "on-failure"; RestartSec = "5s"; }; }; systemd.services.unbound-pinix = { description = "Private DNS for the new pinix tailnet"; wantedBy = [ "multi-user.target" ]; requires = [ "pinix-dns-netns.service" ]; after = [ "pinix-dns-netns.service" "tailscaled-pinix.service" ]; partOf = [ "pinix-dns-netns.service" ]; serviceConfig = { NetworkNamespacePath = "/run/netns/${ns}"; DynamicUser = true; RuntimeDirectory = "unbound-pinix"; AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; ExecStartPre = "${pkgs.unbound}/bin/unbound-checkconf ${unboundConfig}"; ExecStart = "${pkgs.unbound}/bin/unbound -d -c ${unboundConfig}"; Restart = "on-failure"; RestartSec = "5s"; }; }; }