···
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; };
17
+
gerbil-wg0-fix-script = pkgs.writeShellApplication {
18
+
name = "gerbil-wg0-fix-script";
19
+
runtimeInputs = with pkgs; [
23
+
# will not work if the interface is renamed
24
+
# https://github.com/fosrl/newt/issues/37#issuecomment-3193385911
26
+
if [ ! -f /var/lib/pangolin/config/wg0 ]; then
31
+
touch /var/lib/pangolin/config/wg0
32
+
systemctl restart gerbil --no-block
38
+
app.dashboard_url = "https://${cfg.dashboardDomain}";
40
+
base_domain = cfg.baseDomain;
41
+
prefer_wildcard_cert = false;
44
+
external_port = 3000;
45
+
internal_port = 3001;
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";
52
+
gerbil.base_endpoint = cfg.dashboardDomain;
53
+
flags.enable_integration_api = false;
57
+
options.services = {
59
+
enable = lib.mkEnableOption "Pangolin reverse proxy server";
60
+
package = lib.mkPackageOption pkgs "fosrl-pangolin" { };
62
+
settings = lib.mkOption {
63
+
inherit (format) type;
66
+
Additional attributes to be merged with the configuration options and written to Pangolin's `config.yml` file.
73
+
external_port = 3007;
74
+
internal_port = 3008;
77
+
prefer_wildcard_cert = true;
82
+
openFirewall = lib.mkEnableOption "opening TCP ports 80 and 443, and UDP port 51820 in the firewall for the Pangolin service(s)";
84
+
baseDomain = lib.mkOption {
85
+
type = with lib.types; nullOr str;
88
+
Your base fully qualified domain name (without any subdomains).
90
+
example = "example.com";
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}";
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`.
100
+
example = "auth.example.com";
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";
108
+
An email address for SSL certificate registration with Let's Encrypt. This should be an email you have access to.
112
+
# this assumes that all domains are hosted by the same provider
113
+
dnsProvider = lib.mkOption {
114
+
type = nullOrOpt lib.types.str;
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.
121
+
# provide path to file to keep secrets out of the nix store
122
+
environmentFile = lib.mkOption {
123
+
type = with lib.types; nullOr path;
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:
130
+
SERVER_SECRET=1234567890abc
133
+
example = "/etc/nixos/secrets/pangolin.env";
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.";
144
+
port = lib.mkOption {
145
+
type = lib.types.port;
148
+
Specifies the port to listen on for Gerbil.
152
+
environmentFile = lib.mkOption {
153
+
type = nullOrOpt lib.types.path;
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.
159
+
example = "/etc/nixos/secrets/gerbil.env";
164
+
config = lib.mkIf cfg.enable {
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.";
174
+
# wildcards implies (dnsProvider and traefikEnvironmentFile)
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.";
182
+
networking.firewall = lib.mkIf cfg.openFirewall {
183
+
allowedTCPPorts = [
187
+
allowedUDPPorts = [ 51820 ];
193
+
description = "Pangolin service user";
194
+
group = "fossorial";
195
+
isSystemUser = true;
196
+
packages = [ cfg.package ];
199
+
description = "Gerbil service user";
200
+
group = "fossorial";
201
+
isSystemUser = true;
204
+
groups.fossorial = {
212
+
# order is as follows
213
+
# "pangolin.service"
215
+
# "traefik.service"
217
+
# make tunnels declarative by calling API
220
+
tmpfiles.settings."10-fossorial-paths" = {
221
+
"${cfg.dataDir}".d = {
223
+
group = "fossorial";
226
+
"${cfg.dataDir}/config".d = {
228
+
group = "fossorial";
231
+
"${cfg.dataDir}/config/letsencrypt".d = {
233
+
group = "fossorial";
239
+
description = "Pangolin reverse proxy tunneling service";
240
+
wantedBy = [ "multi-user.target" ];
241
+
requires = [ "network.target" ];
242
+
after = [ "network.target" ];
245
+
mkdir -p ${cfg.dataDir}/config
246
+
cp -f ${cfgFile} ${cfg.dataDir}/config/config.yml
251
+
Group = "fossorial";
252
+
WorkingDirectory = cfg.dataDir;
253
+
Restart = "always";
254
+
EnvironmentFile = cfg.environmentFile;
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 = [
283
+
CapabilityBoundingSet = [
284
+
"~CAP_BLOCK_SUSPEND"
300
+
SystemCallFilter = [
303
+
"~@cpu-emulation:EPERM"
311
+
"~@privileged:EPERM"
314
+
"~@resources:EPERM"
320
+
ExecStart = lib.getExe cfg.package;
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" ];
333
+
# provide default to use correct port without envfile
335
+
LISTEN = "localhost:" + toString config.services.gerbil.port;
340
+
Group = "fossorial";
341
+
WorkingDirectory = cfg.dataDir;
342
+
Restart = "always";
343
+
EnvironmentFile = cfg.environmentFile;
344
+
ReadWritePaths = "${cfg.dataDir}/config";
346
+
AmbientCapabilities = [
350
+
CapabilityBoundingSet = [
353
+
"~CAP_BLOCK_SUSPEND"
364
+
"~CAP_SYS_TTY_CONFIG"
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 = [
391
+
SystemCallFilter = [
395
+
"~@cpu-emulation:EPERM"
402
+
"~@privileged:EPERM"
405
+
"~@resources:EPERM"
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"
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;
424
+
wantedBy = [ "multi-user.target" ];
425
+
after = [ "gerbil.service" ];
426
+
requires = [ "gerbil.service" ];
427
+
partOf = [ "gerbil.service" ];
432
+
services.traefik = {
434
+
group = "fossorial";
435
+
dataDir = "${cfg.dataDir}/config/traefik";
436
+
staticConfigOptions = {
438
+
endpoint = "http://localhost:${toString finalSettings.server.internal_port}/api/v1/traefik-config";
439
+
pollInterval = "5s";
441
+
# TODO to change this once #437073 is merged.
442
+
experimental.plugins.badger = {
443
+
moduleName = "github.com/fosrl/badger";
444
+
version = "v1.2.0";
446
+
certificatesResolvers.letsencrypt.acme =
448
+
if finalSettings.domains.domain1.prefer_wildcard_cert then
450
+
# see https://doc.traefik.io/traefik/https/acme/#providers
451
+
dnsChallenge.provider = cfg.dnsProvider;
455
+
httpChallenge.entryPoint = "web";
461
+
email = cfg.letsEncryptEmail;
462
+
storage = "${cfg.dataDir}/config/letsencrypt/acme.json";
463
+
caServer = "https://acme-v02.api.letsencrypt.org/directory";
466
+
web.address = ":80";
469
+
transport.respondingTimeouts.readTimeout = "30m";
470
+
http.tls.certResolver = "letsencrypt";
474
+
dynamicConfigOptions = {
476
+
middlewares.redirect-to-https.redirectScheme.scheme = "https";
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" ];
485
+
# Next.js router (handles everything except API and WebSocket paths)
487
+
rule = "Host(`${cfg.dashboardDomain}`) && !PathPrefix(`/api/v1`)";
488
+
service = "next-service";
489
+
entryPoints = [ "websecure" ];
491
+
lib.optionalAttrs (finalSettings.domains.domain1.prefer_wildcard_cert) {
493
+
{ main = cfg.baseDomain; }
494
+
{ sans = "*.${cfg.baseDomain}"; }
500
+
certResolver = "letsencrypt";
503
+
# API router (handles /api/v1 paths)
505
+
rule = "Host(`${cfg.dashboardDomain}`) && PathPrefix(`/api/v1`)";
506
+
service = "api-service";
507
+
entryPoints = [ "websecure" ];
508
+
tls.certResolver = "letsencrypt";
512
+
rule = "Host(`${cfg.dashboardDomain}`)";
513
+
service = "api-service";
514
+
entryPoints = [ "websecure" ];
515
+
tls.certResolver = "letsencrypt";
517
+
# Integration API router
518
+
int-api-router-redirect = lib.mkIf (finalSettings.flags.enable_integration_api) {
519
+
rule = "Host(`api.${cfg.baseDomain}`)";
520
+
service = "int-api-service";
521
+
entryPoints = [ "web" ];
522
+
middlewares = [ "redirect-to-https" ];
524
+
int-api-router = lib.mkIf (finalSettings.flags.enable_integration_api) {
525
+
rule = "Host(`api.${cfg.baseDomain}`)";
526
+
service = "int-api-service";
527
+
entryPoints = [ "websecure" ];
528
+
tls.certResolver = "letsencrypt";
534
+
next-service.loadBalancer.servers = [
535
+
{ url = "http://localhost:${toString finalSettings.server.next_port}"; }
537
+
# API/WebSocket server
538
+
api-service.loadBalancer.servers = [
539
+
{ url = "http://localhost:${toString finalSettings.server.external_port}"; }
541
+
# Integration API server
542
+
int-api-service.loadBalancer.servers = lib.mkIf (finalSettings.flags.enable_integration_api) [
543
+
{ url = "http://localhost:${toString finalSettings.server.integration_port}"; }
551
+
meta.maintainers = with lib.maintainers; [