Commit 7262adc

HPCesia <me@hpcesia.com>
2026-08-18 15:55:10
services: init
Assisted-by: opencode:deepseek-v4-flash
1 parent 23f8d16
Changed files (53)
.secrets
modules
.secrets/cache/cyrene/8c484e4af20d1cce15643b03116d03a3f22b7bf803934a7d19aba5d4b1c78b05
@@ -0,0 +1,8 @@
+age-encryption.org/v1
+-> ssh-ed25519 7JvPpg kk2onxD9Nedvsv2O8/i+c/MIO/Ft2gQZl3UfUoc8Fzc
+TjkvNHqVswt+qGUHrGoRjxcqhOdWnePnzBtr8Pi/qDY
+-> JF~R-grease L13H B|:#@
+1n4C2R79DpjIrWCZ
+--- WVqjT4IwLciW3YAUY2KIFOFTe7HDaILPbSv9AzXef1U
+�
+��U���d�\h(	$������"j�J��|��Nĸ��Vj�����?n�B@�:mNҔ�撿���
\ No newline at end of file
.secrets/cache/cyrene/dae3525ed12a4a62ebe55896e0c587441052d11b04c85789b6b12133fb93476e
Binary file
.secrets/cache/kevin/5734c51b59b8b435d2518f4929954d8426e33db71216a3dfc10f887a30fd587b
Binary file
.secrets/cache/kevin/64f6593022e2631627c4c7710f5d1d8e81fd815726a363ca99881e8f08cd9538
@@ -0,0 +1,8 @@
+age-encryption.org/v1
+-> ssh-ed25519 WM7kiQ Yy0XYZsXQQPSDl7kSuJTQYCkD4xiVbz7LHeGuqaeYgU
+rmN4jOIjxMBNAuR6xVcHFBz7n8dWahcWYEoRGfdGqOQ
+-> .Ys6!!(!-grease yOF9Q]
+t52d8z0PGb5keL6fx7aGTZ7N/gLUCAyA5vFEY07IqB7JcWkumzwwlWLt8NyCHOr5
+Czq3
+--- pQeW8z17ytdi67ZWbT99hm8Jl2qijSUQ4ctyw55TDag
+?KDg*Z��}���3�>�(����	�)����)��:�I$I��5����*
\ No newline at end of file
.secrets/cache/kevin/83789c335fcee710e5c0b18a5aa7f4d7436a2e51386ba57115e0ea0791e53efd
Binary file
.secrets/cache/kevin/8b8624022b6227c9c3e3d4a349dbb3554c2b2f31be2b4d79153003d2ef90f1db
Binary file
.secrets/cache/kevin/e62331160d2c9b2083dcff12923722e7ee52e91a2c7ac3519666d873ceef12b1
@@ -0,0 +1,7 @@
+age-encryption.org/v1
+-> ssh-ed25519 WM7kiQ E4rJUlDpXKKUvv7lBSEiuF6FT8d+jBuHR7JdRvgbaHI
+S+TFcRHLKOZX/QbUv21ji7lsGHnbRv2nMhBK+c9rqKY
+-> 8hX_Qpav-grease &Q;F\j ~ o
+QjjO6Rg7Mw
+--- +0ysTrong4Of4Iz+Zja7BRjfgK7sJ+WZ3O9bEE6aG/g
+��*�S)N��PCW�Q瑈,�Wտf��������pz����p說m�֯8�A�D���.]�I��^���"�YJ�,��2�&�yhW���i<hu�s	 �i�3ZW�r�_��}�=���
\ No newline at end of file
.secrets/cache/mobius/4dc632afbd55188aa506d97caff379262941a534a849be320e9a5cada60862bb
@@ -0,0 +1,8 @@
+age-encryption.org/v1
+-> ssh-ed25519 qzANJQ NNulIpfb2mrRwkE3oe5btR3Nzli64TI+rxQGAeQQRA8
+JSLJCjFHpmNLdxVMxAFAPdGtoFWg8hrgvokmj3Wbm+w
+-> g!0-grease RVdnv]oG kK2My,
+2Qwn9OlEywiCSKtReLQokkGay1y8PBCkC5Rmv29gEmwl4Vs8IQfjYc7j2xk7SW/b
+Tyv120zosiuNiWiZwZn8NZoQuyen/r6JC5QDav11smSU2TV1
+--- EcJ1eZNu/w+jKNiYx37VY8eEYjnT4KnK6O7sgcv6pl8
+H���������i'4%s�*ef�#����V.QƓس��=�yE>���V*S���m !��Ja�Bb���N�
\ No newline at end of file
.secrets/cache/mobius/7cb3cb9d311407cc2934003c86a7d208bcd3fdf243692540d3502aae0c80f57a
@@ -0,0 +1,8 @@
+age-encryption.org/v1
+-> ssh-ed25519 qzANJQ ALDhHYMj4hQVFVe6e/VQpqqhIoe3s8ZoEHgiwRKoBRg
+aAOSwO9DEXVx0D7bc8ZCr1fpP/i22ziJruUrPiSCdoo
+-> qiRcmzR-grease [Q3(L<@d
+iBsmldu+xZEItG50Rrbnvfj7HORLXwoo4TVJpgVjEP5SAi6pndi8yqOhlXV7o4Fk
+EYPJz7fyhGk4PCGCni87B54LTMsNGm3H8lM
+--- SxmsDyX8BoIFjpoa2STv0FN3NmNKXy8c+wKNCRdabOM
+�o#��W�Y����pmB�Mr��aѾ���/ĭ^F�����hP�z��o_d֯��G���X�F��K|����d˦�P��
\ No newline at end of file
.secrets/cache/mobius/a46423ad52d4eb61b90363a4ec8dee749fda08c40a03ef0408b6b1f5062ea789
@@ -0,0 +1,7 @@
+age-encryption.org/v1
+-> ssh-ed25519 qzANJQ OS/EnqqzqYorzCjdsG9h+KNRJSc372XTYm626UFftRk
+hnEpVqOELwlSiygeU+w2a7v17ZWd5rky+IcLMmRXlPU
+-> Y*2~-grease 6i
+oqAF6++6USavEZcN8nazSkP7Ozkx49PRCetykM7RIdR4vUHtlcg2/nT+5gYOFI8
+--- OeW/gPErTTISjBGqdkYGIMe6Gt8PtuG/QS6t1SHNRE4
++�Gq^Ǎ2��5wV�8�5|u@�}��xN��=飁�{��QzEjde�s�C(9��~U>�0�S۫�$�_i��
\ No newline at end of file
.secrets/cache/mobius/e2d2ff62a19824807da7ff35ab6730a06ac7ffd03d28a35f9f81c20a86cd875e
@@ -0,0 +1,7 @@
+age-encryption.org/v1
+-> ssh-ed25519 qzANJQ W3ag2ZnzE19bBlpK0xw/yNpHZe8OJJNc6HeOkz0OLE4
+o7yXUSGXffYPKtkvx6HfOUwAdDfpc9exzV9/2L8CCkE
+-> 9-grease R
+nbPKPOiigeT7LWLaOxF907Dv4lwqD6zBTvxcHGiiZWjpjuU
+--- VztDJOsj0uKk7p0j83QaCm9AwTIkG4rh02jf13lJRiU
+�)�p+���~=�O�C�E��z
�{z����ie�6=����@
�r�R�- 8!l\@�xO��%�
��Hi9�;��ʔ���On�&7
\ No newline at end of file
.secrets/cache/tribios/39f47eb8026ae1b24b41c6d16a486706569863a8320d44c15addb65aa6ccd78f
@@ -0,0 +1,7 @@
+age-encryption.org/v1
+-> ssh-ed25519 1YGZAA SrPrxO9CVrSJfL7EyD0xHJ7LgsnFYZD3bKrcV/2vb0U
+GlcbxXBCxe+QVJflYyj7a8NP343ejfjQ9GAAghWbOiI
+-> v-grease (Wg A&X d7 bMDI
+KkSYWm9xET+lvbreDScpEpwdplbOlQrz
+--- O0s23mZ33006tpyidF0aETxgbtlt2naTj6Dc+orA3tQ
+	qx��ܑ(o�t�J���i �>���)�lm^����G?7c�K)��\��
\ No newline at end of file
.secrets/cache/tribios/523f549ed96d70bac2b29f3d9dfa5caeb6ae6b131fe1ba5e49ffa2943d55f6b6
Binary file
.secrets/cache/tribios/933dc774c4b9926ee79cc113303524f3872b2da03f5572ffb2dc6e2d900102fe
Binary file
.secrets/cache/tribios/c06551522a2701a8586f7fc2d1f69b17510352b8bf5cd9be5b1b8537063f1772
Binary file
.secrets/cache/tribios/fa4f45b01d2fd59747bc5b48dfd1f8f357f64f6666dc0aed3497f2848fb05c5a
@@ -0,0 +1,8 @@
+age-encryption.org/v1
+-> ssh-ed25519 1YGZAA jUZdgSFnIqnCc4Tl+RzJ1I7NeB/9Ywgd/n7oBUde5SM
+aDiISIja0UtrOyjk0eLrY+B2KbTeITcGiKvzh5BJYDY
+-> }Ws&hI-grease D
+T4trE7+WhAX0pkkMobCjhUu9O1z/FCXIxxy+1JrjLl/2aqWX9zvmE+G/A/LTnd3R
+sm7Dg79IAppW7+hP0M3sxYSI2B7h6zjxbf7UgpyHbzgjLtZIwg
+--- dtq5ywZrW1hUTDcsgQpA7SEx4j4tQ5vgJ/cbINEvvqQ
+�})���[m�1��0���YS������NG��*W��	;�[.����
\ No newline at end of file
modules/dev/utils.nix
@@ -1,6 +1,8 @@
 {den, ...}: {
   den.aspects.dev.includes = [den.aspects.dev.utils];
   den.aspects.dev.utils = {
+    includes = [den.aspects.services.podman];
+
     nixos = {
       programs.nix-ld.enable = true;
     };
modules/hosts/cyrene/services/default.nix
@@ -0,0 +1,48 @@
+{den, ...}: {
+  den.hosts.cyrene = {
+    settings = {
+      services.artalk = {
+        domain = "artalk.hpcesia.com";
+      };
+
+      services.goatcounter = {
+        domains = ["goatcounter.hpcesia.com"];
+      };
+
+      services.headplane = {
+        domain = "gate.trin.one";
+        port = 3466;
+        cookieSecretFileAged = ./headplane-cookie-secret.age;
+      };
+      services.headscale = {
+        domain = "gate.trin.one";
+        port = 3465;
+        derp.enable = true;
+        dns = {
+          enable = true;
+          domain = "net.trin.one";
+        };
+      };
+
+      services.vaultwarden = {
+        domain = "vault.hpcesia.com";
+      };
+
+      services.wakapi = {
+        domain = "wakapi.hpcesia.com";
+        passwordSaltFileAged = ./wakapi-password-salt.age;
+      };
+    };
+  };
+
+  den.aspects.cyrene.includes = with den.aspects; [
+    services.caddy
+
+    services.artalk
+    services.goatcounter
+    services.headplane
+    services.headscale
+    services.vaultwarden
+    services.wakapi
+  ];
+}
modules/hosts/cyrene/services/wakapi-password-salt.age
@@ -0,0 +1,8 @@
+age-encryption.org/v1
+-> piv-p256 xCEwtQ ArMGCYZ4lirDmx5jXklUK7jg8s9w6ZXpVLVUQ8FGjJ/N
+ogybMfNZs+NsC3W+pDbSg6jY1zA4pPDNAY7Xy4ggT+I
+-> ~-grease
+j7PDY+pvPTt8PrPSrjL23gx7aPWhvOZwMxu0GuGKx7Ktdoz3c1VpOeccqrRQIzRo
+DAs2pMwJQxanpuASpNQZoHE
+--- yhRyawIu6199h5q/kNNXrnTRxn2/W/a0HPcagJqZKKk
+� Y1l���<o���ēv���jWd�n�8�.�ޯ�\���h��/�-��0�Y|�s�J���cd��(�f�K����c��D��9s4Ral������
\ No newline at end of file
modules/hosts/kevin/networking.nix
@@ -1,12 +1,21 @@
-{
+{den, ...}: {
   den.hosts.kevin = {
     address = {
       ipv4.tailscale = "100.119.83.79";
       ipv6.tailscale = "fd7a:115c:a1e0:cdc1:45b1:c962:aac4:e0dc";
     };
+    settings.services.mihomo = {
+      autoStart = false;
+      interfaces.wan = "wlp0s20f3";
+    };
   };
 
   den.aspects.kevin = {
+    includes = [
+      den.aspects.services.mihomo
+      den.aspects.services.mihomo.tun
+    ];
+
     nixos = {
       networking.networkmanager.enable = true;
     };
modules/hosts/mobius/services/default.nix
@@ -0,0 +1,69 @@
+{den, ...}: {
+  den.hosts.mobius = {
+    settings = {
+      services.forgejo.runner = {
+        instances = {
+          internal = {
+            name = "runner-internal";
+            servers = {
+              gateOfInfinity = {
+                url = "https://git.net.trin.one";
+                uuid = "53fa1df5-f0dd-423c-91a7-afee2488f253";
+                tokenFileAged = ./forgejo-runner-goi-token.age;
+              };
+            };
+            labels = [
+              "ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-latest"
+              "nixos-latest:docker://git.net.trin.one/hpcesia/nix-act-image:latest-x86_64-linux"
+            ];
+            extraSettings = {
+              container.network = "host";
+            };
+          };
+          codeberg = {
+            name = "runner-codeberg";
+            servers = {
+              codeberge = {
+                url = "https://codeberg.org";
+                uuid = "ba3966e6-ad25-4de2-89ec-4b96b9e1965f";
+                tokenFileAged = ./forgejo-runner-codeberg-token.age;
+              };
+            };
+            labels = [
+              "nixos-latest:docker://git.net.trin.one/hpcesia/nix-act-image:latest-x86_64-linux"
+            ];
+          };
+        };
+      };
+      services.forgejo.server = {
+        domain = "git.net.trin.one";
+      };
+
+      services.navidrome = {
+        domain = "navidrome.net.trin.one";
+      };
+
+      services.woodpecker.agent.agents = {
+        codeberg = {
+          server = "grpc.ci.codeberg.org:443";
+          secretFileAged = ./woodpecker-agent-codeberg-token.age;
+          labels = {
+            tier = "high";
+          };
+        };
+      };
+    };
+  };
+
+  den.aspects.mobius.includes = with den.aspects; [
+    dev.binfmt # For aarch64 build in CI
+
+    services.caddy
+    services.caddy.tailscale
+
+    services.forgejo.runner
+    services.forgejo.server
+    services.navidrome
+    services.woodpecker.agent
+  ];
+}
modules/hosts/mobius/services/forgejo-runner-codeberg-token.age
@@ -0,0 +1,8 @@
+age-encryption.org/v1
+-> piv-p256 xCEwtQ AgsY4z/ukfl9c9xuDxDp74LeU+GcPbk5zHcWePXHSeq+
+9Z1Jo8vvxHRyGpVQupGXq7yeD6lxTETjowxJGz6Uaw4
+-> k+KdV-grease 1_mU7J oo[@c ]%'8;=P f\
+AfXCE0AekhAiJXb/Fh2nBYxpwPnw8WGW4ln7B3r/MPspEYNQV6E/PJUWWNRSY5u1
+J7RODuXkch8xCj9f0g5NsxNZGFIEruA9Rb4dZKE73S8fuBYsv+B37iRrg/IV
+--- eNk4nnvW6SDNq8w5lPL7plmaYzscHpy6162HKskVkAI
+��GZ�f��>�Q�䓝��e���MdWB���zmq��R2-��x �t��,���ϡV�~O=:5���ګKp$
\ No newline at end of file
modules/hosts/mobius/services/forgejo-runner-goi-token.age
@@ -0,0 +1,8 @@
+age-encryption.org/v1
+-> piv-p256 xCEwtQ A2xZQI2fXTNqYlgTSj2R0DuocSEbeDirfwN+W/5hpFoQ
+iS7WS5phlBfxWdBbzSNeBuwcdi5kXZvQQIguwKqKwWI
+-> &PQ-grease 8zJ, _$[ N1,xZ,zi
+vGRs9JfG4q4pmlm7Ml/r9c2dru3+HtcgbBYe1mCRK2DT2I9E4wxHpTyej3aD9mkA
+XnnxjuW1KttsRLhpZwhRswj2RVgo1BnakKRjc+0TmSbA
+--- RuujIYnmPBiGXfRBP8Mg1V1v1y7bleUtRjFUTVclj1s
+%�ٳt�GlcD����61h^�=H�������J��E����›�FI�d��&L,� ���G�)w�
\ No newline at end of file
modules/hosts/mobius/services/woodpecker-agent-codeberg-token.age
@@ -0,0 +1,8 @@
+age-encryption.org/v1
+-> piv-p256 xCEwtQ AnpdkWZgSL4YLHSoJAiDJQaeSHX6REnq0IwXDMBfey0b
+61n7uI4SwtXyxIYZK/GGi8JYRiURE9ZHh3pBOiikp+U
+-> g-grease "`,0l5~ wjtC ,]
+eZZBTNbycgKyOvMYtmSzQbWwm++b0eVwhGdARIWlIYdA8dkb7w
+--- zNm68hH2goG1Fp24hlt1CL0wAUwSJU+AuiZBSF4Xn5I
+�	?�'6�c:������C����
+#a@Ͳ���Vo&��J]��%'6|J��jY!��}o�yf��]�H� ��\7!�q�d�PAS���[�
\ No newline at end of file
modules/hosts/tribios/networking.nix
@@ -1,12 +1,25 @@
-{
+{den, ...}: {
   den.hosts.tribios = {
     address = {
       ipv4.tailscale = "100.127.6.231";
       ipv6.tailscale = "fd7a:115c:a1e0:675d:6820:4fa0:fddc:a59";
     };
+    settings.services.mihomo = {
+      autoStart = true;
+      tailscaleWebControl = true;
+      interfaces = {
+        wan = "enP3p49s0";
+        lan = ["br-lan"];
+      };
+    };
   };
 
   den.aspects.tribios = {
+    includes = [
+      den.aspects.services.mihomo
+      den.aspects.services.mihomo.tproxy
+    ];
+
     nixos = {
       boot.kernel.sysctl = {
         "net.ipv4.ip_forward" = 1;
modules/services/caddy/_lib.nix
@@ -0,0 +1,74 @@
+{lib, ...}: rec {
+  # Wrap IPv6 addresses in brackets so they can be used in a URL authority.
+  fmtAddr = isV6: addr:
+    if isV6
+    then "[${addr}]"
+    else addr;
+
+  sanitize = lib.replaceStrings ["." ":" "/" "*"] ["-" "-" "-" "-"];
+
+  templateName = domain: "caddy-reverse-proxy-${sanitize domain}";
+
+  normalizePath = p: let
+    p' =
+      if lib.hasPrefix "/" p
+      then p
+      else "/${p}";
+  in
+    lib.removeSuffix "/" p';
+
+  mkEntry = resolution: domain: conf: {
+    inherit domain resolution;
+    port = conf.port;
+    path =
+      if conf ? path && conf.path != null
+      then normalizePath conf.path
+      else null;
+    stripPath = conf.stripPath or false;
+    upstream = conf.upstream or null;
+  };
+
+  proxyLine = config: e:
+    if e.resolution.kind == "import"
+    then "import ${config.vaultix.templates.${templateName e.domain}.path}"
+    else let
+      upstream =
+        if e.upstream != null
+        then e.upstream
+        else "";
+    in "reverse_proxy http://${e.resolution.address}:${toString e.port}${upstream}";
+
+  entryLines = config: e: [(proxyLine config e)];
+
+  entryBlock = config: e: let
+    lines = entryLines config e;
+    directive =
+      if e.stripPath
+      then "handle_path"
+      else "handle";
+    mkBlock = p: "${directive} ${p} {\n${lib.concatMapStrings (l: "\t${l}\n") lines}}";
+  in [
+    (mkBlock e.path)
+    (mkBlock "${e.path}/*")
+  ];
+
+  fallbackBlock = config: e: let
+    lines = entryLines config e;
+  in "handle {\n${lib.concatMapStrings (l: "\t${l}\n") lines}}";
+
+  forwardAuthBlock = ''
+    forward_auth unix//run/tailscale.nginx-auth.sock {
+        uri /auth
+        header_up Remote-Addr {remote_host}
+        header_up Remote-Port {remote_port}
+        header_up Original-URI {uri}
+        copy_headers {
+            Tailscale-User>X-Webauth-User
+            Tailscale-Name>X-Webauth-Name
+            Tailscale-Login>X-Webauth-Login
+            Tailscale-Tailnet>X-Webauth-Tailnet
+            Tailscale-Profile-Picture>X-Webauth-Profile-Picture
+        }
+    }
+  '';
+}
modules/services/caddy/default.nix
@@ -0,0 +1,161 @@
+{den, ...}: {
+  den.aspects.services.caddy = {
+    includes = [
+      den.aspects.services.caddy.reverse-proxy-collector
+    ];
+
+    persist = {
+      directories = [
+        {
+          directory = "/var/lib/caddy";
+          user = "caddy";
+          group = "caddy";
+        }
+      ];
+    };
+
+    nixos = {
+      services.caddy = {
+        enable = true;
+        enableReload = true;
+
+        globalConfig = ''
+          http_port   80
+          https_port  443
+        '';
+      };
+
+      networking.firewall.allowedTCPPorts = [80 443];
+    };
+  };
+
+  den.aspects.services.caddy.reverse-proxy-collector = {host, ...}: {
+    nixos = {
+      reverseProxy,
+      config,
+      lib,
+      ...
+    }: let
+      inherit
+        (import ./_lib.nix {inherit lib;})
+        fmtAddr
+        mkEntry
+        entryBlock
+        fallbackBlock
+        templateName
+        ;
+
+      # Resolve how Caddy on the current host should reach the host that
+      # submitted the reverse proxy (the source host).
+      #
+      # Priority:
+      #   1. Same host                -> loopback.
+      #   2. Source has a clear text  -> use it directly.
+      #   3. Source has a secret IP   -> import it from a vaultix template.
+      #   4. Otherwise                -> abort with a helpful message.
+      resolveAddress = srcHost: let
+        src = srcHost.address;
+
+        v4ClearText = src.ipv4.clearText != null;
+        v6ClearText = src.ipv6.clearText != null;
+
+        v4Secret = src.ipv4.secret.name != null && src.ipv4.secret.file != null;
+        v6Secret = src.ipv6.secret.name != null && src.ipv6.secret.file != null;
+      in
+        if srcHost.name == host.name
+        then {
+          kind = "inline";
+          address = "127.0.0.1";
+        }
+        else if v4ClearText
+        then {
+          kind = "inline";
+          address = fmtAddr false src.ipv4.clearText;
+        }
+        else if v6ClearText
+        then {
+          kind = "inline";
+          address = fmtAddr true src.ipv6.clearText;
+        }
+        else if v4Secret
+        then {
+          kind = "import";
+          isV6 = false;
+          secretName = src.ipv4.secret.name;
+          secretFile = src.ipv4.secret.file;
+        }
+        else if v6Secret
+        then {
+          kind = "import";
+          isV6 = true;
+          secretName = src.ipv6.secret.name;
+          secretFile = src.ipv6.secret.file;
+        }
+        else
+          abort ''
+            Caddy on host '${host.name}' cannot reverse proxy to host '${srcHost.name}': no usable address is configured.
+
+            Please configure an address for host '${srcHost.name}' (see `address` in modules/hosts/schema.nix), one of:
+              - clear text IPv4/IPv6           (address.ipv4.clearText / address.ipv6.clearText)
+              - secret IPv4/IPv6               (address.ipv4.secret    / address.ipv6.secret)
+          '';
+
+      entries =
+        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);
+
+      importEntries = lib.filter (e: e.resolution.kind == "import") entries;
+
+      virtualHosts = let
+        grouped = lib.groupBy (e: e.domain) entries;
+
+        mkVirtualHost = name: group: let
+          noPath = lib.filter (e: e.path == null) group;
+          withPath = lib.filter (e: e.path != null) group;
+          noPathCount = builtins.length noPath;
+        in
+          assert lib.assertMsg (noPathCount <= 1)
+          "Multiple entries without a path for domain '${name}': only one root (pathless) entry per domain is allowed, but found ${toString noPathCount}."; let
+            pathLines = lib.concatMap (entryBlock config) withPath;
+            fallbackLines =
+              if noPathCount == 1
+              then [(fallbackBlock config (builtins.head noPath))]
+              else [];
+            blocks = pathLines ++ fallbackLines;
+          in {
+            inherit name;
+            value.extraConfig = lib.concatLines (["encode zstd gzip"] ++ blocks);
+          };
+      in
+        lib.listToAttrs (lib.mapAttrsToList mkVirtualHost grouped);
+
+      reverseProxyTemplates = lib.listToAttrs (map (e: {
+          name = templateName e.domain;
+          value = {
+            content = ''
+              reverse_proxy http://${fmtAddr e.resolution.isV6 config.vaultix.placeholder.${e.resolution.secretName}}:${toString e.port}
+            '';
+            owner = config.services.caddy.user;
+            group = config.services.caddy.group;
+            mode = "0400";
+          };
+        })
+        importEntries);
+
+      reverseProxySecrets = lib.listToAttrs (map (e: {
+          name = e.resolution.secretName;
+          value.file = e.resolution.secretFile;
+        })
+        importEntries);
+    in {
+      services.caddy.virtualHosts = virtualHosts;
+      vaultix.secrets = reverseProxySecrets;
+      vaultix.templates = reverseProxyTemplates;
+    };
+  };
+}
modules/services/caddy/quirks.nix
@@ -0,0 +1,28 @@
+{
+  den,
+  lib,
+  ...
+}: let
+  # Tailnet domain (base domain of Headscale MagicDNS). Tailnet reverse proxy
+  # entries are recognized by this suffix.
+  tailnetDomain = "net.trin.one";
+in {
+  den.quirks.reverseProxy.description = "Reverse proxy site entries collected from aspects (host)";
+
+  den.aspects.services.caddy.tailnetDomain = tailnetDomain;
+
+  den.policies.collect-reverse-proxy = _: let
+    inherit (den.lib.policy) pipe;
+  in [
+    (pipe.from "reverseProxy" [
+      (pipe.collect ({host, ...}: true))
+      (pipe.transform (value:
+        lib.mapAttrs
+        (domain: conf: conf // {tailscale = lib.hasSuffix ".${tailnetDomain}" domain;})
+        value))
+      pipe.withProvenance
+    ])
+  ];
+
+  den.schema.host.includes = [den.policies.collect-reverse-proxy];
+}
modules/services/caddy/tailscale-cloudflare-token.age
Binary file
modules/services/caddy/tailscale.nix
@@ -0,0 +1,125 @@
+{den, ...}: {
+  den.aspects.services.caddy.tailscale = {host, ...}: {
+    nixos = {
+      reverseProxy,
+      config,
+      lib,
+      pkgs,
+      ...
+    }: let
+      tailnetDomain = den.aspects.services.caddy.tailnetDomain;
+
+      inherit
+        (import ./_lib.nix {inherit lib;})
+        mkEntry
+        entryBlock
+        fallbackBlock
+        forwardAuthBlock
+        ;
+
+      entries =
+        lib.concatMap (
+          r:
+            lib.mapAttrsToList (mkEntry {
+              kind = "inline";
+              address = "127.0.0.1";
+            }) (lib.filterAttrs (_: conf: conf.tailscale or false) r.value)
+        )
+        (lib.filter (r: r.source.host.name == host.name) reverseProxy);
+
+      enabled = builtins.length entries > 0;
+
+      # Wildcard site: only obtains the *.net.trin.one certificate via DNS-01.
+      # Concrete subdomain sites below reuse it automatically (Caddy 2.10+).
+      certificateVirtualHost = {
+        hostName = "*.${tailnetDomain}";
+        extraConfig = ''
+          tls {
+            dns cloudflare {env.CF_API_TOKEN}
+            resolvers 1.1.1.1 1.0.0.1
+          }
+          abort
+        '';
+      };
+
+      mkVirtualHost = name: group: let
+        noPath = lib.filter (e: e.path == null) group;
+        withPath = lib.filter (e: e.path != null) group;
+        noPathCount = builtins.length noPath;
+      in
+        assert lib.assertMsg (noPathCount <= 1) (
+          "Multiple entries without a path for domain '${name}':"
+          + "only one root (pathless) entry per domain is allowed,"
+          + "but found ${toString noPathCount}."
+        ); let
+          pathLines = lib.concatMap (entryBlock config) withPath;
+          fallbackLines =
+            if noPathCount == 1
+            then [(fallbackBlock config (builtins.head noPath))]
+            else [];
+          blocks = pathLines ++ fallbackLines;
+        in {
+          inherit name;
+          value.extraConfig = lib.concatLines (["encode zstd gzip" forwardAuthBlock] ++ blocks);
+        };
+
+      virtualHosts = let
+        grouped = lib.groupBy (e: e.domain) entries;
+        siteHosts = lib.mapAttrsToList mkVirtualHost grouped;
+      in
+        lib.listToAttrs (siteHosts
+          ++ [
+            {
+              name = "tailnet-wildcard";
+              value = certificateVirtualHost;
+            }
+          ]);
+    in
+      lib.mkIf enabled {
+        services.caddy = {
+          package = pkgs.caddy.withPlugins {
+            plugins = [
+              "github.com/caddy-dns/cloudflare@v0.2.4"
+            ];
+            hash = "sha256-7GoH8YLCoPmPExQxoga2FHB58zQDoZVf1BBwkVi0SsQ=";
+          };
+          virtualHosts = virtualHosts;
+        };
+
+        systemd.services.caddy.serviceConfig.EnvironmentFile = [config.vaultix.templates.caddy-tailnet-env.path];
+
+        vaultix.secrets.tailnet-cf-token.file = ./tailscale-cloudflare-token.age;
+        vaultix.templates.caddy-tailnet-env = {
+          content = ''
+            CF_API_TOKEN=${config.vaultix.placeholder.tailnet-cf-token}
+          '';
+          owner = config.services.caddy.user;
+          group = config.services.caddy.group;
+          mode = "0400";
+        };
+
+        systemd.sockets.tailscale-nginx-auth = {
+          description = "Tailscale NGINX Authentication socket";
+          partOf = ["tailscale-nginx-auth.service"];
+          wantedBy = ["sockets.target"];
+          listenStreams = ["/run/tailscale.nginx-auth.sock"];
+        };
+
+        systemd.services.tailscale-nginx-auth = {
+          description = "Tailscale NGINX Authentication service";
+          requires = ["tailscale-nginx-auth.socket"];
+          after = ["tailscaled.service"];
+
+          serviceConfig = {
+            ExecStart = "${pkgs.tailscale-nginx-auth}/bin/tailscale.nginx-auth";
+            DynamicUser = true;
+            BindPaths = ["/run/tailscale/tailscaled.sock"];
+            PrivateDevices = true;
+            ProtectHome = true;
+            RestrictAddressFamilies = ["AF_UNIX"];
+            Restart = "on-failure";
+          };
+        };
+      };
+  };
+}
modules/services/forgejo/runner.nix
@@ -0,0 +1,132 @@
+{
+  den,
+  lib,
+  ...
+}: {
+  den.aspects.services.forgejo.runner = {
+    includes = [den.aspects.services.podman];
+    settings.host = {
+      instances = lib.mkOption {
+        type = lib.types.attrsOf (lib.types.submodule ({name, ...}: {
+          options = {
+            name = lib.mkOption {
+              type = lib.types.str;
+              default = name;
+            };
+            servers = lib.mkOption {
+              type = lib.types.attrsOf (lib.types.submodule {
+                options = {
+                  url = lib.mkOption {
+                    type = lib.types.str;
+                  };
+                  uuid = lib.mkOption {
+                    type = lib.types.str;
+                  };
+                  tokenFileAged = lib.mkOption {
+                    type = lib.types.path;
+                  };
+                  labels = lib.mkOption {
+                    type = lib.types.listOf lib.types.str;
+                    description = "Extra labels used for this server.";
+                    default = [];
+                  };
+                };
+              });
+              default = {};
+            };
+            labels = lib.mkOption {
+              type = lib.types.listOf lib.types.str;
+              default = [];
+            };
+            extraEnvironments = lib.mkOption {
+              type = lib.types.attrsOf lib.types.str;
+              default = {};
+            };
+            extraSettings = lib.mkOption {
+              type = lib.types.attrsOf lib.types.anything;
+              default = {};
+            };
+          };
+        }));
+        default = {};
+      };
+    };
+
+    persist = {
+      directoies = [
+        {
+          directory = "/var/lib/private/forgejo-runner";
+          user = "nobody";
+          group = "nogroup";
+          mode = "0700";
+        }
+      ];
+    };
+
+    nixos = {
+      host,
+      config,
+      ...
+    }: let
+      cfg = host.settings.services.forgejo.runner;
+      mkServerTokenSecretName = instance: server: "forgejo-runner-${instance}-${server}-token";
+    in {
+      # If you would like to use docker runners in combination with cache actions,
+      # be sure to add docker bridge interfaces “br-*” to the firewalls’ trusted interfaces.
+      # See https://forgejo.org/docs/next/admin/actions/runner-installation/#nixos
+      networking.firewall.trustedInterfaces =
+        if (config.networking.nftables.enable)
+        then ["br-*"]
+        else ["br-+"];
+
+      services.forgejo-runner.instances =
+        lib.mapAttrs (instance: instanceCfg: {
+          enable = true;
+
+          settings = lib.mkMerge [
+            {
+              runner.labels = lib.unique (instanceCfg.labels ++ (lib.concatLists (lib.mapAttrsToList (_: serverCfg: serverCfg.labels) instanceCfg.servers)));
+              server.connections =
+                lib.mapAttrs (server: serverCfg: {
+                  inherit (serverCfg) url uuid;
+                })
+                instanceCfg.servers;
+              cache = {
+                enabled = true;
+                # See https://forgejo.org/docs/latest/user/actions/advanced-features/#cache
+                # ONLY for podman backend
+                proxy_port = 4000;
+                actions_cache_url_override = "http://host.containers.internal:4000";
+              };
+              container = {
+                enable_ipv6 = true;
+                options = "--cap-add sys_admin --cap-add mknod --device /dev/fuse";
+              };
+            }
+            instanceCfg.extraSettings
+          ];
+
+          secrets = {
+            server.connections =
+              lib.mapAttrs (server: _: {
+                token_url = config.vaultix.secrets.${mkServerTokenSecretName instance server}.path;
+              })
+              instanceCfg.servers;
+          };
+        })
+        cfg.instances;
+
+      vaultix.secrets = lib.mergeAttrsList (
+        lib.mapAttrsToList (instance: instanceCfg:
+          lib.mapAttrs' (
+            server: serverCfg:
+              lib.nameValuePair (mkServerTokenSecretName instance server) {
+                file = serverCfg.tokenFileAged;
+              }
+          )
+          instanceCfg.servers)
+        cfg.instances
+      );
+    };
+  };
+}
modules/services/forgejo/server.nix
@@ -0,0 +1,122 @@
+{
+  den,
+  lib,
+  ...
+}: {
+  den.aspects.services.forgejo.server = {
+    settings.host = {
+      domain = lib.mkOption {
+        type = lib.types.str;
+      };
+      address = lib.mkOption {
+        type = lib.types.str;
+        default = "127.0.0.1";
+      };
+      port = lib.mkOption {
+        type = lib.types.port;
+        default = 3155;
+      };
+    };
+
+    reverseProxy = {host, ...}: let
+      cfg = host.settings.services.forgejo.server;
+    in {
+      ${cfg.domain} = {
+        port = cfg.port;
+      };
+    };
+
+    persist = {config, ...}: {
+      directories = [
+        {
+          directory = config.services.forgejo.stateDir;
+          inherit (config.services.forgejo) user group;
+          mode = "0700";
+        }
+      ];
+    };
+
+    nixos = {
+      host,
+      config,
+      ...
+    }: let
+      hostCfg = host.settings.services.forgejo.server;
+    in {
+      services.forgejo = {
+        enable = true;
+        user = "git";
+        group = "forgejo";
+        database = {
+          type = "sqlite3";
+        };
+        lfs.enable = true;
+        settings = {
+          DEFAULT = {
+            APP_NAME = "Gate Of Infinity";
+            APP_SLOGAN = "Walk toward the tomorrow where the stars gleam.";
+            APP_DISPLAY_NAME_FORMAT = "{APP_NAME}";
+          };
+          server = {
+            DOMAIN = hostCfg.domain;
+            HTTP_ADDR = hostCfg.address;
+            HTTP_PORT = hostCfg.port;
+            # Tailnet deployments (domain under the tailnet domain) use the
+            # tailnet SSH daemon on 22; public deployments use the openssh
+            # server port.
+            SSH_PORT =
+              if lib.hasSuffix ".${den.aspects.services.caddy.tailnetDomain}" hostCfg.domain
+              then 22
+              else host.settings.core.openssh.server.port;
+            PROTOCOL = "http";
+            ROOT_URL = "https://${hostCfg.domain}/";
+          };
+          service = {
+            DISABLE_REGISTRATION = true;
+            ENABLE_BASIC_AUTHENTICATION = false;
+          };
+          repository = {
+            DEFAULT_REPO_UNITS = "repo.code,repo.releases,repo.issues,repo.pulls";
+            DEFAULT_FORK_REPO_UNITS = "repo.code,repo.pulls";
+            DEFAULT_MIRROR_REPO_UNITS = "repo.code";
+          };
+          actions = {
+            ENABLED = true;
+            DEFAULT_ACTIONS_URL = "https://${hostCfg.domain}";
+          };
+          webhook = {
+            ALLOWED_HOST_LIST = "external,loopback";
+          };
+          log = {
+            LEVEL = "Info";
+            LOGGER_ROUTER_MODE = "Error";
+          };
+          ui = {
+            THEMES = lib.concatStringsSep "," [
+              "forgejo-auto"
+              "forgejo-light"
+              "forgejo-dark"
+              "gitea-auto"
+              "gitea-light"
+              "gitea-dark"
+            ];
+          };
+        };
+      };
+
+      users.users."git" = {
+        isSystemUser = true;
+        useDefaultShell = true;
+        group = config.services.forgejo.group;
+        home = config.services.forgejo.stateDir;
+      };
+
+      services.openssh = {
+        extraConfig = ''
+          Match User git
+            AcceptEnv GIT_PROTOCOL
+        '';
+      };
+    };
+  };
+}
modules/services/mihomo/default.nix
@@ -0,0 +1,109 @@
+{
+  den,
+  lib,
+  inputs,
+  ...
+}: {
+  den.aspects.services.mihomo = {
+    settings.host = {
+      autoStart = lib.mkEnableOption "Auto start mihomo service";
+      tailscaleWebControl = lib.mkEnableOption "Allow connect external controller from tailscale subnet";
+      interfaces = {
+        wan = lib.mkOption {
+          description = "The WAN interface to bind.";
+          type = lib.types.str;
+          default = null;
+        };
+        lan = lib.mkOption {
+          description = "The LAN interface to bind.";
+          type = lib.types.listOf lib.types.str;
+          default = [];
+        };
+      };
+      ports = {
+        controller = lib.mkOption {
+          type = lib.types.port;
+          default = 7900;
+        };
+        dns = lib.mkOption {
+          type = lib.types.port;
+          default = 1053;
+        };
+        mixed = lib.mkOption {
+          type = lib.types.port;
+          default = 7890;
+        };
+        tproxy = lib.mkOption {
+          type = lib.types.port;
+          default = 7894;
+        };
+      };
+    };
+
+    includes = with den.aspects.services.mihomo; [
+      dns
+      proxies
+      proxy-groups
+      rules
+      sniffer
+    ];
+
+    cache = {
+      directories = [
+        {
+          directory = "/var/lib/mihomo";
+          user = "mihomo";
+          group = "mihomo";
+        }
+      ];
+    };
+
+    nixos = {
+      host,
+      pkgs,
+      ...
+    }: let
+      cfg = host.settings.services.mihomo;
+    in {
+      imports = [inputs.nur-hpcesia.nixosModules.mihomo];
+
+      services.mihomo = {
+        enable = true;
+        webui = pkgs.metacubexd;
+        processesInfo = lib.mkDefault true;
+
+        config = {
+          mixed-port = cfg.ports.mixed;
+          mode = "rule";
+          ipv6 = false;
+          find-process-mode = lib.mkDefault "strict";
+          allow-lan = lib.mkDefault true;
+          bind-address = lib.mkDefault "*";
+          log-level = "warning";
+          interface-name = cfg.interfaces.wan;
+          unified-delay = true;
+          tcp-concurrent = true;
+          geodata-mode = true; # `.dat`
+          geox-url = {
+            geoip = "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.dat";
+            geosite = "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat";
+          };
+          external-controller = "${
+            if cfg.tailscaleWebControl
+            then host.address.ipv4.tailscale
+            else "127.0.0.1"
+          }:${toString cfg.ports.controller}";
+          external-controller-cors = {
+            allow-origins = ["*"];
+            allow-private-network = true;
+          };
+        };
+      };
+
+      systemd.services."mihomo" = {
+        after = lib.optional (cfg.tailscaleWebControl) "tailscaled.service";
+        wantedBy = lib.mkIf (!cfg.autoStart) (lib.mkForce []);
+      };
+    };
+  };
+}
modules/services/mihomo/dns.nix
@@ -0,0 +1,76 @@
+{
+  den.aspects.services.mihomo.dns = {
+    nixos = {host, ...}: let
+      cfg = host.settings.services.mihomo;
+    in {
+      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"];
+          };
+          dns = {
+            enable = true;
+            listen = ":${toString cfg.ports.dns}";
+            prefer-h3 = false;
+            ipv6 = false;
+            enhanced-mode = "redir-host";
+            respect-rules = true;
+            nameserver = [
+              "https://dns.google/dns-query#disable-qtype-64=true&disable-qtype-65=true"
+              "https://cloudflare-dns.com/dns-query#disable-qtype-64=true&disable-qtype-65=true"
+            ];
+            proxy-server-nameserver = [
+              "https://dns.alidns.com/dns-query#disable-qtype-64=true&disable-qtype-65=true"
+              "https://doh.pub/dns-query#disable-qtype-64=true&disable-qtype-65=true"
+            ];
+            direct-nameserver = [
+              "https://dns.alidns.com/dns-query#disable-qtype-64=true&disable-qtype-65=true"
+              "https://doh.pub/dns-query#disable-qtype-64=true&disable-qtype-65=true"
+            ];
+            nameserver-policy = {
+              # Tailscale
+              "+.net.trin.one" = "100.100.100.100";
+              ".ts.net" = "100.100.100.100";
+
+              "geosite:cn,private" = [
+                "https://dns.alidns.com/dns-query#disable-qtype-64=true&disable-qtype-65=true"
+                "https://doh.pub/dns-query#disable-qtype-64=true&disable-qtype-65=true"
+              ];
+              "geosite:geolocation-!cn" = [
+                "https://dns.google/dns-query#disable-qtype-64=true&disable-qtype-65=true"
+                "https://cloudflare-dns.com/dns-query#disable-qtype-64=true&disable-qtype-65=true"
+              ];
+            };
+            fake-ip-range = "198.18.0.1/16";
+            fake-ip-filter-mode = "blacklist";
+            fake-ip-filter = [
+              "+.+m2m"
+              "+.$injections.adguard.org"
+              "+.$local.adguard.org"
+              "+.+bogon"
+              "+.+lan"
+              "+.+local"
+              "+.+localdomain"
+              "+.home.arpa"
+              "dns.msftncsi.com"
+              "*.srv.nintendo.net"
+              "*.stun.playstation.net"
+              "xbox.*.microsoft.com"
+              "*.xboxlive.com"
+              "*.turn.twilio.com"
+              "*.stun.twilio.com"
+              "stun.syncthing.net"
+              "stun.*"
+              "*.sslip.io"
+              "*.nip.io"
+              "gate.trin.one"
+            ];
+          };
+        };
+      };
+    };
+  };
+}
modules/services/mihomo/providers-hong_xing.age
@@ -0,0 +1,7 @@
+age-encryption.org/v1
+-> piv-p256 xCEwtQ AkfcYR/bKvdE3tz9X7f7/E+hAlycxlO5t54xNWiqSR5G
+EfbqiRZd51WbMxO3glbUIpk96oF+g6jmYAfnNplTcII
+-> Ct~qcB-grease isXi sZuy5 .4)FI_
+lFEBjA
+--- 4Bc8MyFX+vmSWf3cZIz3OFYSrGnpyMShZWGDhrbTJVM
+d.b��H� c�	��ڔ��B�mu�ɪj����‡:MK��As)���t�V���p?ϫM��S���1����T��5�z�R�����6�I*`I2cԚ�3�lΊ�H.y��S�P���5$rxi�m
\ No newline at end of file
modules/services/mihomo/providers-mo_jie.age
Binary file
modules/services/mihomo/proxies.nix
@@ -0,0 +1,51 @@
+{
+  den.aspects.services.mihomo.proxies = {
+    nixos = {config, ...}: {
+      services.mihomo = {
+        config = {
+          proxy-providers = let
+            providerParam = {
+              type = "http";
+              interval = 86400;
+              health-check = {
+                enable = true;
+                url = "https://cp.cloudflare.com";
+                interval = 300;
+              };
+            };
+          in {
+            mo_jie =
+              providerParam
+              // {
+                url._secret = config.vaultix.secrets.mihomo-providers-mo_jie.path;
+                path = "./proxy_provider/providers-mo_jie.yaml";
+                override.additional-prefix = "[MJ]";
+              };
+            hong_xing =
+              providerParam
+              // {
+                url._secret = config.vaultix.secrets.mihomo-providers-hong_xing.path;
+                path = "./proxy_provider/providers-hong_xing.yaml";
+                override.additional-prefix = "[HX]";
+              };
+          };
+          proxies = [
+          ];
+        };
+      };
+
+      vaultix.secrets = {
+        mihomo-providers-mo_jie = {
+          file = ./providers-mo_jie.age;
+          owner = "mihomo";
+          group = "mihomo";
+        };
+        mihomo-providers-hong_xing = {
+          file = ./providers-hong_xing.age;
+          owner = "mihomo";
+          group = "mihomo";
+        };
+      };
+    };
+  };
+}
modules/services/mihomo/proxy-groups.nix
@@ -0,0 +1,133 @@
+{
+  den.aspects.services.mihomo.proxy-groups = {
+    nixos = {
+      services.mihomo = {
+        config = {
+          proxy-groups = let
+            nonsenseKeywords = "回国|校园|网站|地址|剩余|过期|时间|有效|网址|禁止|邮箱|发布|客服|订阅|节点";
+
+            filterHK = "^(?=.*((?i)🇭🇰|香港|\\b(HK|Hong)(\\d+)?\\b))(?!.*((?i)${nonsenseKeywords})).*$";
+            filterTW = "^(?=.*((?i)🇹🇼|台湾|\\b(TW|Tai|Taiwan)(\\d+)?\\b))(?!.*((?i)${nonsenseKeywords})).*$";
+            filterJP = "^(?=.*((?i)🇯🇵|日本|川日|东京|大阪|泉日|埼玉|\\b(JP|Japan)(\\d+)?\\b))(?!.*((?i)${nonsenseKeywords})).*$";
+            filterKR = "^(?=.*((?i)🇰🇷|韩国|韓|首尔|\\b(KR|Korea)(\\d+)?\\b))(?!.*((?i)${nonsenseKeywords})).*$";
+            filterSG = "^(?=.*((?i)🇸🇬|新加坡|狮|\\b(SG|Singapore)(\\d+)?\\b))(?!.*((?i)${nonsenseKeywords})).*$";
+            filterUS = "^(?=.*((?i)🇺🇸|美国|波特兰|达拉斯|俄勒冈|凤凰城|费利蒙|硅谷|拉斯维加斯|洛杉矶|圣何塞|圣克拉拉|西雅图|芝加哥|\\b(US|United States)(\\d+)?\\b))(?!.*((?i)${nonsenseKeywords})).*$";
+            filterUK = "^(?=.*((?i)🇬🇧|英国|伦敦|\\b(UK|United Kingdom)(\\d+)?\\b))(?!.*((?i)${nonsenseKeywords})).*$";
+            filterFR = "^(?=.*((?i)🇫🇷|法国|\\b(FR|France)(\\d+)?\\b))(?!.*((?i)${nonsenseKeywords})).*$";
+            filterDE = "^(?=.*((?i)🇩🇪|德国|\\b(DE|Germany)(\\d+)?\\b))(?!.*((?i)${nonsenseKeywords})).*$";
+            filterOthers = "^(?!.*(🇭🇰|HK|Hong|香港|🇹🇼|TW|Taiwan|Wan|🇯🇵|JP|Japan|日本|🇸🇬|SG|Singapore|狮城|🇺🇸|US|United States|America|美国|🇩🇪|DE|Germany|德国|🇬🇧|UK|United Kingdom|英国|🇰🇷|KR|Korea|韩国|韓|🇫🇷|FR|France|法国)).*$";
+            filterAll = "^(?=.*(.))(?!.*((?i)群|邀请|返利|循环|官网|客服|网站|网址|获取|订阅|流量|到期|机场|下次|版本|官址|备用|过期|已用|联系|邮箱|工单|贩卖|通知|倒卖|防止|国内|地址|频道|无法|说明|使用|提示|特别|访问|支持|教程|关注|更新|作者|加入|(\\b(USE|USED|TOTAL|EXPIRE|EMAIL|Panel|Channel|Author)\\b|(\\d{4}-\\d{2}-\\d{2}|\\d+G)))).*$";
+
+            select = {
+              type = "select";
+              url = "https://connectivitycheck.platform.hicloud.com/generate_204";
+              disable-udp = false;
+              hidden = false;
+              include-all = true;
+            };
+            urlTest = {
+              type = "url-test";
+              url = "https://connectivitycheck.platform.hicloud.com/generate_204";
+              interval = 300;
+              tolerance = 50;
+              disable-udp = false;
+              hidden = true;
+              include-all = true;
+            };
+
+            regions = {
+              HK = {
+                flag = "🇭🇰";
+                filter = filterHK;
+              };
+              TW = {
+                flag = "🇹🇼";
+                filter = filterTW;
+              };
+              JP = {
+                flag = "🇯🇵";
+                filter = filterJP;
+              };
+              KR = {
+                flag = "🇰🇷";
+                filter = filterKR;
+              };
+              SG = {
+                flag = "🇸🇬";
+                filter = filterSG;
+              };
+              US = {
+                flag = "🇺🇸";
+                filter = filterUS;
+              };
+              UK = {
+                flag = "🇬🇧";
+                filter = filterUK;
+              };
+              FR = {
+                flag = "🇫🇷";
+                filter = filterFR;
+              };
+              DE = {
+                flag = "🇩🇪";
+                filter = filterDE;
+              };
+            };
+          in
+            [
+              {
+                name = "SELECT";
+                type = "select";
+                proxies = ["AUTO" "MANUAL" "DIRECT"];
+                url = "http://connectivitycheck.platform.hicloud.com/generate_204";
+                icon = "https://raw.githubusercontent.com/Orz-3/mini/master/Color/Static.png";
+              }
+              {
+                name = "MANUAL";
+                type = "select";
+                proxies = ["Others - MANUAL"] ++ (map (x: "${regions.${x}.flag} - MANUAL") (builtins.attrNames regions));
+                url = "http://connectivitycheck.platform.hicloud.com/generate_204";
+                icon = "https://raw.githubusercontent.com/Orz-3/mini/master/Color/Cylink.png";
+              }
+              {
+                name = "AUTO";
+                type = "select";
+                proxies = map (x: "${regions.${x}.flag} - AUTO") (builtins.attrNames regions);
+                url = "http://connectivitycheck.platform.hicloud.com/generate_204";
+                icon = "https://raw.githubusercontent.com/Orz-3/mini/master/Color/Urltest.png";
+              }
+            ]
+            ++ (map (x:
+              urlTest
+              // {
+                name = "${regions.${x}.flag} - AUTO";
+                filter = regions.${x}.filter;
+              }) (builtins.attrNames regions))
+            ++ (map (x:
+              select
+              // {
+                name = "${regions.${x}.flag} - MANUAL";
+                filter = regions.${x}.filter;
+              }) (builtins.attrNames regions))
+            ++ [
+              (select
+                // {
+                  name = "Others - MANUAL";
+                  filter = filterOthers;
+                })
+              (urlTest
+                // {
+                  name = "AllIn - AUTO";
+                  filter = filterAll;
+                })
+              (select
+                // {
+                  name = "AllIn - MANUAL";
+                  filter = filterAll;
+                })
+            ];
+        };
+      };
+    };
+  };
+}
modules/services/mihomo/rules.nix
@@ -0,0 +1,306 @@
+{
+  den,
+  lib,
+  ...
+}: {
+  den.aspects.services.mihomo.rules = {
+    nixos = {
+      host,
+      config,
+      ...
+    }: let
+      allHosts = builtins.concatMap builtins.attrValues (builtins.attrValues den.hosts);
+      otherHosts = builtins.filter (h: h.name != host.name) allHosts;
+
+      hostsWithIpv4ClearText = builtins.filter (h: h.address.ipv4.clearText != null) otherHosts;
+      hostsWithIpv4Secret = builtins.filter (h: h.address.ipv4.secret.name != null && h.address.ipv4.secret.file != null) otherHosts;
+      hostsWithIpv6ClearText = builtins.filter (h: h.address.ipv6.clearText != null) otherHosts;
+      hostsWithIpv6Secret = builtins.filter (h: h.address.ipv6.secret.name != null && h.address.ipv6.secret.file != null) otherHosts;
+
+      mkMihomoIpRule = ip: "IP-CIDR,${ip}/32";
+
+      clearTextIps =
+        (map (h: mkMihomoIpRule h.address.ipv4.clearText) hostsWithIpv4ClearText)
+        ++ (map (h: mkMihomoIpRule h.address.ipv6.clearText) hostsWithIpv6ClearText);
+
+      secretIps =
+        (map (h: {_secret = config.vaultix.templates."mihomo-rules-${h.address.ipv4.secret.name}".path;}) hostsWithIpv4Secret)
+        ++ (map (h: {_secret = config.vaultix.templates."mihomo-rules-${h.address.ipv6.secret.name}".path;}) hostsWithIpv6Secret);
+
+      allHostIps = clearTextIps ++ secretIps;
+    in {
+      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)
+        );
+        templates =
+          lib.mergeAttrsList
+          ((map (h: let
+                name = h.address.ipv4.secret.name;
+              in {
+                "mihomo-rules-${name}".content = mkMihomoIpRule config.vaultix.placeholder.${name};
+              })
+              hostsWithIpv4Secret)
+            ++ (map (h: let
+                name = h.address.ipv6.secret.name;
+              in {
+                "mihomo-rules-${name}".content = mkMihomoIpRule config.vaultix.placeholder.${name};
+              })
+              hostsWithIpv6Secret));
+      };
+
+      services.mihomo = {
+        config = {
+          rules = [
+            # === Non-IP ===
+            # Private
+            "RULE-SET,reject_non_ip,REJECT"
+            "RULE-SET,reject_domainset,REJECT"
+            "RULE-SET,reject_non_ip_drop,REJECT-DROP"
+            "RULE-SET,reject_non_ip_no_drop,REJECT"
+            "RULE-SET,tailscale_non_ip,DIRECT"
+            "RULE-SET,lan_non_ip,DIRECT"
+
+            # Game
+            "DOMAIN-SUFFIX,cm.steampowered.com,DIRECT"
+            "DOMAIN-SUFFIX,steamserver.net,DIRECT"
+            "GEOSITE,steam@cn,DIRECT"
+            "GEOSITE,category-game-platforms-download@cn,DIRECT"
+
+            # Should DIRECT
+            "DST-PORT,22,DIRECT" # For SSH
+            "DOMAIN-SUFFIX,kagi.com,DIRECT" # DIRECT is faster
+            "DOMAIN-SUFFIX,mxrouting.net,DIRECT"
+
+            # Common
+            "RULE-SET,cdn_domainset,SELECT"
+            "RULE-SET,cdn_non_ip,SELECT"
+            "RULE-SET,stream_non_ip,🇺🇸 - AUTO"
+            "RULE-SET,telegram_non_ip,🇺🇸 - AUTO"
+            "RULE-SET,apple_cdn,DIRECT"
+            "RULE-SET,download_domainset,SELECT"
+            "RULE-SET,download_non_ip,SELECT"
+            "RULE-SET,microsoft_cdn_non_ip,DIRECT"
+            "RULE-SET,apple_cn_non_ip,DIRECT"
+            "RULE-SET,apple_services,DIRECT"
+            "RULE-SET,microsoft_non_ip,DIRECT"
+            "RULE-SET,ai_non_ip,🇺🇸 - AUTO"
+            "RULE-SET,global_non_ip,SELECT"
+            "RULE-SET,domestic_non_ip,DIRECT"
+            "RULE-SET,direct_non_ip,DIRECT"
+
+            # === IP ===
+            "RULE-SET,reject_ip,REJECT"
+            "RULE-SET,my_hosts,DIRECT"
+            "RULE-SET,telegram_ip,🇺🇸 - AUTO"
+            "RULE-SET,stream_ip,🇺🇸 - AUTO"
+            "RULE-SET,lan_ip,DIRECT"
+            "RULE-SET,domestic_ip,DIRECT"
+            "RULE-SET,china_ip,DIRECT"
+            "MATCH,SELECT"
+          ];
+
+          rule-providers = let
+            ruleSetClassical = {
+              type = "http";
+              behavior = "classical";
+              interval = 43200;
+              format = "text";
+              proxy = "SELECT";
+            };
+            ruleSetDomain = {
+              type = "http";
+              behavior = "domain";
+              interval = 43200;
+              format = "text";
+              proxy = "SELECT";
+            };
+            ruleSetIpcidr = {
+              type = "http";
+              behavior = "ipcidr";
+              interval = 43200;
+              format = "text";
+              proxy = "SELECT";
+            };
+          in {
+            my_hosts = {
+              type = "inline";
+              behavior = "classical";
+              payload = allHostIps;
+            };
+            tailscale_non_ip = {
+              type = "inline";
+              behavior = "classical";
+              payload = [
+                "PROCESS-NAME,tailscale"
+                "PROCESS-NAME,tailscaled"
+                "PROCESS-NAME,.tailscaled-wrapped"
+                "DOMAIN-SUFFIX,ts.net"
+                "DOMAIN-SUFFIX,net.trin.one"
+                "DOMAIN,controlplane.tailscale.com"
+                "DOMAIN,gate.trin.one"
+              ];
+            };
+            reject_non_ip_no_drop =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/reject-no-drop.txt";
+                path = "./rule_set/sukkaw_ruleset/reject_non_ip_no_drop.txt";
+              };
+            reject_non_ip_drop =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/reject-drop.txt";
+                path = "./rule_set/sukkaw_ruleset/reject_non_ip_drop.txt";
+              };
+            reject_non_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/reject.txt";
+                path = "./rule_set/sukkaw_ruleset/reject_non_ip.txt";
+              };
+            reject_domainset =
+              ruleSetDomain
+              // {
+                url = "https://ruleset.skk.moe/Clash/domainset/reject.txt";
+                path = "./rule_set/sukkaw_ruleset/reject_domainset.txt";
+              };
+            reject_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/ip/reject.txt";
+                path = "./rule_set/sukkaw_ruleset/reject_ip.txt";
+              };
+            cdn_domainset =
+              ruleSetDomain
+              // {
+                url = "https://ruleset.skk.moe/Clash/domainset/cdn.txt";
+                path = "./rule_set/sukkaw_ruleset/cdn_domainset.txt";
+              };
+            cdn_non_ip =
+              ruleSetDomain
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/cdn.txt";
+                path = "./rule_set/sukkaw_ruleset/cdn_non_ip.txt";
+              };
+            stream_non_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/stream.txt";
+                path = "./rule_set/sukkaw_ruleset/stream_non_ip.txt";
+              };
+            stream_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/ip/stream.txt";
+                path = "./rule_set/sukkaw_ruleset/stream_ip.txt";
+              };
+            ai_non_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/ai.txt";
+                path = "./rule_set/sukkaw_ruleset/ai_non_ip.txt";
+              };
+            telegram_non_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/telegram.txt";
+                path = "./rule_set/sukkaw_ruleset/telegram_non_ip.txt";
+              };
+            telegram_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/ip/telegram.txt";
+                path = "./rule_set/sukkaw_ruleset/telegram_ip.txt";
+              };
+            apple_cdn =
+              ruleSetDomain
+              // {
+                url = "https://ruleset.skk.moe/Clash/domainset/apple_cdn.txt";
+                path = "./rule_set/sukkaw_ruleset/apple_cdn.txt";
+              };
+            apple_services =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/apple_services.txt";
+                path = "./rule_set/sukkaw_ruleset/apple_services.txt";
+              };
+            apple_cn_non_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/apple_cn.txt";
+                path = "./rule_set/sukkaw_ruleset/apple_cn_non_ip.txt";
+              };
+            microsoft_cdn_non_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/microsoft_cdn.txt";
+                path = "./rule_set/sukkaw_ruleset/microsoft_cdn_non_ip.txt";
+              };
+            microsoft_non_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/microsoft.txt";
+                path = "./rule_set/sukkaw_ruleset/microsoft_non_ip.txt";
+              };
+            download_domainset =
+              ruleSetDomain
+              // {
+                url = "https://ruleset.skk.moe/Clash/domainset/download.txt";
+                path = "./rule_set/sukkaw_ruleset/download_domainset.txt";
+              };
+            download_non_ip =
+              ruleSetDomain
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/download.txt";
+                path = "./rule_set/sukkaw_ruleset/download_non_ip.txt";
+              };
+            lan_non_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/lan.txt";
+                path = "./rule_set/sukkaw_ruleset/lan_non_ip.txt";
+              };
+            lan_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/ip/lan.txt";
+                path = "./rule_set/sukkaw_ruleset/lan_ip.txt";
+              };
+            domestic_non_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/domestic.txt";
+                path = "./rule_set/sukkaw_ruleset/domestic_non_ip.txt";
+              };
+            direct_non_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/direct.txt";
+                path = "./rule_set/sukkaw_ruleset/direct_non_ip.txt";
+              };
+            global_non_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/non_ip/global.txt";
+                path = "./rule_set/sukkaw_ruleset/global_non_ip.txt";
+              };
+            domestic_ip =
+              ruleSetClassical
+              // {
+                url = "https://ruleset.skk.moe/Clash/ip/domestic.txt";
+                path = "./rule_set/sukkaw_ruleset/domestic_ip.txt";
+              };
+            china_ip =
+              ruleSetIpcidr
+              // {
+                url = "https://ruleset.skk.moe/Clash/ip/china_ip.txt";
+                path = "./rule_set/sukkaw_ruleset/china_ip.txt";
+              };
+          };
+        };
+      };
+    };
+  };
+}
modules/services/mihomo/sniffer.nix
@@ -0,0 +1,30 @@
+{
+  den.aspects.services.mihomo.sniffer = {
+    nixos = {
+      services.mihomo = {
+        config = {
+          sniffer = {
+            enable = true;
+            force-dns-mapping = true;
+            sniff = {
+              HTTP = {
+                ports = [80 "8080-8880"];
+                override-destination = true;
+              };
+              TLS = {
+                ports = [443 8443];
+              };
+              QUIC = {
+                ports = [443 8443];
+              };
+            };
+            skip-domain = [
+              "Mijia Cloud"
+              "+.push.apple.com"
+            ];
+          };
+        };
+      };
+    };
+  };
+}
modules/services/mihomo/tproxy.nix
@@ -0,0 +1,183 @@
+{lib, ...}: {
+  den.aspects.services.mihomo.tproxy = {
+    nixos = {
+      host,
+      pkgs,
+      ...
+    }: let
+      cfg = host.settings.services.mihomo;
+      tproxyMark = "666";
+      # Marks WAN-bound local traffic in the output chain. Must differ
+      # from tproxyMark and its policy rule must stay un-qualified:
+      # fib_validate_source() reverse lookups always run with
+      # flowi4_iif = lo and (src_valid_mark=1, set by tailscaled) keep
+      # the packet mark, so reusing mark 666 or adding "iif lo" would
+      # route LAN reverse lookups into table 100 again and drop them
+      # as martians. Looped-back self packets skip source validation
+      # because their output dst survives loopback_xmit.
+      selfMark = "667";
+      tproxyRules = pkgs.writeText "mihomo-tproxy.nft" ''
+        table inet mihomo {
+          define MIHOMO_TPROXY_MARK=${tproxyMark}
+          define MIHOMO_SELF_MARK=${selfMark}
+          define MIHOMO_TPROXY_PORT=${toString cfg.ports.tproxy}
+          define MIHOMO_DNS_PORT=${toString cfg.ports.dns}
+          set bypass-ipv4 {
+            type ipv4_addr
+            flags interval
+            elements = {
+              0.0.0.0/8,
+              10.0.0.0/8,
+              100.64.0.0/10,
+              127.0.0.0/8,
+              169.254.0.0/16,
+              172.16.0.0/12,
+              192.168.0.0/16,
+              224.0.0.0/4,
+              240.0.0.0/4
+            }
+          }
+          set bypass-ipv6 {
+            type ipv6_addr
+            flags interval
+            elements = {
+              ::/128,
+              ::1/128,
+              fc00::/7,
+              fe80::/10,
+              ff00::/8
+            }
+          }
+          set bypass-tcp-ports {
+            type inet_service
+            elements = { 53, 67, 68, 123 }
+          }
+          set bypass-udp-ports {
+            type inet_service
+            # 3478: STUN; 41641: WireGuard endpoints of Tailscale peers.
+            elements = { 53, 67, 68, 123, 3478, 41641 }
+          }
+          set bypass-udp-sports {
+            type inet_service
+            # Tailscale WireGuard sockets on LAN clients. Proxying them
+            # breaks NAT endpoint discovery, and the router's own
+            # tailscaled already occupies UDP 41641, so mihomo cannot
+            # even bind its transparent reply socket (EADDRINUSE).
+            elements = { 41641 }
+          }
+          set outbounds {
+            type ifname
+            elements = { ${cfg.interfaces.wan} }
+          }
+          chain tproxy-prerouting {
+            type filter hook prerouting priority mangle; policy accept;
+            meta l4proto { tcp, udp } socket transparent 1 mark set $MIHOMO_TPROXY_MARK return
+            socket transparent 0 socket wildcard 0 return
+            ip daddr @bypass-ipv4 return
+            ip6 daddr @bypass-ipv6 return
+            tcp dport @bypass-tcp-ports return
+            udp dport @bypass-udp-ports return
+            udp sport @bypass-udp-sports return
+            fib daddr type { local, broadcast, anycast, multicast } return
+            meta l4proto { tcp, udp } tproxy to :$MIHOMO_TPROXY_PORT meta mark set $MIHOMO_TPROXY_MARK return
+          }
+          chain tproxy-output {
+            type route hook output priority mangle; policy accept;
+            # Exempt mihomo's own traffic (proxy nodes, DoH, providers).
+            meta skuid mihomo return
+            oifname != @outbounds return
+            ip daddr @bypass-ipv4 return
+            ip6 daddr @bypass-ipv6 return
+            tcp dport @bypass-tcp-ports return
+            udp dport @bypass-udp-ports return
+            udp sport @bypass-udp-sports return
+            fib daddr type { local, broadcast, anycast, multicast } return
+            meta l4proto { tcp, udp } meta mark set $MIHOMO_SELF_MARK return
+          }
+          chain dns-prerouting {
+            type nat hook prerouting priority dstnat; policy accept;
+            tcp dport 53 redirect to :$MIHOMO_DNS_PORT
+            udp dport 53 redirect to :$MIHOMO_DNS_PORT
+          }
+          chain dns-output {
+            type nat hook output priority dstnat; policy accept;
+            meta skuid mihomo return
+            # Keep systemd-resolved loops (127.0.0.53) and Tailscale
+            # MagicDNS (100.100.100.100) intact.
+            ip daddr @bypass-ipv4 return
+            ip6 daddr @bypass-ipv6 return
+            tcp dport 53 redirect to :$MIHOMO_DNS_PORT
+            udp dport 53 redirect to :$MIHOMO_DNS_PORT
+          }
+        }
+      '';
+    in {
+      boot.kernel.sysctl = {
+        "net.ipv4.conf.all.rp_filter" = 0;
+        "net.ipv4.conf.default.rp_filter" = 0;
+      };
+
+      # The nftables firewall's strict rpfilter chain (priority mangle + 10)
+      # runs right after tproxy-prerouting (priority mangle) and does
+      # `fib saddr . mark . iif oif` with the tproxy mark set: the lookup
+      # hits table 100 (local default dev lo), oif=lo never equals iif,
+      # so every tproxied packet would be dropped without this exemption.
+      networking.firewall.extraReversePathFilterRules = "meta mark { ${tproxyMark}, ${selfMark} } accept";
+
+      services.mihomo = {
+        # tunMode grants CAP_NET_ADMIN + PrivateUsers=false needed for
+        # IP_TRANSPARENT. tun.enable = false keeps the actual TUN
+        # device off — traffic interception is done via nftables instead.
+        tunMode = true;
+        config = {
+          tun.enable = lib.mkForce false;
+          tproxy-port = cfg.ports.tproxy;
+        };
+      };
+
+      systemd.services.mihomo = {
+        serviceConfig = let
+          ip = lib.getExe' pkgs.iproute2 "ip";
+          # tailscaled sets net.ipv4.conf.all.src_valid_mark=1, making
+          # fib_validate_source() keep the fwmark during its reverse
+          # lookup. Without an iif qualifier that lookup would also hit
+          # table 100 (local default dev lo -> RTN_LOCAL, not
+          # RTN_UNICAST) and every marked packet would be dropped as a
+          # martian. The reverse lookup runs with flowi4_iif = lo, so
+          # restricting the rule to LAN ingress keeps it out of source
+          # validation while forward lookups from LAN still match.
+          ipRulesAdd =
+            lib.concatMapStrings (ifname: ''
+              ${ip} rule add fwmark ${tproxyMark} iif ${ifname} lookup 100 pref 5000 2>/dev/null || true
+            '')
+            cfg.interfaces.lan;
+          ipRulesDel =
+            lib.concatMapStrings (ifname: ''
+              ${ip} rule del fwmark ${tproxyMark} iif ${ifname} lookup 100 pref 5000 2>/dev/null || true
+            '')
+            cfg.interfaces.lan;
+        in {
+          ExecStartPre = lib.mkAfter [
+            "+${pkgs.writeShellScript "mihomo-tproxy-start" ''
+              ${lib.getExe pkgs.nftables} delete table inet mihomo 2>/dev/null || true
+              ${lib.getExe pkgs.nftables} -f ${tproxyRules}
+              ${ipRulesAdd}
+              ${ip} rule add fwmark ${selfMark} lookup 100 pref 5001 2>/dev/null || true
+              ${ip} route add local 0.0.0.0/0 dev lo table 100 2>/dev/null || true
+            ''}"
+          ];
+          ExecStopPost = lib.mkAfter [
+            "+${pkgs.writeShellScript "mihomo-tproxy-stop" ''
+              ${ip} route del local 0.0.0.0/0 dev lo table 100 2>/dev/null || true
+              ${ip} rule del fwmark ${selfMark} lookup 100 pref 5001 2>/dev/null || true
+              ${ipRulesDel}
+              ${lib.getExe pkgs.nftables} delete table inet mihomo 2>/dev/null || true
+            ''}"
+          ];
+          AmbientCapabilities = lib.mkAfter ["CAP_NET_BIND_SERVICE"];
+          CapabilityBoundingSet = lib.mkAfter ["CAP_NET_BIND_SERVICE"];
+        };
+      };
+    };
+  };
+}
modules/services/mihomo/tun.nix
@@ -0,0 +1,42 @@
+{lib, ...}: {
+  den.aspects.services.mihomo.tun = {
+    nixos = {
+      host,
+      config,
+      ...
+    }: let
+      cfg = host.settings.services.mihomo;
+    in {
+      services.mihomo = {
+        tunMode = true;
+        config = {
+          tun = {
+            enable = true;
+            stack = "mixed";
+            device = "mihomo-tun0";
+            auto-route = true;
+            auto-redirect = true;
+            auto-detect-interface = false;
+            dns-hijack = [
+              "any:53"
+              "tcp://any:53"
+            ];
+            strict-route = true;
+            mtu = 1500;
+            include-interface = ["lo"] ++ [cfg.interfaces.wan] ++ cfg.interfaces.lan;
+            route-exclude-address = [
+              "192.168.0.0/16"
+              "10.0.0.0/8"
+              "172.16.0.0/12"
+              # Tailscale
+              "100.64.0.0/10"
+              "fd7a:115c:a1e0::/48"
+            ];
+          };
+        };
+      };
+
+      networking.firewall.trustedInterfaces = lib.mkIf config.services.mihomo.tunMode [config.services.mihomo.config.tun.device];
+    };
+  };
+}
modules/services/woodpecker/agent.nix
@@ -0,0 +1,83 @@
+{
+  den,
+  lib,
+  ...
+}: {
+  den.aspects.services.woodpecker.agent = {
+    includes = [den.aspects.services.podman];
+    settings.host = {
+      agents = lib.mkOption {
+        type = lib.types.attrsOf (lib.types.submodule {
+          options = {
+            server = lib.mkOption {
+              type = lib.types.str;
+            };
+            secretFileAged = lib.mkOption {
+              type = lib.types.path;
+            };
+            labels = lib.mkOption {
+              type = lib.types.attrsOf lib.types.str;
+              default = {};
+            };
+            extraEnvironments = lib.mkOption {
+              type = lib.types.attrsOf lib.types.str;
+              default = {};
+            };
+          };
+        });
+        default = {};
+      };
+    };
+
+    nixos = {
+      host,
+      config,
+      ...
+    }: let
+      cfg = host.settings.services.woodpecker.agent;
+      mapLabels = lib.concatMapAttrsStringSep "," (n: v: "${n}=${v}");
+      mkSecretName = name: "woodpecker-agent-${name}-token";
+    in
+      lib.mkMerge (
+        lib.mapAttrsToList (name: agent: {
+          services.woodpecker-agents.agents.${name} = {
+            enable = true;
+            extraGroups = ["podman"];
+            environment =
+              {
+                WOODPECKER_AGENT_LABELS = mapLabels agent.labels;
+                WOODPECKER_SERVER = agent.server;
+                WOODPECKER_AGENT_SECRET_FILE = config.vaultix.secrets.${mkSecretName name}.path;
+                WOODPECKER_GRPC_SECURE = "true";
+                WOODPECKER_MAX_WORKFLOWS = "4";
+                DOCKER_HOST = "unix:///run/podman/podman.sock";
+                WOODPECKER_BACKEND = "docker";
+                WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6 = "true";
+              }
+              // agent.extraEnvironments;
+          };
+
+          systemd.services."woodpecker-agent-${name}".serviceConfig = {
+            DynamicUser = lib.mkForce false;
+            User = "woodpecker-agent-${name}";
+            Group = "woodpecker-agent-${name}";
+          };
+
+          users.users."woodpecker-agent-${name}" = {
+            isSystemUser = true;
+            useDefaultShell = true;
+            group = "woodpecker-agent-${name}";
+          };
+          users.groups."woodpecker-agent-${name}" = {};
+
+          vaultix.secrets.${mkSecretName name} = {
+            file = agent.secretFileAged;
+            owner = "root";
+            group = "woodpecker-agent-${name}";
+            mode = "0440";
+          };
+        })
+        cfg.agents
+      );
+  };
+}
modules/services/woodpecker/server.nix
@@ -0,0 +1,3 @@
+{
+  # TODO: Add Woodpecker CI server
+}
modules/services/artalk.nix
@@ -0,0 +1,68 @@
+{lib, ...}: {
+  den.aspects.services.artalk = {
+    settings.host = {
+      domain = lib.mkOption {
+        type = lib.types.str;
+      };
+      address = lib.mkOption {
+        type = lib.types.str;
+        default = "127.0.0.1";
+      };
+      port = lib.mkOption {
+        type = lib.types.port;
+        default = 7364;
+      };
+    };
+
+    persist = {config, ...}: let
+      cfg = config.services.artalk;
+    in {
+      directories = [
+        {
+          directory = cfg.workdir;
+          inherit (cfg) user group;
+          mode = "0700";
+        }
+      ];
+      files = lib.optional (cfg.allowModify) {
+        file = cfg.configFile;
+        parent = {
+          inherit (cfg) user group;
+          mode = "0700";
+        };
+      };
+    };
+
+    reverseProxy = {host, ...}: let
+      cfg = host.settings.services.artalk;
+    in {
+      ${cfg.domain} = {
+        port = cfg.port;
+      };
+    };
+
+    nixos = {host, ...}: let
+      cfg = host.settings.services.artalk;
+    in {
+      services.artalk = {
+        enable = true;
+        allowModify = true;
+        settings = {
+          host = cfg.address;
+          port = cfg.port;
+          debug = false;
+          db = {
+            type = "sqlite";
+            file = "./data/artalk.db";
+            user = "artalk";
+            charset = "utf8mb4";
+          };
+          log = {
+            enabled = true;
+            filename = "./data/artalk.log";
+          };
+        };
+      };
+    };
+  };
+}
modules/services/goatcounter.nix
@@ -0,0 +1,44 @@
+{lib, ...}: {
+  den.aspects.services.goatcounter = {
+    settings.host = {
+      domains = lib.mkOption {
+        type = lib.types.listOf lib.types.str;
+        default = [];
+      };
+      port = lib.mkOption {
+        type = lib.types.port;
+        default = 4627;
+      };
+    };
+
+    persist = {
+      directories = [
+        {
+          directory = "/var/lib/private/goatcounter";
+          user = "nobody";
+          group = "nogroup";
+          mode = "0700";
+        }
+      ];
+    };
+
+    reverseProxy = {host, ...}: let
+      cfg = host.settings.services.goatcounter;
+    in
+      lib.genAttrs cfg.domains (_: {inherit (cfg) port;});
+
+    nixos = {host, ...}: let
+      cfg = host.settings.services.goatcounter;
+    in {
+      services.goatcounter = {
+        enable = true;
+        address = "127.0.0.1";
+        port = cfg.port;
+        proxy = true;
+        extraArgs = [
+          "-automigrate"
+        ];
+      };
+    };
+  };
+}
modules/services/headplane.nix
@@ -0,0 +1,70 @@
+{lib, ...}: {
+  den.aspects.services.headplane = {
+    settings.host = {
+      domain = lib.mkOption {
+        type = lib.types.nullOr lib.types.str;
+        default = null;
+      };
+      port = lib.mkOption {
+        type = lib.types.port;
+        default = 8081;
+      };
+      cookieSecretFileAged = lib.mkOption {
+        type = lib.types.path;
+        description = "An age encypted file containing the cookie secret. The secret must be exactly 32 characters long.";
+      };
+    };
+
+    persist = {
+      directories = [
+        {
+          directory = "/var/lib/headplane";
+          user = "headscale";
+          group = "headscale";
+          mode = "0700";
+        }
+      ];
+    };
+
+    reverseProxy = {host, ...}: let
+      cfg = host.settings.services.headplane;
+    in {
+      ${cfg.domain} = {
+        port = cfg.port;
+        path = "/admin";
+        stripPath = false;
+      };
+    };
+
+    nixos = {
+      host,
+      config,
+      ...
+    }: let
+      cfg = host.settings.services.headplane;
+    in {
+      services.headplane = {
+        enable = true;
+
+        settings = {
+          headscale = {
+            config_path = "/var/lib/headscale/config.yaml";
+            dns_records_path = "/var/lib/headscale/dns-records.json";
+          };
+          server = {
+            base_url = "https://${cfg.domain}/admin/oidc/callback";
+            port = cfg.port;
+            cookie_secret_path = config.vaultix.secrets.headplane-cookie-secret.path;
+          };
+        };
+      };
+
+      vaultix.secrets.headplane-cookie-secret = {
+        file = cfg.cookieSecretFileAged;
+        owner = config.services.headscale.user;
+        group = config.services.headscale.group;
+        mode = "0400";
+      };
+    };
+  };
+}
modules/services/headscale.nix
@@ -0,0 +1,234 @@
+{
+  den,
+  lib,
+  ...
+}: {
+  den.aspects.services.headscale = {
+    settings.host = {
+      domain = lib.mkOption {
+        type = lib.types.nullOr lib.types.str;
+        default = null;
+      };
+      port = lib.mkOption {
+        type = lib.types.port;
+        default = 8080;
+      };
+      derp = {
+        enable = lib.mkEnableOption "Enable Headscale's builtin DERP server";
+        port = lib.mkOption {
+          type = lib.types.port;
+          default = 3478;
+        };
+      };
+      dns = {
+        enable = lib.mkEnableOption "Enable Headscale's Magic DNS";
+        domain = lib.mkOption {
+          type = lib.types.str;
+          default = "ts.net";
+        };
+      };
+    };
+
+    persist = {
+      directories = [
+        {
+          directory = "/var/lib/headscale";
+          user = "headscale";
+          group = "headscale";
+          mode = "0700";
+        }
+      ];
+    };
+
+    reverseProxy = {host, ...}: let
+      cfg = host.settings.services.headscale;
+    in {
+      ${cfg.domain} = {
+        port = cfg.port;
+      };
+    };
+
+    nixos = {
+      host,
+      pkgs,
+      config,
+      reverseProxy,
+      ...
+    }: let
+      cfg = host.settings.services.headscale;
+      hostAddr = host.address;
+
+      configFilePath = "/var/lib/headscale/config.yaml";
+      dnsRecordsFilePath = "/var/lib/headscale/dns-records.json";
+
+      tailnetDomain = den.aspects.services.caddy.tailnetDomain;
+
+      # 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;
+
+      duplicateDomains =
+        lib.filter (domain: builtins.length (lib.filter (e: e.domain == domain) tailnetEntries) > 1)
+        (lib.unique (map (e: e.domain) tailnetEntries));
+
+      dnsRecords = let
+        assertNoDuplicates =
+          lib.assertMsg (duplicateDomains == [])
+          "Multiple hosts declare the same tailnet domain: ${lib.concatStringsSep ", " duplicateDomains}";
+
+        assertDomainMatches =
+          lib.assertMsg (cfg.dns.domain == tailnetDomain)
+          ("Headscale MagicDNS base domain '${cfg.dns.domain}' does not match the tailnet domain"
+            + "'${tailnetDomain}' used by the caddy tailnet module (hardcoded in modules/services/caddy/quirks.nix).");
+      in
+        assert assertNoDuplicates;
+        assert assertDomainMatches;
+          lib.concatMap (e: let
+            v4 = e.source.address.ipv4.tailscale;
+            v6 = e.source.address.ipv6.tailscale;
+          in
+            assert lib.assertMsg (v4 != null || v6 != null)
+            ("Tailnet domain '${e.domain}' is declared by host '${e.source.name}' which has no tailscale address configured"
+              + "(see `address` in modules/hosts/schema.nix).");
+              lib.optionals (v4 != null) [
+                {
+                  name = e.domain;
+                  type = "A";
+                  value = v4;
+                }
+              ]
+              ++ lib.optionals (v6 != null) [
+                {
+                  name = e.domain;
+                  type = "AAAA";
+                  value = v6;
+                }
+              ])
+          tailnetEntries;
+    in {
+      services.headscale = {
+        enable = true;
+        address = "127.0.0.1";
+        port = cfg.port;
+        settings = {
+          server_url = "https://${cfg.domain}";
+          database.type = "sqlite";
+          tls_cert_path = null; # Use webserver for TLS instead.
+          tls_key_path = null;
+          prefixes = {
+            v4 = "100.64.0.0/10";
+            v6 = "fd7a:115c:a1e0::/48";
+            allocation = "random";
+          };
+          derp.server = lib.optionalAttrs (cfg.derp.enable) {
+            enabled = true;
+            stun_listen_addr = "0.0.0.0:${toString cfg.derp.port}";
+            verify_clients = true;
+            region_id = 999;
+            region_code = "headscale";
+            region_name = "Headscale Embedded DERP";
+            ipv4 = lib.mkIf (hostAddr.ipv4.clearText != null) hostAddr.ipv4.clearText;
+            ipv6 = lib.mkIf (hostAddr.ipv6.clearText != null) hostAddr.ipv6.clearText;
+          };
+          dns = {
+            magic_dns = cfg.dns.enable;
+            override_local_dns = cfg.dns.enable;
+            base_domain = cfg.dns.domain;
+            nameservers.global = [
+              # IPv4
+              "119.29.29.29" # DNSPod
+              "223.5.5.5" # AliDNS
+              # IPv6
+              "2400:3200::1" # AliDNS
+              "2606:4700:4700::1111" # Cloudflare
+            ];
+            extra_records_path = dnsRecordsFilePath;
+          };
+        };
+      };
+
+      networking.firewall.allowedUDPPorts = lib.optional (cfg.derp.enable) cfg.derp.port;
+
+      systemd.services.headscale = let
+        nixConfig = config.services.headscale.configFile;
+        hsCfg = config.services.headscale;
+
+        nixDnsRecords =
+          pkgs.writeText "headscale-dns-records.json"
+          (builtins.toJSON dnsRecords);
+
+        mergeHeadscaleState = pkgs.writeShellScript "merge-headscale-state" ''
+          set -euo pipefail
+
+          # Merge main configuration
+          if [ -f "${configFilePath}" ]; then
+            ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ({}; . * $item)' \
+              "${configFilePath}" \
+              "${nixConfig}" \
+              > "${configFilePath}.tmp" \
+              && mv "${configFilePath}.tmp" "${configFilePath}"
+          else
+            cp "${nixConfig}" "${configFilePath}"
+            chmod 0640 "${configFilePath}"
+          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
+            cp "${nixDnsRecords}" "${dnsRecordsFilePath}"
+            chmod 0640 "${dnsRecordsFilePath}"
+          fi
+        '';
+      in {
+        serviceConfig = {
+          ExecStartPre = [mergeHeadscaleState];
+          EnvironmentFile =
+            lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)
+            config.vaultix.templates.headscale-env.path;
+        };
+
+        script = lib.mkForce ''
+          ${lib.optionalString (hsCfg.settings.database.postgres.password_file != null) ''
+            export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg hsCfg.settings.database.postgres.password_file})"
+          ''}
+          exec ${lib.getExe hsCfg.package} serve --config ${configFilePath}
+        '';
+      };
+
+      vaultix.templates.headscale-env =
+        lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)
+        {
+          content = lib.concatLines (
+            (
+              lib.optional
+              (hostAddr.ipv4.secret.name != null)
+              "HEADSCALE_DERP_SERVER_IPV4=${config.vaultix.placeholder.${hostAddr.ipv4.secret.name}}"
+            )
+            ++ (
+              lib.optional
+              (hostAddr.ipv6.secret.name != null)
+              "HEADSCALE_DERP_SERVER_IPV4=${config.vaultix.placeholder.${hostAddr.ipv6.secret.name}}"
+            )
+          );
+          owner = config.services.headscale.user;
+          group = config.services.headscale.group;
+          mode = "0400";
+        };
+    };
+  };
+}
modules/services/navidrome.nix
@@ -0,0 +1,60 @@
+{lib, ...}: {
+  den.aspects.services.navidrome = {
+    settings.host = {
+      domain = lib.mkOption {
+        type = lib.types.str;
+      };
+      address = lib.mkOption {
+        type = lib.types.str;
+        default = "127.0.0.1";
+      };
+      port = lib.mkOption {
+        type = lib.types.port;
+        default = 4533;
+      };
+    };
+
+    persist = {
+      directories = [
+        {
+          directory = "/var/lib/navidrome";
+          user = "navidrome";
+          group = "navidrome";
+          mode = "0700";
+        }
+      ];
+    };
+
+    cache = {
+      directories = [
+        {
+          directory = "/var/lib/navidrome/cache";
+          user = "navidrome";
+          group = "navidrome";
+          mode = "0700";
+        }
+      ];
+    };
+
+    reverseProxy = {host, ...}: let
+      cfg = host.settings.services.navidrome;
+    in {
+      ${cfg.domain} = {
+        port = cfg.port;
+      };
+    };
+
+    nixos = {host, ...}: let
+      cfg = host.settings.services.navidrome;
+    in {
+      services.navidrome = {
+        enable = true;
+        settings = {
+          Address = cfg.address;
+          Port = cfg.port;
+          DataFolder = "/var/lib/navidrome";
+        };
+      };
+    };
+  };
+}
modules/services/podman.nix
@@ -0,0 +1,28 @@
+{
+  den.aspects.services.podman = {
+    nixos = {config, ...}: {
+      virtualisation.podman = {
+        enable = true;
+        dockerCompat = true;
+        dockerSocket.enable = true;
+        autoPrune.enable = true;
+      };
+
+      # Enable container name DNS for all Podman networks.
+      networking.firewall.interfaces = let
+        matchAll =
+          if !config.networking.nftables.enable
+          then "podman+"
+          else "podman*";
+      in {
+        "${matchAll}".allowedUDPPorts = [53];
+      };
+
+      virtualisation.oci-containers.backend = "podman";
+    };
+
+    user = {
+      extraGroups = ["podman"];
+    };
+  };
+}
modules/services/vaultwarden.nix
@@ -0,0 +1,53 @@
+{lib, ...}: {
+  den.aspects.services.vaultwarden = {
+    settings.host = {
+      domain = lib.mkOption {
+        type = lib.types.str;
+      };
+      address = lib.mkOption {
+        type = lib.types.str;
+        default = "127.0.0.1";
+      };
+      port = lib.mkOption {
+        type = lib.types.port;
+        default = 8222;
+      };
+    };
+
+    persist = {
+      directories = [
+        {
+          directory = "/var/lib/vaultwarden";
+          user = "vaultwarden";
+          group = "vaultwarden";
+          mode = "0700";
+        }
+      ];
+    };
+
+    reverseProxy = {host, ...}: let
+      cfg = host.settings.services.vaultwarden;
+    in {
+      ${cfg.domain} = {
+        port = cfg.port;
+      };
+    };
+
+    nixos = {host, ...}: let
+      cfg = host.settings.services.vaultwarden;
+    in {
+      services.vaultwarden = {
+        enable = true;
+
+        inherit (cfg) domain;
+        dbBackend = "sqlite";
+        config = {
+          SIGNUPS_ALLOWED = false;
+          ENABLE_WEBSOCKET = true;
+          ROCKET_ADDRESS = cfg.address;
+          ROCKET_PORT = cfg.port;
+        };
+      };
+    };
+  };
+}
modules/services/wakapi.nix
@@ -0,0 +1,79 @@
+{lib, ...}: {
+  den.aspects.services.wakapi = {
+    settings.host = {
+      domain = lib.mkOption {
+        type = lib.types.str;
+      };
+      address = {
+        ipv4 = lib.mkOption {
+          type = lib.types.str;
+          default = "127.0.0.1";
+        };
+        ipv6 = lib.mkOption {
+          type = lib.types.str;
+          default = "::1";
+        };
+      };
+      port = lib.mkOption {
+        type = lib.types.port;
+        default = 5423;
+      };
+      passwordSaltFileAged = lib.mkOption {
+        type = lib.types.path;
+      };
+    };
+
+    persist = {
+      directories = [
+        {
+          directory = "/var/lib/private/wakapi";
+          user = "nobody";
+          group = "nogroup";
+          mode = "0700";
+        }
+      ];
+    };
+
+    reverseProxy = {host, ...}: let
+      cfg = host.settings.services.wakapi;
+    in {
+      ${cfg.domain} = {
+        port = cfg.port;
+      };
+    };
+
+    nixos = {
+      host,
+      config,
+      ...
+    }: let
+      cfg = host.settings.services.wakapi;
+    in {
+      services.wakapi = {
+        enable = true;
+        settings = {
+          server = {
+            listen_ipv4 = cfg.address.ipv4;
+            listen_ipv6 = cfg.address.ipv6;
+            port = cfg.port;
+            public_url = "https://${cfg.domain}";
+          };
+          app = {
+            leaderboard_enabled = false;
+            avatar_url_template = "https://0.gravatar.com/avatar/{email_hash}";
+            date_format = "2006-01-02"; # Go
+            datetime_format = "2006-01-02 15:04";
+          };
+        };
+        environmentFiles = [
+          config.vaultix.templates.wakapi-env.path
+        ];
+      };
+
+      vaultix.secrets.wakapi-password-salt.file = cfg.passwordSaltFileAged;
+      vaultix.templates.wakapi-env.content = ''
+        WAKAPI_PASSWORD_SALT=${config.vaultix.placeholder.wakapi-password-salt}
+      '';
+    };
+  };
+}