1{
2 utils,
3 config,
4 options,
5 lib,
6 pkgs,
7 ...
8}:
9let
10 cfg = config.services.pangolin;
11 format = pkgs.formats.yaml { };
12 finalSettings = lib.attrsets.recursiveUpdate pangolinConf cfg.settings;
13 cfgFile = format.generate "config.yml" finalSettings;
14 # override the type to allow for optionality
15 nullOrOpt = t: lib.types.nullOr t // { _optional = true; };
16
17 gerbil-wg0-fix-script = pkgs.writeShellApplication {
18 name = "gerbil-wg0-fix-script";
19 runtimeInputs = with pkgs; [
20 coreutils
21 iproute2
22 ];
23 # will not work if the interface is renamed
24 # https://github.com/fosrl/newt/issues/37#issuecomment-3193385911
25 text = ''
26 if [ ! -f /var/lib/pangolin/config/wg0 ]; then
27 until ip l d wg0
28 do
29 sleep 2
30 done
31 touch /var/lib/pangolin/config/wg0
32 systemctl restart gerbil --no-block
33 fi
34 '';
35 };
36
37 pangolinConf = {
38 app.dashboard_url = "https://${cfg.dashboardDomain}";
39 domains.domain1 = {
40 base_domain = cfg.baseDomain;
41 prefer_wildcard_cert = false;
42 };
43 server = {
44 external_port = 3000;
45 internal_port = 3001;
46 next_port = 3002;
47 integration_port = 3004;
48 # needs to be set, otherwise this fails silently
49 # see https://github.com/fosrl/newt/issues/37
50 internal_hostname = "localhost";
51 };
52 gerbil.base_endpoint = cfg.dashboardDomain;
53 flags.enable_integration_api = false;
54 };
55in
56{
57 options.services = {
58 pangolin = {
59 enable = lib.mkEnableOption "Pangolin reverse proxy server";
60 package = lib.mkPackageOption pkgs "fosrl-pangolin" { };
61
62 settings = lib.mkOption {
63 inherit (format) type;
64 default = { };
65 description = ''
66 Additional attributes to be merged with the configuration options and written to Pangolin's `config.yml` file.
67 '';
68 example = {
69 app = {
70 save_logs = true;
71 };
72 server = {
73 external_port = 3007;
74 internal_port = 3008;
75 };
76 domains.domain1 = {
77 prefer_wildcard_cert = true;
78 };
79 };
80 };
81
82 openFirewall = lib.mkEnableOption "opening TCP ports 80 and 443, and UDP port 51820 in the firewall for the Pangolin service(s)";
83
84 baseDomain = lib.mkOption {
85 type = with lib.types; nullOr str;
86 default = null;
87 description = ''
88 Your base fully qualified domain name (without any subdomains).
89 '';
90 example = "example.com";
91 };
92
93 dashboardDomain = lib.mkOption {
94 type = lib.types.str;
95 default = if (isNull cfg.baseDomain) then "" else "pangolin.${cfg.baseDomain}";
96 defaultText = "pangolin.\${config.services.pangolin.baseDomain}";
97 description = ''
98 The domain where the application will be hosted. This is used for many things, including generating links. You can run Pangolin on a subdomain or root domain. Do not prefix with `http` or `https`.
99 '';
100 example = "auth.example.com";
101 };
102
103 letsEncryptEmail = lib.mkOption {
104 type = with lib.types; nullOr str;
105 default = config.security.acme.defaults.email;
106 defaultText = lib.literalExpression "config.security.acme.defaults.email";
107 description = ''
108 An email address for SSL certificate registration with Let's Encrypt. This should be an email you have access to.
109 '';
110 };
111
112 # this assumes that all domains are hosted by the same provider
113 dnsProvider = lib.mkOption {
114 type = nullOrOpt lib.types.str;
115 default = null;
116 description = ''
117 The DNS provider Traefik will request wildcard certificates from. See the [Traefik Documentation](https://doc.traefik.io/traefik/https/acme/#providers) for more information.
118 '';
119 };
120
121 # provide path to file to keep secrets out of the nix store
122 environmentFile = lib.mkOption {
123 type = with lib.types; nullOr path;
124 default = null;
125 description = ''
126 Path to a file containing sensitive environment variables for Pangolin. See the [Pangolin Documentation](https://docs.fossorial.io/Pangolin/Configuration/config) for more information.
127 These will overwrite anything defined in the config.
128 The file should contain environment-variable assignments like:
129 ```
130 SERVER_SECRET=1234567890abc
131 ```
132 '';
133 example = "/etc/nixos/secrets/pangolin.env";
134 };
135
136 dataDir = lib.mkOption {
137 type = lib.types.str;
138 default = "/var/lib/pangolin";
139 example = "/srv/pangolin";
140 description = "Path to variable state data directory for Pangolin.";
141 };
142 };
143 gerbil = {
144 port = lib.mkOption {
145 type = lib.types.port;
146 default = 3003;
147 description = ''
148 Specifies the port to listen on for Gerbil.
149 '';
150 };
151
152 environmentFile = lib.mkOption {
153 type = nullOrOpt lib.types.path;
154 default = null;
155 description = ''
156 Path to a file containing sensitive environment variables for Gerbil. See the [Gerbil Documentation](https://docs.fossorial.io/Pangolin/Configuration/config) for more information.
157 These will overwrite anything defined in the config.
158 '';
159 example = "/etc/nixos/secrets/gerbil.env";
160 };
161 };
162 };
163
164 config = lib.mkIf cfg.enable {
165
166 assertions =
167 (lib.mapAttrsToList (name: value: {
168 # check if the value is optional by looking at the type
169 assertion = (value == null) -> options.services.pangolin."${name}".type._optional or false;
170 message = "services.pangolin.${name} must be provided when Pangolin is enabled.";
171 }) cfg)
172 ++ [
173 {
174 # wildcards implies (dnsProvider and traefikEnvironmentFile)
175 assertion =
176 (finalSettings.traefik.prefer_wildcard_cert or finalSettings.domains.domain1.prefer_wildcard_cert)
177 -> (cfg.dnsProvider != "" && config.services.traefik.environmentFiles != [ ]);
178 message = "services.pangolin.dnsProvider and services.traefik.environmentFile must be provided when prefer_wildcard_cert is true.";
179 }
180 ];
181
182 networking.firewall = lib.mkIf cfg.openFirewall {
183 allowedTCPPorts = [
184 80
185 443
186 ];
187 allowedUDPPorts = [ 51820 ];
188 };
189
190 users = {
191 users = {
192 pangolin = {
193 description = "Pangolin service user";
194 group = "fossorial";
195 isSystemUser = true;
196 packages = [ cfg.package ];
197 };
198 gerbil = {
199 description = "Gerbil service user";
200 group = "fossorial";
201 isSystemUser = true;
202 };
203 };
204 groups.fossorial = {
205 members = [
206 "pangolin"
207 "gerbil"
208 "traefik"
209 ];
210 };
211 };
212 # order is as follows
213 # "pangolin.service"
214 # "gerbil.service"
215 # "traefik.service"
216 ### TODO:
217 # make tunnels declarative by calling API
218 ###
219 systemd = {
220 tmpfiles.settings."10-fossorial-paths" = {
221 "${cfg.dataDir}".d = {
222 user = "pangolin";
223 group = "fossorial";
224 mode = "0770";
225 };
226 "${cfg.dataDir}/config".d = {
227 user = "pangolin";
228 group = "fossorial";
229 mode = "0770";
230 };
231 "${cfg.dataDir}/config/letsencrypt".d = {
232 user = "traefik";
233 group = "fossorial";
234 mode = "0700";
235 };
236 };
237 services = {
238 pangolin = {
239 description = "Pangolin reverse proxy tunneling service";
240 wantedBy = [ "multi-user.target" ];
241 requires = [ "network.target" ];
242 after = [ "network.target" ];
243
244 preStart = ''
245 mkdir -p ${cfg.dataDir}/config
246 cp -f ${cfgFile} ${cfg.dataDir}/config/config.yml
247 '';
248
249 serviceConfig = {
250 User = "pangolin";
251 Group = "fossorial";
252 WorkingDirectory = cfg.dataDir;
253 Restart = "always";
254 EnvironmentFile = cfg.environmentFile;
255 # hardening
256 ProtectSystem = "full";
257 ProtectHome = true;
258 PrivateTmp = "disconnected";
259 PrivateDevices = true;
260 PrivateMounts = true;
261 ProtectKernelTunables = true;
262 ProtectKernelModules = true;
263 ProtectKernelLogs = true;
264 ProtectControlGroups = true;
265 LockPersonality = true;
266 RestrictRealtime = true;
267 ProtectClock = true;
268 ProtectProc = "noaccess";
269 ProtectHostname = true;
270 NoNewPrivileges = true;
271 RestrictSUIDSGID = true;
272 RestrictAddressFamilies = [
273 "AF_INET"
274 "AF_INET6"
275 "AF_NETLINK"
276 "AF_UNIX"
277 ];
278 SocketBindDeny = [
279 "ipv4:tcp"
280 "ipv4:udp"
281 "ipv6:udp"
282 ];
283 CapabilityBoundingSet = [
284 "~CAP_BLOCK_SUSPEND"
285 "~CAP_BPF"
286 "~CAP_CHOWN"
287 "~CAP_MKNOD"
288 "~CAP_NET_RAW"
289 "~CAP_PERFMON"
290 "~CAP_SYS_BOOT"
291 "~CAP_SYS_CHROOT"
292 "~CAP_SYS_MODULE"
293 "~CAP_SYS_NICE"
294 "~CAP_SYS_PACCT"
295 "~CAP_SYS_PTRACE"
296 "~CAP_SYS_TIME"
297 "~CAP_SYSLOG"
298 "~CAP_WAKE_ALARM"
299 ];
300 SystemCallFilter = [
301 "~@chown:EPERM"
302 "~@clock:EPERM"
303 "~@cpu-emulation:EPERM"
304 "~@debug:EPERM"
305 "~@keyring:EPERM"
306 "~@memlock:EPERM"
307 "~@module:EPERM"
308 "~@mount:EPERM"
309 "~@obsolete:EPERM"
310 "~@pkey:EPERM"
311 "~@privileged:EPERM"
312 "~@raw-io:EPERM"
313 "~@reboot:EPERM"
314 "~@resources:EPERM"
315 "~@sandbox:EPERM"
316 "~@setuid:EPERM"
317 "~@swap:EPERM"
318 "~@timer:EPERM"
319 ];
320 ExecStart = lib.getExe cfg.package;
321 };
322 };
323 gerbil = {
324 description = "Gerbil Service";
325 wantedBy = [ "multi-user.target" ];
326 after = [ "pangolin.service" ];
327 requires = [ "pangolin.service" ];
328 before = [ "traefik.service" ];
329 requiredBy = [ "traefik.service" ];
330 # restarting gerbil restarts traefik
331 upholds = [ "traefik.service" ];
332
333 # provide default to use correct port without envfile
334 environment = {
335 LISTEN = "localhost:" + toString config.services.gerbil.port;
336 };
337
338 serviceConfig = {
339 User = "gerbil";
340 Group = "fossorial";
341 WorkingDirectory = cfg.dataDir;
342 Restart = "always";
343 EnvironmentFile = cfg.environmentFile;
344 ReadWritePaths = "${cfg.dataDir}/config";
345 # hardening
346 AmbientCapabilities = [
347 "CAP_NET_ADMIN"
348 "CAP_SYS_MODULE"
349 ];
350 CapabilityBoundingSet = [
351 "CAP_NET_ADMIN"
352 "CAP_SYS_MODULE"
353 "~CAP_BLOCK_SUSPEND"
354 "~CAP_BPF"
355 "~CAP_CHOWN"
356 "~CAP_MKNOD"
357 "~CAP_PERFMON"
358 "~CAP_SYS_BOOT"
359 "~CAP_SYS_CHROOT"
360 "~CAP_SYS_NICE"
361 "~CAP_SYS_PACCT"
362 "~CAP_SYS_PTRACE"
363 "~CAP_SYS_TIME"
364 "~CAP_SYS_TTY_CONFIG"
365 "~CAP_SYSLOG"
366 "~CAP_WAKE_ALARM"
367 ];
368 ProtectSystem = "full";
369 ProtectHome = true;
370 PrivateTmp = "disconnected";
371 PrivateDevices = true;
372 PrivateMounts = true;
373 ProtectKernelTunables = true;
374 ProtectKernelModules = true;
375 ProtectKernelLogs = true;
376 ProtectControlGroups = true;
377 LockPersonality = true;
378 RestrictRealtime = true;
379 ProtectClock = true;
380 ProtectProc = "noaccess";
381 ProtectHostname = true;
382 NoNewPrivileges = true;
383 RestrictSUIDSGID = true;
384 MemoryDenyWriteExecute = true;
385 RestrictAddressFamilies = [
386 "AF_INET"
387 "AF_INET6"
388 "AF_NETLINK"
389 "AF_UNIX"
390 ];
391 SystemCallFilter = [
392 "~@aio:EPERM"
393 "~@chown:EPERM"
394 "~@clock:EPERM"
395 "~@cpu-emulation:EPERM"
396 "~@debug:EPERM"
397 "~@keyring:EPERM"
398 "~@memlock:EPERM"
399 "~@mount:EPERM"
400 "~@obsolete:EPERM"
401 "~@pkey:EPERM"
402 "~@privileged:EPERM"
403 "~@raw-io:EPERM"
404 "~@reboot:EPERM"
405 "~@resources:EPERM"
406 "~@sandbox:EPERM"
407 "~@setuid:EPERM"
408 "~@swap:EPERM"
409 "~@sync:EPERM"
410 "~@timer:EPERM"
411 ];
412 ExecStart = utils.escapeSystemdExecArgs [
413 (lib.getExe pkgs.fosrl-gerbil)
414 "--reachableAt=http://localhost:${toString config.services.gerbil.port}"
415 "--generateAndSaveKeyTo=${toString cfg.dataDir}/config/key"
416 "--remoteConfig=http://localhost:${toString finalSettings.server.internal_port}/api/v1/gerbil/get-config"
417 ];
418 # will not work if the interface is renamed
419 # https://github.com/fosrl/newt/issues/37#issuecomment-3193385911
420 ExecStartPost = lib.getExe gerbil-wg0-fix-script;
421 };
422 };
423 traefik = {
424 wantedBy = [ "multi-user.target" ];
425 after = [ "gerbil.service" ];
426 requires = [ "gerbil.service" ];
427 partOf = [ "gerbil.service" ];
428 };
429 };
430 };
431
432 services.traefik = {
433 enable = true;
434 group = "fossorial";
435 dataDir = "${cfg.dataDir}/config/traefik";
436 staticConfigOptions = {
437 providers.http = {
438 endpoint = "http://localhost:${toString finalSettings.server.internal_port}/api/v1/traefik-config";
439 pollInterval = "5s";
440 };
441 # TODO to change this once #437073 is merged.
442 experimental.plugins.badger = {
443 moduleName = "github.com/fosrl/badger";
444 version = "v1.2.0";
445 };
446 certificatesResolvers.letsencrypt.acme =
447 (
448 if finalSettings.domains.domain1.prefer_wildcard_cert then
449 {
450 # see https://doc.traefik.io/traefik/https/acme/#providers
451 dnsChallenge.provider = cfg.dnsProvider;
452 }
453 else
454 {
455 httpChallenge.entryPoint = "web";
456 }
457 )
458 //
459 # common
460 {
461 email = cfg.letsEncryptEmail;
462 storage = "${cfg.dataDir}/config/letsencrypt/acme.json";
463 caServer = "https://acme-v02.api.letsencrypt.org/directory";
464 };
465 entryPoints = {
466 web.address = ":80";
467 websecure = {
468 address = ":443";
469 transport.respondingTimeouts.readTimeout = "30m";
470 http.tls.certResolver = "letsencrypt";
471 };
472 };
473 };
474 dynamicConfigOptions = {
475 http = {
476 middlewares.redirect-to-https.redirectScheme.scheme = "https";
477 routers = {
478 # HTTP to HTTPS redirect router
479 main-app-router-redirect = {
480 rule = "Host(`${cfg.dashboardDomain}`)";
481 service = "next-service";
482 entryPoints = [ "web" ];
483 middlewares = [ "redirect-to-https" ];
484 };
485 # Next.js router (handles everything except API and WebSocket paths)
486 next-router = {
487 rule = "Host(`${cfg.dashboardDomain}`) && !PathPrefix(`/api/v1`)";
488 service = "next-service";
489 entryPoints = [ "websecure" ];
490 tls =
491 lib.optionalAttrs (finalSettings.domains.domain1.prefer_wildcard_cert) {
492 domains = [
493 { main = cfg.baseDomain; }
494 { sans = "*.${cfg.baseDomain}"; }
495 ];
496 }
497 //
498 # common
499 {
500 certResolver = "letsencrypt";
501 };
502 };
503 # API router (handles /api/v1 paths)
504 api-router = {
505 rule = "Host(`${cfg.dashboardDomain}`) && PathPrefix(`/api/v1`)";
506 service = "api-service";
507 entryPoints = [ "websecure" ];
508 tls.certResolver = "letsencrypt";
509 };
510 # WebSocket router
511 ws-router = {
512 rule = "Host(`${cfg.dashboardDomain}`)";
513 service = "api-service";
514 entryPoints = [ "websecure" ];
515 tls.certResolver = "letsencrypt";
516 };
517 # Integration API router
518 int-api-router-redirect = {
519 rule = "Host(`api.${cfg.baseDomain}`)";
520 service = "int-api-service";
521 entryPoints = [ "web" ];
522 middlewares = [ "redirect-to-https" ];
523 };
524 int-api-router = {
525 rule = "Host(`api.${cfg.baseDomain}`)";
526 service = "int-api-service";
527 entryPoints = [ "websecure" ];
528 tls.certResolver = "letsencrypt";
529 };
530 };
531 # needs to be a mkMerge otherwise will give error about standalone element
532 services = lib.mkMerge [
533 {
534 # Next.js server
535 next-service.loadBalancer.servers = [
536 { url = "http://localhost:${toString finalSettings.server.next_port}"; }
537 ];
538 # API/WebSocket server
539 api-service.loadBalancer.servers = [
540 { url = "http://localhost:${toString finalSettings.server.external_port}"; }
541 ];
542 }
543 (lib.mkIf (finalSettings.flags.enable_integration_api) {
544 # Integration API server
545 int-api-service.loadBalancer.servers = [
546 { url = "http://localhost:${toString finalSettings.server.integration_port}"; }
547 ];
548 })
549 ];
550 };
551 };
552 };
553 };
554
555 meta.maintainers = with lib.maintainers; [
556 jackr
557 sigmasquadron
558 ];
559}