at master 19 kB view raw
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}