diff --git a/modules/build-types/server.nix b/modules/build-types/server.nix index 53bc190..5455065 100644 --- a/modules/build-types/server.nix +++ b/modules/build-types/server.nix @@ -44,14 +44,8 @@ in exit 0 fi - # Pool exists on a device but not yet imported — let the standard - # zfs-import-${poolName}.service handle it normally. - if zpool import -d /dev/disk/by-id -N "${poolName}" 2>/dev/null; then - exit 0 - fi - - # No pool found at all. Create it on the Proxmox data disk (scsi1), - # which appears as /dev/disk/by-id/scsi-*drive-scsi1 inside the VM. + # Locate the data disk first — used for both the fallback import + # attempt and, only if the disk is genuinely blank, pool creation. DATA_DISK="" for candidate in /dev/disk/by-id/scsi-*drive-scsi1; do [[ "$candidate" == *-part* ]] && continue @@ -63,8 +57,31 @@ in exit 1 fi + # Try importing via the by-id symlink directory first (normal path), + # then fall back to scanning the disk directly. The two-step exists + # because of a udev race: systemd-udev-settle.service can clear before + # /dev/disk/by-id/ entries are fully populated, causing the first + # import to fail even when the pool is intact on the disk. + if zpool import -d /dev/disk/by-id -N "${poolName}" 2>/dev/null; then + exit 0 + fi + if zpool import -d "$DATA_DISK" -N "${poolName}" 2>/dev/null; then + exit 0 + fi + + # Both import attempts failed. Before creating a new pool, verify the + # disk is genuinely blank — if ZFS label metadata is present the import + # failed for some other reason and we must not clobber existing data. + if zdb -l "$DATA_DISK" 2>/dev/null | grep -q "name: '${poolName}'"; then + echo "zfs-init-${poolName}: $DATA_DISK has ZFS pool '${poolName}' metadata but import failed — refusing to overwrite existing data. Run 'zpool import -d $DATA_DISK ${poolName}' manually to investigate." >&2 + exit 1 + fi + + # Disk is genuinely blank: create the pool. -f is intentionally + # omitted so that if we somehow reach this point with an existing pool + # on the disk, zpool refuses rather than silently destroying data. echo "zfs-init-${poolName}: creating pool on $DATA_DISK" - zpool create -f "${poolName}" "$DATA_DISK" + zpool create "${poolName}" "$DATA_DISK" ${lib.concatMapStrings (ds: '' zfs create "${poolName}/${ds}" '') poolDatasets} diff --git a/modules/build-types/tailscale-router.nix b/modules/build-types/tailscale-router.nix index bf3e1b2..557eef3 100644 --- a/modules/build-types/tailscale-router.nix +++ b/modules/build-types/tailscale-router.nix @@ -3,6 +3,7 @@ { imports = [ ../tailscale/subnet-router.nix + ../tailscale/ts-dns-forwarder.nix ../beszel/enable-agent.nix ]; diff --git a/modules/tailscale/ts-dns-forwarder.nix b/modules/tailscale/ts-dns-forwarder.nix new file mode 100644 index 0000000..1745177 --- /dev/null +++ b/modules/tailscale/ts-dns-forwarder.nix @@ -0,0 +1,52 @@ +{ vars, ... }: + +{ + # Run dnsmasq on the LAN interface as a forwarding-only resolver for + # *.ts.net (Tailscale MagicDNS names). FreeIPA's bind-dyndb-ldap + # cannot reach 100.100.100.100 (Tailscale's internal resolver) directly + # because the DC is not a Tailscale node. This host IS a Tailscale node + # and can reach 100.100.100.100 via its tailscale0 interface, so it + # acts as an intermediary: FreeIPA has a conditional forward zone for + # ts.net pointing here (vars.tailscaleRouterIp), and this dnsmasq + # instance forwards those queries onward to Tailscale's resolver. + # + # Configure FreeIPA once after deploying this host: + # kinit admin + # ipa dnsforwardzone-add ${vars.tailnetDomain} \ + # --forwarder=${vars.tailscaleRouterIp} \ + # --forward-policy=only + # Note: IPA refuses to shadow ts.net (a real public TLD); use the + # tailnet-specific subdomain (vars.tailnetDomain) instead. + services.dnsmasq = { + enable = true; + settings = { + # Listen only on the LAN interface — not tailscale0 or loopback. + # bind-interfaces prevents dnsmasq from binding to 0.0.0.0 and + # then filtering by interface later; combined with `interface` this + # ensures it genuinely listens only on eth0. + bind-interfaces = true; + interface = [ vars.lxcLanInterface ]; + + # Forward-only: no local /etc/hosts or /etc/resolv.conf reading, + # no negative caching of NXDOMAIN for names this instance doesn't + # serve. All ts.net queries come from FreeIPA's conditional forwarder + # and must be answered by Tailscale's resolver. + no-hosts = true; + no-resolv = true; + + # Tailscale's internal "Quad100" resolver — reachable from any + # Tailscale node via the tailscale0 interface. Scoped to the + # specific tailnet subdomain (vars.tailnetDomain) rather than + # all of ts.net: FreeIPA refuses to shadow ts.net (a real public + # TLD with DNSimple nameservers) so the conditional forward zone + # in FreeIPA must use the tailnet-specific subdomain instead: + # ipa dnsforwardzone-add ${vars.tailnetDomain} \ + # --forwarder=${vars.tailscaleRouterIp} \ + # --forward-policy=only + server = [ "/${vars.tailnetDomain}/100.100.100.100" ]; + }; + }; + + networking.firewall.allowedUDPPorts = [ 53 ]; + networking.firewall.allowedTCPPorts = [ 53 ]; +}