main
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 mkEntry = tailscale: resolution: domain: conf: {
130 inherit domain resolution tailscale;
131 https = conf.https or false;
132 port = conf.port;
133 };
134
135 publicEntries =
136 lib.concatMap (
137 r: lib.mapAttrsToList (mkEntry false (resolveAddress r.source.host)) r.value
138 )
139 reverseProxy;
140
141 tailscaleEntries =
142 lib.concatMap (
143 r:
144 lib.mapAttrsToList (mkEntry true {
145 kind = "inline";
146 address = "127.0.0.1";
147 })
148 r.value
149 )
150 (lib.filter (r: r.source.host.name == host.name) reverseProxyTailscale);
151
152 entries = tailscaleEntries ++ lib.optionals external publicEntries;
153
154 proxyLine = e:
155 if e.resolution.kind == "import"
156 then "import ${config.vaultix.templates.${templateName e.domain}.path}"
157 else "reverse_proxy http${lib.optionalString e.https "s"}://${e.resolution.address}:${toString e.port}";
158
159 entryLines = e:
160 if !e.tailscale
161 then [(proxyLine e)]
162 else if (host.hasAspect den.aspects.services.tailscale-nginx-auth)
163 then [
164 ''
165 forward_auth unix//run/tailscale.nginx-auth.sock {
166 uri /auth
167 header_up Remote-Addr {remote_host}
168 header_up Remote-Port {remote_port}
169 header_up Original-URI {uri}
170 copy_headers {
171 Tailscale-User>X-Webauth-User
172 Tailscale-Name>X-Webauth-Name
173 Tailscale-Login>X-Webauth-Login
174 Tailscale-Tailnet>X-Webauth-Tailnet
175 Tailscale-Profile-Picture>X-Webauth-Profile-Picture
176 }
177 }
178 ''
179 (proxyLine e)
180 ]
181 else
182 abort ''
183 Caddy on host '${host.name}' cannot use Tailscale reverse proxy: the tailscale-nginx-auth aspect is not enabled.
184
185 Add `den.aspects.services.tailscale-nginx-auth` to the host's aspects to enable
186 Tailscale authentication for reverse-proxied services.
187 '';
188
189 importEntries = lib.filter (e: e.resolution.kind == "import") entries;
190
191 # Tailscale-only vhosts are served over plain HTTP: their domains have no
192 # public DNS, so Caddy's automatic HTTPS would fail trying to reach a
193 # public ACME CA. And self-hosted Headscale did'nt support HTTPS serve yet.
194 # The `http://` scheme disables automatic HTTPS for the site
195 # (Tailscale/WireGuard already encrypts the transport). Public vhosts keep
196 # automatic HTTPS.
197 siteName = e:
198 if e.tailscale && !host.settings.services.webserver.caddy.tailscaleUseHttps
199 then "http://${e.domain}"
200 else e.domain;
201
202 virtualHosts = lib.listToAttrs (map (e: {
203 name = siteName e;
204 value.extraConfig = lib.concatLines (["encode zstd gzip"] ++ entryLines e);
205 })
206 entries);
207
208 reverseProxyTemplates = lib.listToAttrs (map (e: {
209 name = templateName e.domain;
210 value = {
211 content = ''
212 reverse_proxy http${lib.optionalString e.https "s"}://${fmtAddr e.resolution.isV6 config.vaultix.placeholder.${e.resolution.secretName}}:${toString e.port}
213 '';
214 owner = config.services.caddy.user;
215 group = config.services.caddy.group;
216 mode = "0400";
217 };
218 })
219 importEntries);
220
221 reverseProxySecrets = lib.listToAttrs (map (e: {
222 name = e.resolution.secretName;
223 value.file = e.resolution.secretFile;
224 })
225 importEntries);
226 in {
227 services.caddy.virtualHosts = virtualHosts;
228 vaultix.secrets = reverseProxySecrets;
229 vaultix.templates = reverseProxyTemplates;
230 };
231 };
232}