main
1{den, ...}: {
2 den.aspects.services.caddy = {
3 includes = [
4 den.aspects.services.caddy.reverse-proxy-collector
5 ];
6
7 persist = {
8 directories = [
9 {
10 directory = "/var/lib/caddy";
11 user = "caddy";
12 group = "caddy";
13 }
14 ];
15 };
16
17 nixos = {pkgs, ...}: {
18 services.caddy = {
19 enable = true;
20 enableReload = true;
21 package = pkgs.caddy.withPlugins {
22 plugins = [
23 "github.com/caddy-dns/cloudflare@v0.2.4"
24 "github.com/mholt/caddy-l4@v0.1.2"
25 ];
26 hash = "sha256-AV85IaY63+Jkb3JadhQFZ6CeBkzbk9AWqY0/B3KP/4U=";
27 };
28
29 globalConfig = ''
30 http_port 80
31 https_port 443
32 '';
33 };
34
35 networking.firewall.allowedTCPPorts = [80 443];
36 };
37 };
38
39 den.aspects.services.caddy.reverse-proxy-collector = {host, ...}: {
40 nixos = {
41 reverseProxy,
42 lib,
43 config,
44 ...
45 }: let
46 inherit
47 (import ./_lib.nix {inherit lib;})
48 fmtAddr
49 mkEntry
50 entryBlock
51 fallbackBlock
52 templateName
53 ;
54
55 # Prefer loopback, then a clear-text address, then a vaultix-imported
56 # secret; abort when none is available.
57 resolveAddress = srcHost: let
58 src = srcHost.address;
59
60 v4ClearText = src.ipv4.clearText != null;
61 v6ClearText = src.ipv6.clearText != null;
62
63 v4Secret = src.ipv4.secret.name != null && src.ipv4.secret.file != null;
64 v6Secret = src.ipv6.secret.name != null && src.ipv6.secret.file != null;
65 in
66 if srcHost.name == host.name
67 then {
68 kind = "inline";
69 address = "127.0.0.1";
70 }
71 else if v4ClearText
72 then {
73 kind = "inline";
74 address = fmtAddr false src.ipv4.clearText;
75 }
76 else if v6ClearText
77 then {
78 kind = "inline";
79 address = fmtAddr true src.ipv6.clearText;
80 }
81 else if v4Secret
82 then {
83 kind = "import";
84 isV6 = false;
85 secretName = src.ipv4.secret.name;
86 secretFile = src.ipv4.secret.file;
87 }
88 else if v6Secret
89 then {
90 kind = "import";
91 isV6 = true;
92 secretName = src.ipv6.secret.name;
93 secretFile = src.ipv6.secret.file;
94 }
95 else
96 abort ''
97 Caddy on host '${host.name}' cannot reverse proxy to host '${srcHost.name}': no usable address is configured.
98
99 Please configure an address for host '${srcHost.name}' (see `address` in modules/hosts/schema.nix), one of:
100 - clear text IPv4/IPv6 (address.ipv4.clearText / address.ipv6.clearText)
101 - secret IPv4/IPv6 (address.ipv4.secret / address.ipv6.secret)
102 '';
103
104 entries = lib.unique (
105 lib.concatMap (
106 r:
107 lib.mapAttrsToList (mkEntry (resolveAddress r.source.host)) (
108 lib.filterAttrs (_: conf: !(conf.tailscale or false)) r.value
109 )
110 )
111 (lib.filter (r: r.source.host.name == host.name) reverseProxy)
112 );
113
114 importEntries = lib.filter (e: e.resolution.kind == "import") entries;
115
116 virtualHosts = let
117 grouped = lib.groupBy (e: e.domain) entries;
118
119 mkVirtualHost = name: group: let
120 noPath = lib.filter (e: e.path == null) group;
121 withPath = lib.filter (e: e.path != null) group;
122 noPathCount = builtins.length noPath;
123 in
124 assert lib.assertMsg (noPathCount <= 1)
125 ("Multiple entries without a path for domain '${name}':"
126 + "only one root (pathless) entry per domain is allowed,"
127 + "but found ${toString noPathCount}."); let
128 pathLines = lib.concatMap (entryBlock config) withPath;
129 fallbackLines =
130 if noPathCount == 1
131 then [(fallbackBlock config (builtins.head noPath))]
132 else [];
133 blocks = pathLines ++ fallbackLines;
134 in {
135 inherit name;
136 value.extraConfig = lib.concatLines (["encode zstd gzip"] ++ blocks);
137 };
138 in
139 lib.listToAttrs (lib.mapAttrsToList mkVirtualHost grouped);
140
141 reverseProxyTemplates = lib.listToAttrs (map (e: {
142 name = templateName e.domain;
143 value = {
144 content = ''
145 reverse_proxy http://${
146 fmtAddr e.resolution.isV6
147 config.vaultix.placeholder.${e.resolution.secretName}
148 }:${toString e.port}
149 '';
150 owner = config.services.caddy.user;
151 group = config.services.caddy.group;
152 mode = "0400";
153 };
154 })
155 importEntries);
156
157 reverseProxySecrets = lib.listToAttrs (map (e: {
158 name = e.resolution.secretName;
159 value.file = e.resolution.secretFile;
160 })
161 importEntries);
162 in {
163 services.caddy.virtualHosts = virtualHosts;
164 vaultix.secrets = reverseProxySecrets;
165 vaultix.templates = reverseProxyTemplates;
166 };
167 };
168}