Commit 7262adc
Changed files (53)
.secrets
cache
cyrene
kevin
mobius
tribios
modules
dev
hosts
services
forgejo
mihomo
woodpecker
.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/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}
+ '';
+ };
+ };
+}