main
1{den, ...}: {
2 den.aspects.services.caddy.tailscale = {host, ...}: {
3 nixos = {
4 reverseProxy,
5 config,
6 lib,
7 pkgs,
8 ...
9 }: let
10 tailnetDomain = den.aspects.services.caddy.tailnetDomain;
11
12 inherit
13 (import ./_lib.nix {inherit lib;})
14 mkEntry
15 entryBlock
16 fallbackBlock
17 forwardAuthBlock
18 ;
19
20 entries =
21 lib.concatMap (
22 r:
23 lib.mapAttrsToList (mkEntry {
24 kind = "inline";
25 address = "127.0.0.1";
26 }) (lib.filterAttrs (_: conf: conf.tailscale or false) r.value)
27 )
28 (lib.filter (r: r.source.host.name == host.name) reverseProxy);
29
30 enabled = builtins.length entries > 0;
31
32 # Wildcard site: only obtains the *.net.trin.one certificate via DNS-01.
33 # Concrete subdomain sites below reuse it automatically (Caddy 2.10+).
34 certificateVirtualHost = {
35 hostName = "*.${tailnetDomain}";
36 extraConfig = ''
37 tls {
38 dns cloudflare {env.CF_API_TOKEN}
39 resolvers 1.1.1.1 1.0.0.1
40 }
41 abort
42 '';
43 };
44
45 mkVirtualHost = name: group: let
46 noPath = lib.filter (e: e.path == null) group;
47 withPath = lib.filter (e: e.path != null) group;
48 noPathCount = builtins.length noPath;
49 in
50 assert lib.assertMsg (noPathCount <= 1) (
51 "Multiple entries without a path for domain '${name}':"
52 + "only one root (pathless) entry per domain is allowed,"
53 + "but found ${toString noPathCount}."
54 ); let
55 pathLines = lib.concatMap (entryBlock config) withPath;
56 fallbackLines =
57 if noPathCount == 1
58 then [(fallbackBlock config (builtins.head noPath))]
59 else [];
60 blocks = pathLines ++ fallbackLines;
61 in {
62 inherit name;
63 value.extraConfig = lib.concatLines (["encode zstd gzip" forwardAuthBlock] ++ blocks);
64 };
65
66 virtualHosts = let
67 grouped = lib.groupBy (e: e.domain) entries;
68 siteHosts = lib.mapAttrsToList mkVirtualHost grouped;
69 in
70 lib.listToAttrs (siteHosts
71 ++ [
72 {
73 name = "tailnet-wildcard";
74 value = certificateVirtualHost;
75 }
76 ]);
77 in
78 lib.mkIf enabled {
79 services.caddy = {
80 package = pkgs.caddy.withPlugins {
81 plugins = [
82 "github.com/caddy-dns/cloudflare@v0.2.4"
83 ];
84 hash = "sha256-7GoH8YLCoPmPExQxoga2FHB58zQDoZVf1BBwkVi0SsQ=";
85 };
86 virtualHosts = virtualHosts;
87 };
88
89 systemd.services.caddy.serviceConfig.EnvironmentFile = [config.vaultix.templates.caddy-tailnet-env.path];
90
91 vaultix.secrets.tailnet-cf-token.file = ./tailscale-cloudflare-token.age;
92 vaultix.templates.caddy-tailnet-env = {
93 content = ''
94 CF_API_TOKEN=${config.vaultix.placeholder.tailnet-cf-token}
95 '';
96 owner = config.services.caddy.user;
97 group = config.services.caddy.group;
98 mode = "0400";
99 };
100
101 systemd.sockets.tailscale-nginx-auth = {
102 description = "Tailscale NGINX Authentication socket";
103 partOf = ["tailscale-nginx-auth.service"];
104 wantedBy = ["sockets.target"];
105 listenStreams = ["/run/tailscale.nginx-auth.sock"];
106 };
107
108 systemd.services.tailscale-nginx-auth = {
109 description = "Tailscale NGINX Authentication service";
110 requires = ["tailscale-nginx-auth.socket"];
111 after = ["tailscaled.service"];
112
113 serviceConfig = {
114 ExecStart = "${pkgs.tailscale-nginx-auth}/bin/tailscale.nginx-auth";
115 DynamicUser = true;
116 BindPaths = ["/run/tailscale/tailscaled.sock"];
117 PrivateDevices = true;
118 ProtectHome = true;
119 RestrictAddressFamilies = ["AF_UNIX"];
120 Restart = "on-failure";
121 };
122 };
123 };
124 };
125}