1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.cloudflared;
9
10 certificateFile = lib.mkOption {
11 type = with lib.types; nullOr path;
12 description = ''
13 Account certificate file, necessary to create, delete and manage tunnels. It can be obtained by running `cloudflared login`.
14
15 Note that this is **necessary** for a fully declarative set up, as routes can not otherwise be created outside of the Cloudflare interface.
16
17 See [Cert.pem](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/tunnel-useful-terms/#certpem) for information about the file, and [Tunnel permissions](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/local-management/tunnel-permissions/) for a comparison between the account certificate and the tunnel credentials file.
18 '';
19 default = null;
20 };
21
22 originRequest = {
23 connectTimeout = lib.mkOption {
24 type = with lib.types; nullOr str;
25 default = null;
26 example = "30s";
27 description = ''
28 Timeout for establishing a new TCP connection to your origin server. This excludes the time taken to establish TLS, which is controlled by [tlsTimeout](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/configuration/local-management/ingress/#tlstimeout).
29 '';
30 };
31
32 tlsTimeout = lib.mkOption {
33 type = with lib.types; nullOr str;
34 default = null;
35 example = "10s";
36 description = ''
37 Timeout for completing a TLS handshake to your origin server, if you have chosen to connect Tunnel to an HTTPS server.
38 '';
39 };
40
41 tcpKeepAlive = lib.mkOption {
42 type = with lib.types; nullOr str;
43 default = null;
44 example = "30s";
45 description = ''
46 The timeout after which a TCP keepalive packet is sent on a connection between Tunnel and the origin server.
47 '';
48 };
49
50 noHappyEyeballs = lib.mkOption {
51 type = with lib.types; nullOr bool;
52 default = null;
53 example = false;
54 description = ''
55 Disable the “happy eyeballs” algorithm for IPv4/IPv6 fallback if your local network has misconfigured one of the protocols.
56 '';
57 };
58
59 keepAliveConnections = lib.mkOption {
60 type = with lib.types; nullOr int;
61 default = null;
62 example = 100;
63 description = ''
64 Maximum number of idle keepalive connections between Tunnel and your origin. This does not restrict the total number of concurrent connections.
65 '';
66 };
67
68 keepAliveTimeout = lib.mkOption {
69 type = with lib.types; nullOr str;
70 default = null;
71 example = "1m30s";
72 description = ''
73 Timeout after which an idle keepalive connection can be discarded.
74 '';
75 };
76
77 httpHostHeader = lib.mkOption {
78 type = with lib.types; nullOr str;
79 default = null;
80 example = "";
81 description = ''
82 Sets the HTTP `Host` header on requests sent to the local service.
83 '';
84 };
85
86 originServerName = lib.mkOption {
87 type = with lib.types; nullOr str;
88 default = null;
89 example = "";
90 description = ''
91 Hostname that `cloudflared` should expect from your origin server certificate.
92 '';
93 };
94
95 caPool = lib.mkOption {
96 type = with lib.types; nullOr (either str path);
97 default = null;
98 example = "";
99 description = ''
100 Path to the certificate authority (CA) for the certificate of your origin. This option should be used only if your certificate is not signed by Cloudflare.
101 '';
102 };
103
104 noTLSVerify = lib.mkOption {
105 type = with lib.types; nullOr bool;
106 default = null;
107 example = false;
108 description = ''
109 Disables TLS verification of the certificate presented by your origin. Will allow any certificate from the origin to be accepted.
110 '';
111 };
112
113 disableChunkedEncoding = lib.mkOption {
114 type = with lib.types; nullOr bool;
115 default = null;
116 example = false;
117 description = ''
118 Disables chunked transfer encoding. Useful if you are running a WSGI server.
119 '';
120 };
121
122 proxyAddress = lib.mkOption {
123 type = with lib.types; nullOr str;
124 default = null;
125 example = "127.0.0.1";
126 description = ''
127 `cloudflared` starts a proxy server to translate HTTP traffic into TCP when proxying, for example, SSH or RDP. This configures the listen address for that proxy.
128 '';
129 };
130
131 proxyPort = lib.mkOption {
132 type = with lib.types; nullOr int;
133 default = null;
134 example = 0;
135 description = ''
136 `cloudflared` starts a proxy server to translate HTTP traffic into TCP when proxying, for example, SSH or RDP. This configures the listen port for that proxy. If set to zero, an unused port will randomly be chosen.
137 '';
138 };
139
140 proxyType = lib.mkOption {
141 type =
142 with lib.types;
143 nullOr (enum [
144 ""
145 "socks"
146 ]);
147 default = null;
148 example = "";
149 description = ''
150 `cloudflared` starts a proxy server to translate HTTP traffic into TCP when proxying, for example, SSH or RDP. This configures what type of proxy will be started. Valid options are:
151
152 - `""` for the regular proxy
153 - `"socks"` for a SOCKS5 proxy. Refer to the [tutorial on connecting through Cloudflare Access using kubectl](https://developers.cloudflare.com/cloudflare-one/tutorials/kubectl/) for more information.
154 '';
155 };
156 };
157in
158{
159 imports = [
160 (lib.mkRemovedOptionModule
161 [
162 "services"
163 "cloudflared"
164 "user"
165 ]
166 ''
167 Cloudflared now uses a dynamic user, and this option no longer has any effect.
168
169 If the user is still necessary, please define it manually using users.users.cloudflared.
170 ''
171 )
172
173 (lib.mkRemovedOptionModule
174 [
175 "services"
176 "cloudflared"
177 "group"
178 ]
179 ''
180 Cloudflared now uses a dynamic user, and this option no longer has any effect.
181
182 If the group is still necessary, please define it manually using users.groups.cloudflared.
183 ''
184 )
185 ];
186
187 options.services.cloudflared = {
188 inherit certificateFile;
189
190 enable = lib.mkEnableOption "Cloudflare Tunnel client daemon (formerly Argo Tunnel)";
191
192 package = lib.mkPackageOption pkgs "cloudflared" { };
193
194 tunnels = lib.mkOption {
195 description = ''
196 Cloudflare tunnels.
197 '';
198 type = lib.types.attrsOf (
199 lib.types.submodule (
200 { name, ... }:
201 {
202 options = {
203 inherit certificateFile originRequest;
204
205 credentialsFile = lib.mkOption {
206 type = lib.types.path;
207 description = ''
208 Credential file.
209
210 See [Credentials file](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/tunnel-useful-terms/#credentials-file).
211 '';
212 };
213
214 warp-routing = {
215 enabled = lib.mkOption {
216 type = with lib.types; nullOr bool;
217 default = null;
218 description = ''
219 Enable warp routing.
220
221 See [Connect from WARP to a private network on Cloudflare using Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/tutorials/warp-to-tunnel/).
222 '';
223 };
224 };
225
226 default = lib.mkOption {
227 type = lib.types.str;
228 description = ''
229 Catch-all service if no ingress matches.
230
231 See `service`.
232 '';
233 example = "http_status:404";
234 };
235
236 ingress = lib.mkOption {
237 type =
238 with lib.types;
239 attrsOf (
240 either str (
241 submodule (
242 { hostname, ... }:
243 {
244 options = {
245 inherit originRequest;
246
247 service = lib.mkOption {
248 type = with lib.types; nullOr str;
249 default = null;
250 description = ''
251 Service to pass the traffic.
252
253 See [Supported protocols](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/configuration/local-management/ingress/#supported-protocols).
254 '';
255 example = "http://localhost:80, tcp://localhost:8000, unix:/home/production/echo.sock, hello_world or http_status:404";
256 };
257
258 path = lib.mkOption {
259 type = with lib.types; nullOr str;
260 default = null;
261 description = ''
262 Path filter.
263
264 If not specified, all paths will be matched.
265 '';
266 example = "/*.(jpg|png|css|js)";
267 };
268
269 };
270 }
271 )
272 )
273 );
274 default = { };
275 description = ''
276 Ingress rules.
277
278 See [Ingress rules](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/configuration/local-management/ingress/).
279 '';
280 example = {
281 "*.domain.com" = "http://localhost:80";
282 "*.anotherone.com" = "http://localhost:80";
283 };
284 };
285 };
286 }
287 )
288 );
289
290 default = { };
291 example = {
292 "00000000-0000-0000-0000-000000000000" = {
293 credentialsFile = "/tmp/test";
294 ingress = {
295 "*.domain1.com" = {
296 service = "http://localhost:80";
297 };
298 };
299 default = "http_status:404";
300 };
301 };
302 };
303 };
304
305 config = lib.mkIf cfg.enable {
306 systemd.targets = lib.mapAttrs' (
307 name: tunnel:
308 lib.nameValuePair "cloudflared-tunnel-${name}" {
309 description = "Cloudflare tunnel '${name}' target";
310 requires = [ "cloudflared-tunnel-${name}.service" ];
311 after = [ "cloudflared-tunnel-${name}.service" ];
312 unitConfig.StopWhenUnneeded = true;
313 }
314 ) config.services.cloudflared.tunnels;
315
316 systemd.services = lib.mapAttrs' (
317 name: tunnel:
318 let
319 filterConfig = lib.attrsets.filterAttrsRecursive (
320 _: v:
321 !builtins.elem v [
322 null
323 [ ]
324 { }
325 ]
326 );
327
328 filterIngressSet = lib.filterAttrs (_: v: builtins.typeOf v == "set");
329 filterIngressStr = lib.filterAttrs (_: v: builtins.typeOf v == "string");
330
331 ingressesSet = filterIngressSet tunnel.ingress;
332 ingressesStr = filterIngressStr tunnel.ingress;
333
334 fullConfig = filterConfig {
335 tunnel = name;
336 credentials-file = "/run/credentials/cloudflared-tunnel-${name}.service/credentials.json";
337 warp-routing = filterConfig tunnel.warp-routing;
338 originRequest = filterConfig tunnel.originRequest;
339 ingress =
340 (map (
341 key:
342 {
343 hostname = key;
344 }
345 // lib.getAttr key (filterConfig (filterConfig ingressesSet))
346 ) (lib.attrNames ingressesSet))
347 ++ (map (key: {
348 hostname = key;
349 service = lib.getAttr key ingressesStr;
350 }) (lib.attrNames ingressesStr))
351 ++ [ { service = tunnel.default; } ];
352 };
353
354 mkConfigFile = pkgs.writeText "cloudflared.yml" (builtins.toJSON fullConfig);
355 certFile = if (tunnel.certificateFile != null) then tunnel.certificateFile else cfg.certificateFile;
356 in
357 lib.nameValuePair "cloudflared-tunnel-${name}" {
358 after = [
359 "network.target"
360 "network-online.target"
361 ];
362 wants = [
363 "network.target"
364 "network-online.target"
365 ];
366 wantedBy = [ "multi-user.target" ];
367 serviceConfig = {
368 RuntimeDirectory = "cloudflared-tunnel-${name}";
369 RuntimeDirectoryMode = "0400";
370 LoadCredential = [
371 "credentials.json:${tunnel.credentialsFile}"
372 ] ++ (lib.optional (certFile != null) "cert.pem:${certFile}");
373
374 ExecStart = "${cfg.package}/bin/cloudflared tunnel --config=${mkConfigFile} --no-autoupdate run";
375 Restart = "on-failure";
376 DynamicUser = true;
377 };
378
379 environment.TUNNEL_ORIGIN_CERT = lib.mkIf (certFile != null) ''%d/cert.pem'';
380 }
381 ) config.services.cloudflared.tunnels;
382 };
383
384 meta.maintainers = with lib.maintainers; [
385 bbigras
386 anpin
387 ];
388}