Commit 5662f1c

HPCesia <me@hpcesia.com>
2026-09-02 06:27:51
services: make some domain revolve directly
Connect derp directly via my hosts, and it's IP still protected by Cloudflare. Assisted-by: opencode:deepseek-v4-flash
1 parent 1a71345
modules/services/caddy/default.nix
@@ -106,14 +106,15 @@
               - secret IPv4/IPv6      (address.ipv4.secret    / address.ipv6.secret)
           '';
 
-      entries =
+      entries = lib.unique (
         lib.concatMap (
           r:
             lib.mapAttrsToList (mkEntry (resolveAddress r.source.host)) (
               lib.filterAttrs (_: conf: !(conf.tailscale or false)) r.value
             )
         )
-        (lib.filter (r: r.source.host.name == host.name) reverseProxy);
+        (lib.filter (r: r.source.host.name == host.name) reverseProxy)
+      );
 
       importEntries = lib.filter (e: e.resolution.kind == "import") entries;
 
modules/services/caddy/quirks.nix
@@ -16,6 +16,7 @@ in {
         path = "/foo/bar" # Path to reverse proxy to
         stripPath = false; # Whether to strip path prefix ("/foo/bar")
         upstream = "/"; Path to reverse proxy from
+        prefer = "direct"; # Connection policy override: null (host default), "cdn", "direct" or "tailscale"
       }
   '';
 
@@ -25,11 +26,10 @@ in {
     inherit (den.lib.policy) pipe;
   in [
     (pipe.from "reverseProxy" [
-      (pipe.collect ({host, ...}: true))
-      (pipe.transform (value:
-        lib.mapAttrs
+      (pipe.collectAll ({host, ...}: true))
+      (pipe.transform (value: (lib.mapAttrs
         (domain: conf: conf // {tailscale = lib.hasSuffix ".${tailnetDomain}" domain;})
-        value))
+        value)))
       pipe.withProvenance
     ])
   ];
modules/services/caddy/tailscale.nix
@@ -17,7 +17,7 @@
         forwardAuthBlock
         ;
 
-      entries =
+      entries = lib.unique (
         lib.concatMap (
           r:
             lib.mapAttrsToList (mkEntry {
@@ -25,7 +25,8 @@
               address = "127.0.0.1";
             }) (lib.filterAttrs (_: conf: conf.tailscale or false) r.value)
         )
-        (lib.filter (r: r.source.host.name == host.name) reverseProxy);
+        (lib.filter (r: r.source.host.name == host.name) reverseProxy)
+      );
 
       enabled = builtins.length entries > 0;
 
modules/services/direct-dns/_lib.nix
@@ -0,0 +1,59 @@
+{lib}: let
+  fromClearOrSecret = addr:
+    if addr.clearText != null
+    then {
+      kind = "clear";
+      value = addr.clearText;
+    }
+    else if addr.secret.name != null && addr.secret.file != null
+    then {
+      kind = "secret";
+      name = addr.secret.name;
+      file = addr.secret.file;
+    }
+    else null;
+
+  mkClear = value:
+    if value != null
+    then {
+      kind = "clear";
+      inherit value;
+    }
+    else null;
+
+  resolve = host: domain: conf: let
+    prefer = conf.prefer or null;
+    mode =
+      if prefer != null
+      then prefer
+      else host.settings.services.direct-dns.mode or "cdn";
+    v4 =
+      if mode == "direct"
+      then fromClearOrSecret host.address.ipv4
+      else if mode == "tailscale"
+      then mkClear host.address.ipv4.tailscale
+      else null;
+    v6 =
+      if mode == "direct"
+      then fromClearOrSecret host.address.ipv6
+      else if mode == "tailscale"
+      then mkClear host.address.ipv6.tailscale
+      else null;
+  in
+    assert lib.assertMsg (mode != "direct" || v4 != null || v6 != null)
+    "direct-dns: domain '${domain}' on host '${host.name}' prefers 'direct' but the host has no public IPv4/IPv6 configured."; {
+      inherit domain mode v4 v6;
+      hostName = host.name;
+    };
+in {
+  mkDirectEntries = reverseProxy:
+    lib.unique (
+      lib.concatMap (
+        r:
+          lib.mapAttrsToList (resolve r.source.host) (
+            lib.filterAttrs (_: conf: !(conf.tailscale or false)) r.value
+          )
+      )
+      reverseProxy
+    );
+}
modules/services/direct-dns/default.nix
@@ -0,0 +1,19 @@
+{lib, ...}: {
+  den.aspects.services.direct-dns = {
+    settings.host = {
+      mode = lib.mkOption {
+        type = lib.types.enum ["cdn" "direct" "tailscale"];
+        default = "cdn";
+        description = ''
+          Default connection policy for reverse proxy domains served by this host.
+
+          - `cdn`: resolve through public DNS (unchanged).
+          - `direct`: resolve to the host's public IP.
+          - `tailscale`: resolve to the host's tailscale IP.
+
+          Individual reverse proxy entries can override this with `prefer`.
+        '';
+      };
+    };
+  };
+}
modules/services/mihomo/dns.nix
@@ -1,16 +1,68 @@
 {
+  den,
+  lib,
+  ...
+}: let
+  inherit (import ../direct-dns/_lib.nix {inherit lib;}) mkDirectEntries;
+
+  templateName = domain: "mihomo-hosts-${lib.replaceStrings ["." ":"] ["-" "-"] domain}";
+in {
   den.aspects.services.mihomo.dns = {
-    nixos = {host, ...}: let
+    nixos = {
+      host,
+      reverseProxy,
+      config,
+      ...
+    }: let
       cfg = host.settings.services.mihomo;
+
+      directEntries = mkDirectEntries reverseProxy;
+
+      directV4Clear = lib.listToAttrs (lib.concatMap (e:
+        lib.optional (e.v4 != null && e.v4.kind == "clear") {
+          name = e.domain;
+          value = [e.v4.value];
+        })
+      directEntries);
+
+      directV4Secret = lib.listToAttrs (lib.concatMap (e:
+        lib.optional (e.v4 != null && e.v4.kind == "secret") {
+          name = e.domain;
+          value = {
+            _secret = config.vaultix.templates.${templateName e.domain}.path;
+            quote = false;
+          };
+        })
+      directEntries);
+
+      secretEntries = lib.filter (e: e.v4 != null && e.v4.kind == "secret") directEntries;
     in {
+      vaultix.secrets = lib.listToAttrs (map (e: {
+          name = e.v4.name;
+          value.file = e.v4.file;
+        })
+        secretEntries);
+      vaultix.templates = lib.listToAttrs (map (e: {
+          name = templateName e.domain;
+          value = {
+            content = builtins.toJSON [config.vaultix.placeholder.${e.v4.name}];
+            owner = "mihomo";
+            group = "mihomo";
+            mode = "0400";
+          };
+        })
+        secretEntries);
+
       services.mihomo = {
         config = {
-          hosts = {
-            "dns.alidns.com" = ["223.5.5.5" "223.6.6.6" "2400:3200::1" "2400:3200:baba::1"];
-            "doh.pub" = ["1.12.12.12" "1.12.12.21" "120.53.53.53"];
-            "dns.google" = ["8.8.8.8" "8.8.4.4" "2001:4860:4860::8888" "2001:4860:4860::8844"];
-            "cloudflare-dns.com" = ["1.1.1.1" "1.0.0.1" "2606:4700:4700::1111" "2606:4700:4700::1001"];
-          };
+          hosts =
+            {
+              "dns.alidns.com" = ["223.5.5.5" "223.6.6.6" "2400:3200::1" "2400:3200:baba::1"];
+              "doh.pub" = ["1.12.12.12" "1.12.12.21" "120.53.53.53"];
+              "dns.google" = ["8.8.8.8" "8.8.4.4" "2001:4860:4860::8888" "2001:4860:4860::8844"];
+              "cloudflare-dns.com" = ["1.1.1.1" "1.0.0.1" "2606:4700:4700::1111" "2606:4700:4700::1001"];
+            }
+            // directV4Clear // directV4Secret;
           dns = {
             enable = true;
             listen = ":${toString cfg.ports.dns}";
modules/services/headscale.nix
@@ -63,6 +63,8 @@
 
       tailnetDomain = den.aspects.services.caddy.tailnetDomain;
 
+      inherit (import ./direct-dns/_lib.nix {inherit lib;}) mkDirectEntries;
+
       derpHosts = let
         allHosts = builtins.concatMap builtins.attrValues (builtins.attrValues den.hosts);
       in
@@ -161,17 +163,24 @@
 
       # Tailnet entries (from all hosts): emit A/AAAA records pointing at the
       # owning host's tailscale addresses so MagicDNS resolves them.
-      tailnetEntries =
-        lib.concatMap (
-          r:
-            lib.mapAttrsToList (domain: conf: {
-              inherit domain;
-              source = r.source.host;
-            }) (
-              lib.filterAttrs (_: conf: conf.tailscale or false) r.value
-            )
-        )
-        reverseProxy;
+      tailnetEntries = let
+        uniqueByDomain = lib.foldl' (acc: e:
+          if builtins.any (x: x.domain == e.domain) acc
+          then acc
+          else acc ++ [e]) [];
+      in
+        uniqueByDomain (
+          lib.concatMap (
+            r:
+              lib.mapAttrsToList (domain: conf: {
+                inherit domain;
+                source = r.source.host;
+              }) (
+                lib.filterAttrs (_: conf: conf.tailscale or false) r.value
+              )
+          )
+          reverseProxy
+        );
 
       duplicateDomains =
         lib.filter (domain: builtins.length (lib.filter (e: e.domain == domain) tailnetEntries) > 1)
@@ -211,6 +220,35 @@
                 }
               ])
           tailnetEntries;
+
+      directEntries = mkDirectEntries reverseProxy;
+
+      mkRecordValue = ip:
+        if ip.kind == "clear"
+        then ip.value
+        else config.vaultix.placeholder.${ip.name};
+
+      directRecords = lib.concatMap (e:
+        lib.optionals (e.v4 != null) [
+          {
+            name = e.domain;
+            type = "A";
+            value = mkRecordValue e.v4;
+          }
+        ]
+        ++ lib.optionals (e.v6 != null) [
+          {
+            name = e.domain;
+            type = "AAAA";
+            value = mkRecordValue e.v6;
+          }
+        ])
+      directEntries;
+
+      directSecretIps = lib.unique (lib.concatMap (e:
+        lib.optional (e.v4 != null && e.v4.kind == "secret") e.v4
+        ++ lib.optional (e.v6 != null && e.v6.kind == "secret") e.v6)
+      directEntries);
     in {
       services.headscale = {
         enable = true;
@@ -261,6 +299,7 @@
       vaultix.secrets = lib.mkMerge (
         (map (h: {${h.address.ipv4.secret.name}.file = h.address.ipv4.secret.file;}) hostsWithIpv4Secret)
         ++ (map (h: {${h.address.ipv6.secret.name}.file = h.address.ipv6.secret.file;}) hostsWithIpv6Secret)
+        ++ (map (s: {${s.name}.file = s.file;}) directSecretIps)
       );
       vaultix.templates.headscale-derp-map = {
         content = derpMapYaml;
@@ -268,6 +307,12 @@
         group = config.services.headscale.group;
         mode = "0400";
       };
+      vaultix.templates.headscale-direct-records = {
+        content = builtins.toJSON directRecords;
+        owner = config.services.headscale.user;
+        group = config.services.headscale.group;
+        mode = "0400";
+      };
 
       systemd.services.headscale = let
         nixConfig = config.services.headscale.configFile;
@@ -293,16 +338,17 @@
           fi
 
           # Merge DNS extra records
-          if [ -f "${dnsRecordsFilePath}" ]; then
-            ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ([]; . + $item) | unique_by(.name + "|" + .type)' \
-              "${nixDnsRecords}" \
-              "${dnsRecordsFilePath}" \
-              > "${dnsRecordsFilePath}.tmp" \
-              && mv "${dnsRecordsFilePath}.tmp" "${dnsRecordsFilePath}"
-          else
+          if [ ! -f "${dnsRecordsFilePath}" ]; then
             cp "${nixDnsRecords}" "${dnsRecordsFilePath}"
             chmod 0640 "${dnsRecordsFilePath}"
           fi
+
+          ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ([]; . + $item) | unique_by(.name + "|" + .type)' \
+            "${nixDnsRecords}" \
+            "${config.vaultix.templates.headscale-direct-records.path}" \
+            "${dnsRecordsFilePath}" \
+            > "${dnsRecordsFilePath}.tmp" \
+            && mv "${dnsRecordsFilePath}.tmp" "${dnsRecordsFilePath}"
         '';
       in {
         serviceConfig = {
modules/services/tailscale-derp.nix
@@ -15,6 +15,7 @@
     in {
       ${cfg.domain} = {
         port = cfg.port;
+        prefer = "direct";
       };
     };