old
1{
2 den,
3 lib,
4 ...
5}: {
6 den.aspects.services.webserver.caddy = {
7 includes = [
8 den.aspects.services.webserver.caddy.reverse-proxy-collector
9 ];
10
11 settings = {
12 tailscaleUseHttps = lib.mkEnableOption "Whether to use https for tailscale internal virtual hosts";
13 };
14
15 persist = {
16 directories = [
17 {
18 directory = "/var/lib/caddy";
19 user = "caddy";
20 group = "caddy";
21 }
22 ];
23 };
24
25 nixos = {
26 services.caddy = {
27 enable = true;
28 enableReload = true;
29
30 globalConfig = ''
31 http_port 80
32 https_port 443
33 '';
34 };
35
36 networking.firewall.allowedTCPPorts = [80 443];
37 };
38 };
39
40 den.aspects.services.webserver.caddy.reverse-proxy-collector = {host, ...}: {
41 nixos = {
42 reverseProxy,
43 reverseProxyTailscale,
44 config,
45 ...
46 }: let
47 external = host.settings.services.webserver.external;
48
49 # Wrap IPv6 addresses in brackets so they can be used in a URL authority.
50 fmtAddr = isV6: addr:
51 if isV6
52 then "[${addr}]"
53 else addr;
54
55 # Resolve how Caddy on the current host should reach the host that
56 # submitted the reverse proxy (the source host).
57 #
58 # Priority:
59 # 1. Same host -> loopback.
60 # 2. Both ends have Tailscale -> use the source's Tailscale IP.
61 # 3. Source has a clear text -> use it directly.
62 # 4. Source has a secret IP -> import it from a vaultix template.
63 # 5. Otherwise -> abort with a helpful message.
64 resolveAddress = srcHost: let
65 src = srcHost.address;
66 cur = host.address;
67
68 v4TailscaleBoth = src.ipv4.tailscale != null && cur.ipv4.tailscale != null;
69 v6TailscaleBoth = src.ipv6.tailscale != null && cur.ipv6.tailscale != null;
70
71 v4ClearText = src.ipv4.clearText != null;
72 v6ClearText = src.ipv6.clearText != null;
73
74 v4Secret = src.ipv4.secret.name != null && src.ipv4.secret.file != null;
75 v6Secret = src.ipv6.secret.name != null && src.ipv6.secret.file != null;
76 in
77 if srcHost.name == host.name
78 then {
79 kind = "inline";
80 address = "127.0.0.1";
81 }
82 else if v4TailscaleBoth
83 then {
84 kind = "inline";
85 address = fmtAddr false src.ipv4.tailscale;
86 }
87 else if v6TailscaleBoth
88 then {
89 kind = "inline";
90 address = fmtAddr true src.ipv6.tailscale;
91 }
92 else if v4ClearText
93 then {
94 kind = "inline";
95 address = fmtAddr false src.ipv4.clearText;
96 }
97 else if v6ClearText
98 then {
99 kind = "inline";
100 address = fmtAddr true src.ipv6.clearText;
101 }
102 else if v4Secret
103 then {
104 kind = "import";
105 isV6 = false;
106 secretName = src.ipv4.secret.name;
107 secretFile = src.ipv4.secret.file;
108 }
109 else if v6Secret
110 then {
111 kind = "import";
112 isV6 = true;
113 secretName = src.ipv6.secret.name;
114 secretFile = src.ipv6.secret.file;
115 }
116 else
117 abort ''
118 Caddy on host '${host.name}' cannot reverse proxy to host '${srcHost.name}': no usable address is configured.
119
120 Please configure an address for host '${srcHost.name}' (see `address` in modules/hosts/schema.nix), one of:
121 - Tailscale IPv4/IPv6 on both '${srcHost.name}' and '${host.name}' (address.ipv4.tailscale / address.ipv6.tailscale)
122 - clear text IPv4/IPv6 (address.ipv4.clearText / address.ipv6.clearText)
123 - secret IPv4/IPv6 (address.ipv4.secret / address.ipv6.secret)
124 '';
125
126 sanitize = lib.replaceStrings ["." ":" "/" "*"] ["-" "-" "-" "-"];
127 templateName = domain: "caddy-reverse-proxy-${sanitize domain}";
128
129 normalizePath = p: let
130 p' =
131 if lib.hasPrefix "/" p
132 then p
133 else "/${p}";
134 in
135 lib.removeSuffix "/" p';
136
137 mkEntry = tailscale: resolution: domain: conf: {
138 inherit domain resolution tailscale;
139 https = conf.https or false;
140 port = conf.port;
141 path =
142 if conf ? path && conf.path != null
143 then normalizePath conf.path
144 else null;
145 stripPath = conf.stripPath or true;
146 upstream = conf.upstream or null;
147 };
148
149 publicEntries =
150 lib.concatMap (
151 r: lib.mapAttrsToList (mkEntry false (resolveAddress r.source.host)) r.value
152 )
153 reverseProxy;
154
155 tailscaleEntries =
156 lib.concatMap (
157 r:
158 lib.mapAttrsToList (mkEntry true {
159 kind = "inline";
160 address = "127.0.0.1";
161 })
162 r.value
163 )
164 (lib.filter (r: r.source.host.name == host.name) reverseProxyTailscale);
165
166 entries = tailscaleEntries ++ lib.optionals external publicEntries;
167
168 proxyLine = e:
169 if e.resolution.kind == "import"
170 then "import ${config.vaultix.templates.${templateName e.domain}.path}"
171 else let
172 scheme =
173 if e.https
174 then "https"
175 else "http";
176 upstream =
177 if e.upstream != null
178 then e.upstream
179 else "";
180 in "reverse_proxy ${scheme}://${e.resolution.address}:${toString e.port}${upstream}";
181
182 entryLines = e: [(proxyLine e)];
183
184 importEntries = lib.filter (e: e.resolution.kind == "import") entries;
185
186 siteName = e:
187 if e.tailscale && !host.settings.services.webserver.caddy.tailscaleUseHttps
188 then "http://${e.domain}"
189 else e.domain;
190
191 forwardAuthBlock = ''
192 forward_auth unix//run/tailscale.nginx-auth.sock {
193 uri /auth
194 header_up Remote-Addr {remote_host}
195 header_up Remote-Port {remote_port}
196 header_up Original-URI {uri}
197 copy_headers {
198 Tailscale-User>X-Webauth-User
199 Tailscale-Name>X-Webauth-Name
200 Tailscale-Login>X-Webauth-Login
201 Tailscale-Tailnet>X-Webauth-Tailnet
202 Tailscale-Profile-Picture>X-Webauth-Profile-Picture
203 }
204 }
205 '';
206
207 entryBlock = e: let
208 lines = entryLines e;
209 directive =
210 if e.stripPath
211 then "handle_path"
212 else "handle";
213 mkBlock = p: "${directive} ${p} {\n${lib.concatMapStrings (l: "\t${l}\n") lines}}";
214 in [
215 (mkBlock e.path)
216 (mkBlock "${e.path}/*")
217 ];
218
219 fallbackBlock = e: let
220 lines = entryLines e;
221 in "handle {\n${lib.concatMapStrings (l: "\t${l}\n") lines}}";
222
223 virtualHosts = let
224 grouped = lib.groupBy (e: siteName e) entries;
225
226 mkVirtualHost = name: group: let
227 hasTailscale = lib.any (e: e.tailscale) group;
228 noPath = lib.filter (e: e.path == null) group;
229 withPath = lib.filter (e: e.path != null) group;
230 noPathCount = builtins.length noPath;
231 in
232 assert lib.assertMsg (noPathCount <= 1)
233 "Multiple entries without a path for domain '${name}': only one root (pathless) entry per domain is allowed, but found ${toString noPathCount}."; let
234 hasAuth = host.hasAspect den.aspects.services.tailscale-nginx-auth;
235 in
236 assert lib.assertMsg (!hasTailscale || hasAuth) (
237 "Caddy on host '${host.name}' cannot use Tailscale reverse proxy: the tailscale-nginx-auth aspect is not enabled.\n\n"
238 + "Add `den.aspects.services.tailscale-nginx-auth` to the host's aspects to enable "
239 + "Tailscale authentication for reverse-proxied services."
240 ); let
241 authLines =
242 if hasTailscale
243 then [forwardAuthBlock]
244 else [];
245 pathLines = lib.concatMap entryBlock withPath;
246 fallbackLines =
247 if noPathCount == 1
248 then [(fallbackBlock (builtins.head noPath))]
249 else [];
250 blocks = authLines ++ pathLines ++ fallbackLines;
251 in {
252 inherit name;
253 value.extraConfig = lib.concatLines (["encode zstd gzip"] ++ blocks);
254 };
255 in
256 lib.listToAttrs (lib.mapAttrsToList mkVirtualHost grouped);
257
258 reverseProxyTemplates = lib.listToAttrs (map (e: {
259 name = templateName e.domain;
260 value = {
261 content = ''
262 reverse_proxy http${lib.optionalString e.https "s"}://${fmtAddr e.resolution.isV6 config.vaultix.placeholder.${e.resolution.secretName}}:${toString e.port}
263 '';
264 owner = config.services.caddy.user;
265 group = config.services.caddy.group;
266 mode = "0400";
267 };
268 })
269 importEntries);
270
271 reverseProxySecrets = lib.listToAttrs (map (e: {
272 name = e.resolution.secretName;
273 value.file = e.resolution.secretFile;
274 })
275 importEntries);
276 in {
277 services.caddy.virtualHosts = virtualHosts;
278 vaultix.secrets = reverseProxySecrets;
279 vaultix.templates = reverseProxyTemplates;
280 };
281 };
282}