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/hosts/pinix/localServices/tailnet-dns.nix
pijukka c8cfdcd88e host(pinix): add live intent and ereader viewer services
Both run on pinix, published only through the pinix Tailnet namespace and
Traefik's web-internal entrypoint (unbound local-data + Headscale split
DNS for intent./ereader.domain2). Live Intent reads its OpenAI key from
the new pinix-only agenix secret.

ereaderViewer is a path: flake input for now; the repo split plan moves
it into its own repository on cgit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:18:55 +02:00

210 lines
11 KiB
Nix

{ 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";
};
};
}