1{ 2 lib, 3 pkgs, 4 config, 5 ... 6}: 7let 8 inherit (lib) 9 attrNames 10 boolToString 11 concatLines 12 concatLists 13 concatMapAttrs 14 concatStringsSep 15 filterAttrs 16 filterAttrsRecursive 17 flip 18 forEach 19 getExe 20 isBool 21 mapAttrs 22 mapAttrsToList 23 mkDefault 24 mkEnableOption 25 mkIf 26 mkMerge 27 mkOption 28 mkPackageOption 29 optionalAttrs 30 optionalString 31 recursiveUpdate 32 subtractLists 33 toUpper 34 types 35 ; 36 37 cfg = config.services.firezone.server; 38 jsonFormat = pkgs.formats.json { }; 39 availableAuthAdapters = [ 40 "email" 41 "openid_connect" 42 "userpass" 43 "token" 44 "google_workspace" 45 "microsoft_entra" 46 "okta" 47 "jumpcloud" 48 ]; 49 50 typePortRange = 51 types.coercedTo types.port 52 (x: { 53 from = x; 54 to = x; 55 }) 56 ( 57 types.submodule { 58 options = { 59 from = mkOption { 60 type = types.port; 61 description = "The start of the port range, inclusive."; 62 }; 63 64 to = mkOption { 65 type = types.port; 66 description = "The end of the port range, inclusive."; 67 }; 68 }; 69 } 70 ); 71 72 # All non-secret environment variables or the given component 73 collectEnvironment = 74 component: 75 mapAttrs (_: v: if isBool v then boolToString v else toString v) ( 76 cfg.settings // cfg.${component}.settings 77 ); 78 79 # All mandatory secrets which were not explicitly provided by the user will 80 # have to be generated, if they do not yet exist. 81 generateSecrets = 82 let 83 requiredSecrets = filterAttrs (_: v: v == null) cfg.settingsSecret; 84 in 85 '' 86 mkdir -p secrets 87 chmod 700 secrets 88 '' 89 + concatLines ( 90 forEach (attrNames requiredSecrets) (secret: '' 91 if [[ ! -e secrets/${secret} ]]; then 92 echo "Generating ${secret}" 93 # Some secrets like TOKENS_KEY_BASE require a value >=64 bytes. 94 head -c 64 /dev/urandom | base64 -w 0 > secrets/${secret} 95 chmod 600 secrets/${secret} 96 fi 97 '') 98 ); 99 100 # All secrets given in `cfg.settingsSecret` must be loaded from a file and 101 # exported into the environment. Also exclude any variables that were 102 # overwritten by the local component settings. 103 loadSecretEnvironment = 104 component: 105 let 106 relevantSecrets = subtractLists (attrNames cfg.${component}.settings) ( 107 attrNames cfg.settingsSecret 108 ); 109 in 110 concatLines ( 111 forEach relevantSecrets ( 112 secret: 113 ''export ${secret}=$(< ${ 114 if cfg.settingsSecret.${secret} == null then 115 "secrets/${secret}" 116 else 117 "\"$CREDENTIALS_DIRECTORY/${secret}\"" 118 })'' 119 ) 120 ); 121 122 provisionStateJson = 123 let 124 # Convert clientSecretFile options into the real counterpart 125 augmentedAccounts = flip mapAttrs cfg.provision.accounts ( 126 accountName: account: 127 account 128 // { 129 auth = flip mapAttrs account.auth ( 130 authName: auth: 131 recursiveUpdate auth ( 132 optionalAttrs (auth.adapter_config.clientSecretFile != null) { 133 adapter_config.client_secret = "{env:AUTH_CLIENT_SECRET_${toUpper accountName}_${toUpper authName}}"; 134 } 135 ) 136 ); 137 } 138 ); 139 in 140 jsonFormat.generate "provision-state.json" { 141 # Do not include any clientSecretFile attributes in the resulting json 142 accounts = filterAttrsRecursive (k: _: k != "clientSecretFile") augmentedAccounts; 143 }; 144 145 commonServiceConfig = { 146 AmbientCapablities = [ ]; 147 CapabilityBoundingSet = [ ]; 148 LockPersonality = true; 149 MemoryDenyWriteExecute = true; 150 NoNewPrivileges = true; 151 PrivateMounts = true; 152 PrivateTmp = true; 153 PrivateUsers = false; 154 ProcSubset = "pid"; 155 ProtectClock = true; 156 ProtectControlGroups = true; 157 ProtectHome = true; 158 ProtectHostname = true; 159 ProtectKernelLogs = true; 160 ProtectKernelModules = true; 161 ProtectKernelTunables = true; 162 ProtectProc = "invisible"; 163 ProtectSystem = "strict"; 164 RestrictAddressFamilies = [ 165 "AF_INET" 166 "AF_INET6" 167 "AF_NETLINK" 168 "AF_UNIX" 169 ]; 170 RestrictNamespaces = true; 171 RestrictRealtime = true; 172 RestrictSUIDSGID = true; 173 SystemCallArchitectures = "native"; 174 SystemCallFilter = "@system-service"; 175 UMask = "077"; 176 177 DynamicUser = true; 178 User = "firezone"; 179 180 Slice = "system-firezone.slice"; 181 StateDirectory = "firezone"; 182 WorkingDirectory = "/var/lib/firezone"; 183 184 LoadCredential = mapAttrsToList (secretName: secretFile: "${secretName}:${secretFile}") ( 185 filterAttrs (_: v: v != null) cfg.settingsSecret 186 ); 187 Type = "exec"; 188 Restart = "on-failure"; 189 RestartSec = 10; 190 }; 191 192 componentOptions = component: { 193 enable = mkEnableOption "the Firezone ${component} server"; 194 package = mkPackageOption pkgs "firezone-server-${component}" { }; 195 196 settings = mkOption { 197 description = '' 198 Environment variables for this component of the Firezone server. For a 199 list of available variables, please refer to the [upstream definitions](https://github.com/firezone/firezone/blob/main/elixir/apps/domain/lib/domain/config/definitions.ex). 200 Some variables like `OUTBOUND_EMAIL_ADAPTER_OPTS` require json values 201 for which you can use `VAR = builtins.toJSON { /* ... */ }`. 202 203 This component will automatically inherit all variables defined via 204 {option}`services.firezone.server.settings` and 205 {option}`services.firezone.server.settingsSecret`, but which can be 206 overwritten by this option. 207 ''; 208 default = { }; 209 type = types.submodule { 210 freeformType = types.attrsOf ( 211 types.oneOf [ 212 types.bool 213 types.float 214 types.int 215 types.str 216 types.path 217 types.package 218 ] 219 ); 220 }; 221 }; 222 }; 223in 224{ 225 options.services.firezone.server = { 226 enable = mkEnableOption "all Firezone components"; 227 enableLocalDB = mkEnableOption "a local postgresql database for Firezone"; 228 nginx.enable = mkEnableOption "nginx virtualhost definition"; 229 230 openClusterFirewall = mkOption { 231 type = types.bool; 232 default = false; 233 description = '' 234 Opens up the erlang distribution port of all enabled components to 235 allow reaching the server cluster from the internet. You only need to 236 set this if you are actually distributing your cluster across multiple 237 machines. 238 ''; 239 }; 240 241 clusterHosts = mkOption { 242 type = types.listOf types.str; 243 default = [ 244 "api@localhost.localdomain" 245 "web@localhost.localdomain" 246 "domain@localhost.localdomain" 247 ]; 248 description = '' 249 A list of components and their hosts that are part of this cluster. For 250 a single-machine setup, the default value will be sufficient. This 251 value will automatically set `ERLANG_CLUSTER_ADAPTER_CONFIG`. 252 253 The format is `<COMPONENT_NAME>@<HOSTNAME>`. 254 ''; 255 }; 256 257 settingsSecret = mkOption { 258 default = { }; 259 description = '' 260 This is a convenience option which allows you to set secret values for 261 environment variables by specifying a file which will contain the value 262 at runtime. Before starting the server, the content of each file will 263 be loaded into the respective environment variable. 264 265 Otherwise, this option is equivalent to 266 {option}`services.firezone.server.settings`. Refer to the settings 267 option for more information regarding the actual variables and how 268 filtering rules are applied for each component. 269 ''; 270 type = types.submodule { 271 freeformType = types.attrsOf types.path; 272 options = { 273 RELEASE_COOKIE = mkOption { 274 type = types.nullOr types.path; 275 default = null; 276 description = '' 277 A file containing a unique secret identifier for the Erlang 278 cluster. All Firezone components in your cluster must use the 279 same value. 280 281 If this is `null`, a shared value will automatically be generated 282 on startup and used for all components on this machine. You do 283 not need to set this except when you spread your cluster over 284 multiple hosts. 285 ''; 286 }; 287 288 TOKENS_KEY_BASE = mkOption { 289 type = types.nullOr types.path; 290 default = null; 291 description = '' 292 A file containing a unique base64 encoded secret for the 293 `TOKENS_KEY_BASE`. All Firezone components in your cluster must 294 use the same value. 295 296 If this is `null`, a shared value will automatically be generated 297 on startup and used for all components on this machine. You do 298 not need to set this except when you spread your cluster over 299 multiple hosts. 300 ''; 301 }; 302 303 SECRET_KEY_BASE = mkOption { 304 type = types.nullOr types.path; 305 default = null; 306 description = '' 307 A file containing a unique base64 encoded secret for the 308 `SECRET_KEY_BASE`. All Firezone components in your cluster must 309 use the same value. 310 311 If this is `null`, a shared value will automatically be generated 312 on startup and used for all components on this machine. You do 313 not need to set this except when you spread your cluster over 314 multiple hosts. 315 ''; 316 }; 317 318 TOKENS_SALT = mkOption { 319 type = types.nullOr types.path; 320 default = null; 321 description = '' 322 A file containing a unique base64 encoded secret for the 323 `TOKENS_SALT`. All Firezone components in your cluster must 324 use the same value. 325 326 If this is `null`, a shared value will automatically be generated 327 on startup and used for all components on this machine. You do 328 not need to set this except when you spread your cluster over 329 multiple hosts. 330 ''; 331 }; 332 333 LIVE_VIEW_SIGNING_SALT = mkOption { 334 type = types.nullOr types.path; 335 default = null; 336 description = '' 337 A file containing a unique base64 encoded secret for the 338 `LIVE_VIEW_SIGNING_SALT`. All Firezone components in your cluster must 339 use the same value. 340 341 If this is `null`, a shared value will automatically be generated 342 on startup and used for all components on this machine. You do 343 not need to set this except when you spread your cluster over 344 multiple hosts. 345 ''; 346 }; 347 348 COOKIE_SIGNING_SALT = mkOption { 349 type = types.nullOr types.path; 350 default = null; 351 description = '' 352 A file containing a unique base64 encoded secret for the 353 `COOKIE_SIGNING_SALT`. All Firezone components in your cluster must 354 use the same value. 355 356 If this is `null`, a shared value will automatically be generated 357 on startup and used for all components on this machine. You do 358 not need to set this except when you spread your cluster over 359 multiple hosts. 360 ''; 361 }; 362 363 COOKIE_ENCRYPTION_SALT = mkOption { 364 type = types.nullOr types.path; 365 default = null; 366 description = '' 367 A file containing a unique base64 encoded secret for the 368 `COOKIE_ENCRYPTION_SALT`. All Firezone components in your cluster must 369 use the same value. 370 371 If this is `null`, a shared value will automatically be generated 372 on startup and used for all components on this machine. You do 373 not need to set this except when you spread your cluster over 374 multiple hosts. 375 ''; 376 }; 377 }; 378 }; 379 }; 380 381 settings = mkOption { 382 description = '' 383 Environment variables for the Firezone server. For a list of available 384 variables, please refer to the [upstream definitions](https://github.com/firezone/firezone/blob/main/elixir/apps/domain/lib/domain/config/definitions.ex). 385 Some variables like `OUTBOUND_EMAIL_ADAPTER_OPTS` require json values 386 for which you can use `VAR = builtins.toJSON { /* ... */ }`. 387 388 Each component has an additional `settings` option which allows you to 389 override specific variables passed to that component. 390 ''; 391 default = { }; 392 type = types.submodule { 393 freeformType = types.attrsOf ( 394 types.oneOf [ 395 types.bool 396 types.float 397 types.int 398 types.str 399 types.path 400 types.package 401 ] 402 ); 403 }; 404 }; 405 406 smtp = { 407 configureManually = mkOption { 408 type = types.bool; 409 default = false; 410 description = '' 411 Outbound email configuration is mandatory for Firezone and supports 412 many different delivery adapters. Yet, most users will only need an 413 SMTP relay to send emails, so this configuration enforced by default. 414 415 If you want to utilize an alternative way to send emails (e.g. via a 416 supportd API-based service), enable this option and define 417 `OUTBOUND_EMAIL_FROM`, `OUTBOUND_EMAIL_ADAPTER` and 418 `OUTBOUND_EMAIL_ADAPTER_OPTS` manually via 419 {option}`services.firezone.server.settings` and/or 420 {option}`services.firezone.server.settingsSecret`. 421 422 The Firezone documentation holds [a list of supported Swoosh adapters](https://github.com/firezone/firezone/blob/main/website/src/app/docs/reference/env-vars/readme.mdx#outbound-emails). 423 ''; 424 }; 425 426 from = mkOption { 427 type = types.str; 428 example = "firezone@example.com"; 429 description = "Outbound SMTP FROM address"; 430 }; 431 432 host = mkOption { 433 type = types.str; 434 example = "mail.example.com"; 435 description = "Outbound SMTP host"; 436 }; 437 438 port = mkOption { 439 type = types.port; 440 example = 465; 441 description = "Outbound SMTP port"; 442 }; 443 444 implicitTls = mkOption { 445 type = types.bool; 446 default = false; 447 description = "Whether to use implicit TLS instead of STARTTLS (usually port 465)"; 448 }; 449 450 username = mkOption { 451 type = types.str; 452 example = "firezone@example.com"; 453 description = "Username to authenticate against the SMTP relay"; 454 }; 455 456 passwordFile = mkOption { 457 type = types.path; 458 example = "/run/secrets/smtp-password"; 459 description = "File containing the password for the given username. Beware that a file in the nix store will be world readable."; 460 }; 461 }; 462 463 domain = componentOptions "domain"; 464 465 web = componentOptions "web" // { 466 externalUrl = mkOption { 467 type = types.strMatching "^https://.+/$"; 468 example = "https://firezone.example.com/"; 469 description = '' 470 The external URL under which you will serve the web interface. You 471 need to setup a reverse proxy for TLS termination, either with 472 {option}`services.firezone.server.nginx.enable` or manually. 473 ''; 474 }; 475 476 address = mkOption { 477 type = types.str; 478 default = "127.0.0.1"; 479 description = "The address to listen on"; 480 }; 481 482 port = mkOption { 483 type = types.port; 484 default = 8080; 485 description = "The port under which the web interface will be served locally"; 486 }; 487 488 trustedProxies = mkOption { 489 type = types.listOf types.str; 490 default = [ ]; 491 description = "A list of trusted proxies"; 492 }; 493 }; 494 495 api = componentOptions "api" // { 496 externalUrl = mkOption { 497 type = types.strMatching "^https://.+/$"; 498 example = "https://firezone.example.com/api/"; 499 description = '' 500 The external URL under which you will serve the api. You need to 501 setup a reverse proxy for TLS termination, either with 502 {option}`services.firezone.server.nginx.enable` or manually. 503 ''; 504 }; 505 506 address = mkOption { 507 type = types.str; 508 default = "127.0.0.1"; 509 description = "The address to listen on"; 510 }; 511 512 port = mkOption { 513 type = types.port; 514 default = 8081; 515 description = "The port under which the api will be served locally"; 516 }; 517 518 trustedProxies = mkOption { 519 type = types.listOf types.str; 520 default = [ ]; 521 description = "A list of trusted proxies"; 522 }; 523 }; 524 525 provision = { 526 enable = mkEnableOption "provisioning of the Firezone domain server"; 527 accounts = mkOption { 528 type = types.attrsOf ( 529 types.submodule { 530 freeformType = jsonFormat.type; 531 options = { 532 name = mkOption { 533 type = types.str; 534 description = "The account name"; 535 example = "My Organization"; 536 }; 537 538 features = 539 let 540 mkFeatureOption = 541 name: default: 542 mkOption { 543 type = types.bool; 544 inherit default; 545 description = "Whether to enable the `${name}` feature for this account."; 546 }; 547 in 548 { 549 flow_activities = mkFeatureOption "flow_activities" true; 550 policy_conditions = mkFeatureOption "policy_conditions" true; 551 multi_site_resources = mkFeatureOption "multi_site_resources" true; 552 traffic_filters = mkFeatureOption "traffic_filters" true; 553 self_hosted_relays = mkFeatureOption "self_hosted_relays" true; 554 idp_sync = mkFeatureOption "idp_sync" true; 555 rest_api = mkFeatureOption "rest_api" true; 556 internet_resource = mkFeatureOption "internet_resource" true; 557 }; 558 559 actors = mkOption { 560 type = types.attrsOf ( 561 types.submodule { 562 options = { 563 type = mkOption { 564 type = types.enum [ 565 "account_admin_user" 566 "account_user" 567 "service_account" 568 "api_client" 569 ]; 570 description = "The account type"; 571 }; 572 573 name = mkOption { 574 type = types.str; 575 description = "The name of this actor"; 576 }; 577 578 email = mkOption { 579 type = types.str; 580 description = "The email address used to authenticate as this account"; 581 }; 582 }; 583 } 584 ); 585 default = { }; 586 example = { 587 admin = { 588 type = "account_admin_user"; 589 name = "Admin"; 590 email = "admin@myorg.example.com"; 591 }; 592 }; 593 description = '' 594 All actors (users) to provision. The attribute name will only 595 be used to track the actor and does not have any significance 596 for Firezone. 597 ''; 598 }; 599 600 auth = mkOption { 601 type = types.attrsOf ( 602 types.submodule { 603 freeformType = jsonFormat.type; 604 options = { 605 name = mkOption { 606 type = types.str; 607 description = "The name of this authentication provider"; 608 }; 609 610 adapter = mkOption { 611 type = types.enum availableAuthAdapters; 612 description = "The auth adapter type"; 613 }; 614 615 adapter_config.clientSecretFile = mkOption { 616 type = types.nullOr types.path; 617 default = null; 618 description = '' 619 A file containing a the client secret for an openid_connect adapter. 620 You only need to set this if this is an openid_connect provider. 621 ''; 622 }; 623 }; 624 } 625 ); 626 default = { }; 627 example = { 628 myoidcprovider = { 629 adapter = "openid_connect"; 630 adapter_config = { 631 client_id = "clientid"; 632 clientSecretFile = "/run/secrets/oidc-client-secret"; 633 response_type = "code"; 634 scope = "openid email name"; 635 discovery_document_uri = "https://auth.example.com/.well-known/openid-configuration"; 636 }; 637 }; 638 }; 639 description = '' 640 All authentication providers to provision. The attribute name 641 will only be used to track the provider and does not have any 642 significance for Firezone. 643 ''; 644 }; 645 646 resources = mkOption { 647 type = types.attrsOf ( 648 types.submodule { 649 options = { 650 type = mkOption { 651 type = types.enum [ 652 "dns" 653 "cidr" 654 "ip" 655 ]; 656 description = "The resource type"; 657 }; 658 659 name = mkOption { 660 type = types.str; 661 description = "The name of this resource"; 662 }; 663 664 address = mkOption { 665 type = types.str; 666 description = "The address of this resource. Depending on the resource type, this should be an ip, ip with cidr mask or a domain."; 667 }; 668 669 addressDescription = mkOption { 670 type = types.nullOr types.str; 671 default = null; 672 description = "An optional description for resource address, usually a full link to the resource including a schema."; 673 }; 674 675 gatewayGroups = mkOption { 676 type = types.nonEmptyListOf types.str; 677 description = "A list of gateway groups (sites) which can reach the resource and may be used to connect to it."; 678 }; 679 680 filters = mkOption { 681 type = types.listOf ( 682 types.submodule { 683 options = { 684 protocol = mkOption { 685 type = types.enum [ 686 "icmp" 687 "tcp" 688 "udp" 689 ]; 690 description = "The protocol to allow"; 691 }; 692 693 ports = mkOption { 694 type = types.listOf typePortRange; 695 example = [ 696 443 697 { 698 from = 8080; 699 to = 8100; 700 } 701 ]; 702 default = [ ]; 703 apply = 704 xs: map (x: if x.from == x.to then toString x.from else "${toString x.from} - ${toString x.to}") xs; 705 description = "Either a single port or port range to allow. Both bounds are inclusive."; 706 }; 707 }; 708 } 709 ); 710 default = [ ]; 711 description = "A list of filter to restrict traffic. If no filters are given, all traffic is allowed."; 712 }; 713 }; 714 } 715 ); 716 default = { }; 717 example = { 718 vaultwarden = { 719 type = "dns"; 720 name = "Vaultwarden"; 721 address = "vault.example.com"; 722 address_description = "https://vault.example.com"; 723 gatewayGroups = [ "my-site" ]; 724 filters = [ 725 { protocol = "icmp"; } 726 { 727 protocol = "tcp"; 728 ports = [ 729 80 730 443 731 ]; 732 } 733 ]; 734 }; 735 }; 736 description = '' 737 All resources to provision. The attribute name will only be used to 738 track the resource and does not have any significance for Firezone. 739 ''; 740 }; 741 742 policies = mkOption { 743 type = types.attrsOf ( 744 types.submodule { 745 options = { 746 description = mkOption { 747 type = types.nullOr types.str; 748 description = "The description of this policy"; 749 }; 750 751 group = mkOption { 752 type = types.str; 753 description = "The group which should be allowed access to the given resource."; 754 }; 755 756 resource = mkOption { 757 type = types.str; 758 description = "The resource to which access should be allowed."; 759 }; 760 }; 761 } 762 ); 763 default = { }; 764 example = { 765 access_vaultwarden = { 766 name = "Allow anyone to access vaultwarden"; 767 group = "everyone"; 768 resource = "vaultwarden"; 769 }; 770 }; 771 description = '' 772 All policies to provision. The attribute name will only be used to 773 track the policy and does not have any significance for Firezone. 774 ''; 775 }; 776 777 groups = mkOption { 778 type = types.attrsOf ( 779 types.submodule { 780 options = { 781 name = mkOption { 782 type = types.str; 783 description = "The name of this group"; 784 }; 785 786 members = mkOption { 787 type = types.listOf types.str; 788 default = [ ]; 789 description = "The members of this group"; 790 }; 791 792 forceMembers = mkOption { 793 type = types.bool; 794 default = false; 795 description = "Ensure that only the given members are part of this group at every server start."; 796 }; 797 }; 798 } 799 ); 800 default = { }; 801 example = { 802 users = { 803 name = "Users"; 804 }; 805 }; 806 description = '' 807 All groups to provision. The attribute name will only be used 808 to track the group and does not have any significance for 809 Firezone. 810 811 A group named `everyone` will automatically be managed by Firezone. 812 ''; 813 }; 814 815 relayGroups = mkOption { 816 type = types.attrsOf ( 817 types.submodule { 818 options = { 819 name = mkOption { 820 type = types.str; 821 description = "The name of this relay group"; 822 }; 823 }; 824 } 825 ); 826 default = { }; 827 example = { 828 my-relays = { 829 name = "My Relays"; 830 }; 831 }; 832 description = '' 833 All relay groups to provision. The attribute name 834 will only be used to track the relay group and does not have any 835 significance for Firezone. 836 ''; 837 }; 838 839 gatewayGroups = mkOption { 840 type = types.attrsOf ( 841 types.submodule { 842 options = { 843 name = mkOption { 844 type = types.str; 845 description = "The name of this gateway group"; 846 }; 847 }; 848 } 849 ); 850 default = { }; 851 example = { 852 my-gateways = { 853 name = "My Gateways"; 854 }; 855 }; 856 description = '' 857 All gateway groups (sites) to provision. The attribute name 858 will only be used to track the gateway group and does not have any 859 significance for Firezone. 860 ''; 861 }; 862 }; 863 } 864 ); 865 default = { }; 866 example = { 867 main = { 868 name = "My Account / Organization"; 869 metadata.stripe.billing_email = "org@myorg.example.com"; 870 features.rest_api = false; 871 }; 872 }; 873 description = '' 874 All accounts to provision. The attribute name specified here will 875 become the account slug. By using `"{file:/path/to/file}"` as a 876 string value anywhere in these settings, the provisioning script will 877 replace that value with the content of the given file at runtime. 878 879 Please refer to the [Firezone source code](https://github.com/firezone/firezone/blob/main/elixir/apps/domain/lib/domain/accounts/account.ex) 880 for all available properties. 881 ''; 882 }; 883 }; 884 }; 885 886 config = mkMerge [ 887 { 888 assertions = 889 [ 890 { 891 assertion = cfg.provision.enable -> cfg.domain.enable; 892 message = "Provisioning must be done on a machine running the firezone domain server"; 893 } 894 ] 895 ++ concatLists ( 896 flip mapAttrsToList cfg.provision.accounts ( 897 accountName: accountCfg: 898 [ 899 { 900 assertion = (builtins.match "^[[:lower:]_-]+$" accountName) != null; 901 message = "An account name must contain only lowercase characters and underscores, as it will be used as the URL slug for this account."; 902 } 903 ] 904 ++ flip mapAttrsToList accountCfg.auth ( 905 authName: _: { 906 assertion = (builtins.match "^[[:alnum:]_-]+$" authName) != null; 907 message = "The authentication provider attribute key must contain only letters, numbers, underscores or dashes."; 908 } 909 ) 910 ) 911 ); 912 } 913 # Enable all components if the main server is enabled 914 (mkIf cfg.enable { 915 services.firezone.server.domain.enable = true; 916 services.firezone.server.web.enable = true; 917 services.firezone.server.api.enable = true; 918 }) 919 # Create (and configure) a local database if desired 920 (mkIf cfg.enableLocalDB { 921 services.postgresql = { 922 enable = true; 923 ensureUsers = [ 924 { 925 name = "firezone"; 926 ensureDBOwnership = true; 927 } 928 ]; 929 ensureDatabases = [ "firezone" ]; 930 }; 931 932 services.firezone.server.settings = { 933 DATABASE_SOCKET_DIR = "/run/postgresql"; 934 DATABASE_PORT = "5432"; 935 DATABASE_NAME = "firezone"; 936 DATABASE_USER = "firezone"; 937 DATABASE_PASSWORD = "firezone"; 938 }; 939 }) 940 # Create a local nginx reverse proxy 941 (mkIf cfg.nginx.enable { 942 services.nginx = mkMerge [ 943 { 944 enable = true; 945 } 946 ( 947 let 948 urlComponents = builtins.elemAt (builtins.split "https://([^/]*)(/?.*)" cfg.web.externalUrl) 1; 949 domain = builtins.elemAt urlComponents 0; 950 location = builtins.elemAt urlComponents 1; 951 in 952 { 953 virtualHosts.${domain} = { 954 forceSSL = mkDefault true; 955 locations.${location} = { 956 # The trailing slash is important to strip the location prefix from the request 957 proxyPass = "http://${cfg.web.address}:${toString cfg.web.port}/"; 958 proxyWebsockets = true; 959 }; 960 }; 961 } 962 ) 963 ( 964 let 965 urlComponents = builtins.elemAt (builtins.split "https://([^/]*)(/?.*)" cfg.api.externalUrl) 1; 966 domain = builtins.elemAt urlComponents 0; 967 location = builtins.elemAt urlComponents 1; 968 in 969 { 970 virtualHosts.${domain} = { 971 forceSSL = mkDefault true; 972 locations.${location} = { 973 # The trailing slash is important to strip the location prefix from the request 974 proxyPass = "http://${cfg.api.address}:${toString cfg.api.port}/"; 975 proxyWebsockets = true; 976 }; 977 }; 978 } 979 ) 980 ]; 981 }) 982 # Specify sensible defaults 983 { 984 services.firezone.server = { 985 settings = { 986 LOG_LEVEL = mkDefault "info"; 987 RELEASE_HOSTNAME = mkDefault "localhost.localdomain"; 988 989 ERLANG_CLUSTER_ADAPTER = mkDefault "Elixir.Cluster.Strategy.Epmd"; 990 ERLANG_CLUSTER_ADAPTER_CONFIG = mkDefault ( 991 builtins.toJSON { 992 hosts = cfg.clusterHosts; 993 } 994 ); 995 996 TZDATA_DIR = mkDefault "/var/lib/firezone/tzdata"; 997 TELEMETRY_ENABLED = mkDefault false; 998 999 # By default this will open nproc * 2 connections for each component, 1000 # which can exceeds the (default) maximum of 100 connections for 1001 # postgresql on a 12 core +SMT machine. 16 connections will be 1002 # sufficient for small to medium deployments 1003 DATABASE_POOL_SIZE = "16"; 1004 1005 AUTH_PROVIDER_ADAPTERS = mkDefault (concatStringsSep "," availableAuthAdapters); 1006 1007 FEATURE_FLOW_ACTIVITIES_ENABLED = mkDefault true; 1008 FEATURE_POLICY_CONDITIONS_ENABLED = mkDefault true; 1009 FEATURE_MULTI_SITE_RESOURCES_ENABLED = mkDefault true; 1010 FEATURE_SELF_HOSTED_RELAYS_ENABLED = mkDefault true; 1011 FEATURE_IDP_SYNC_ENABLED = mkDefault true; 1012 FEATURE_REST_API_ENABLED = mkDefault true; 1013 FEATURE_INTERNET_RESOURCE_ENABLED = mkDefault true; 1014 FEATURE_TRAFFIC_FILTERS_ENABLED = mkDefault true; 1015 1016 FEATURE_SIGN_UP_ENABLED = mkDefault (!cfg.provision.enable); 1017 1018 WEB_EXTERNAL_URL = mkDefault cfg.web.externalUrl; 1019 API_EXTERNAL_URL = mkDefault cfg.api.externalUrl; 1020 }; 1021 1022 domain.settings = { 1023 ERLANG_DISTRIBUTION_PORT = mkDefault 9000; 1024 HEALTHZ_PORT = mkDefault 4000; 1025 BACKGROUND_JOBS_ENABLED = mkDefault true; 1026 }; 1027 1028 web.settings = { 1029 ERLANG_DISTRIBUTION_PORT = mkDefault 9001; 1030 HEALTHZ_PORT = mkDefault 4001; 1031 BACKGROUND_JOBS_ENABLED = mkDefault false; 1032 1033 PHOENIX_LISTEN_ADDRESS = mkDefault cfg.web.address; 1034 PHOENIX_EXTERNAL_TRUSTED_PROXIES = mkDefault (builtins.toJSON cfg.web.trustedProxies); 1035 PHOENIX_HTTP_WEB_PORT = mkDefault cfg.web.port; 1036 PHOENIX_HTTP_API_PORT = mkDefault cfg.api.port; 1037 PHOENIX_SECURE_COOKIES = mkDefault true; # enforce HTTPS on cookies 1038 }; 1039 1040 api.settings = { 1041 ERLANG_DISTRIBUTION_PORT = mkDefault 9002; 1042 HEALTHZ_PORT = mkDefault 4002; 1043 BACKGROUND_JOBS_ENABLED = mkDefault false; 1044 1045 PHOENIX_LISTEN_ADDRESS = mkDefault cfg.api.address; 1046 PHOENIX_EXTERNAL_TRUSTED_PROXIES = mkDefault (builtins.toJSON cfg.api.trustedProxies); 1047 PHOENIX_HTTP_WEB_PORT = mkDefault cfg.web.port; 1048 PHOENIX_HTTP_API_PORT = mkDefault cfg.api.port; 1049 PHOENIX_SECURE_COOKIES = mkDefault true; # enforce HTTPS on cookies 1050 }; 1051 }; 1052 } 1053 (mkIf (!cfg.smtp.configureManually) { 1054 services.firezone.server.settings = { 1055 OUTBOUND_EMAIL_ADAPTER = "Elixir.Swoosh.Adapters.Mua"; 1056 OUTBOUND_EMAIL_ADAPTER_OPTS = builtins.toJSON { }; 1057 OUTBOUND_EMAIL_FROM = cfg.smtp.from; 1058 OUTBOUND_EMAIL_SMTP_HOST = cfg.smtp.host; 1059 OUTBOUND_EMAIL_SMTP_PORT = toString cfg.smtp.port; 1060 OUTBOUND_EMAIL_SMTP_PROTOCOL = if cfg.smtp.implicitTls then "ssl" else "tcp"; 1061 OUTBOUND_EMAIL_SMTP_USERNAME = cfg.smtp.username; 1062 }; 1063 services.firezone.server.settingsSecret = { 1064 OUTBOUND_EMAIL_SMTP_PASSWORD = cfg.smtp.passwordFile; 1065 }; 1066 }) 1067 (mkIf cfg.provision.enable { 1068 # Load client secrets from authentication providers 1069 services.firezone.server.settingsSecret = flip concatMapAttrs cfg.provision.accounts ( 1070 accountName: accountCfg: 1071 flip concatMapAttrs accountCfg.auth ( 1072 authName: authCfg: 1073 optionalAttrs (authCfg.adapter_config.clientSecretFile != null) { 1074 "AUTH_CLIENT_SECRET_${toUpper accountName}_${toUpper authName}" = 1075 authCfg.adapter_config.clientSecretFile; 1076 } 1077 ) 1078 ); 1079 }) 1080 (mkIf (cfg.openClusterFirewall && cfg.domain.enable) { 1081 networking.firewall.allowedTCPPorts = [ 1082 cfg.domain.settings.ERLANG_DISTRIBUTION_PORT 1083 ]; 1084 }) 1085 (mkIf (cfg.openClusterFirewall && cfg.web.enable) { 1086 networking.firewall.allowedTCPPorts = [ 1087 cfg.web.settings.ERLANG_DISTRIBUTION_PORT 1088 ]; 1089 }) 1090 (mkIf (cfg.openClusterFirewall && cfg.api.enable) { 1091 networking.firewall.allowedTCPPorts = [ 1092 cfg.api.settings.ERLANG_DISTRIBUTION_PORT 1093 ]; 1094 }) 1095 (mkIf (cfg.domain.enable || cfg.web.enable || cfg.api.enable) { 1096 systemd.slices.system-firezone = { 1097 description = "Firezone Slice"; 1098 }; 1099 1100 systemd.targets.firezone = { 1101 description = "Common target for all Firezone services."; 1102 wantedBy = [ "multi-user.target" ]; 1103 }; 1104 1105 systemd.services.firezone-initialize = { 1106 description = "Backend initialization service for the Firezone zero-trust access platform"; 1107 1108 after = mkIf cfg.enableLocalDB [ "postgresql.service" ]; 1109 requires = mkIf cfg.enableLocalDB [ "postgresql.service" ]; 1110 wantedBy = [ "firezone.target" ]; 1111 partOf = [ "firezone.target" ]; 1112 1113 script = '' 1114 mkdir -p "$TZDATA_DIR" 1115 1116 # Generate and load secrets 1117 ${generateSecrets} 1118 ${loadSecretEnvironment "domain"} 1119 1120 echo "Running migrations" 1121 ${getExe cfg.domain.package} eval Domain.Release.migrate 1122 ''; 1123 1124 # We use the domain environment to be able to run migrations 1125 environment = collectEnvironment "domain"; 1126 serviceConfig = commonServiceConfig // { 1127 Type = "oneshot"; 1128 RemainAfterExit = true; 1129 }; 1130 }; 1131 1132 systemd.services.firezone-server-domain = mkIf cfg.domain.enable { 1133 description = "Backend domain server for the Firezone zero-trust access platform"; 1134 after = [ "firezone-initialize.service" ]; 1135 bindsTo = [ "firezone-initialize.service" ]; 1136 wantedBy = [ "firezone.target" ]; 1137 partOf = [ "firezone.target" ]; 1138 1139 script = '' 1140 ${loadSecretEnvironment "domain"} 1141 exec ${getExe cfg.domain.package} start; 1142 ''; 1143 1144 path = [ pkgs.curl ]; 1145 postStart = 1146 '' 1147 # Wait for the firezone server to come online 1148 count=0 1149 while [[ "$(curl -s "http://localhost:${toString cfg.domain.settings.HEALTHZ_PORT}" 2>/dev/null || echo)" != '{"status":"ok"}' ]] 1150 do 1151 sleep 1 1152 if [[ "$count" -eq 30 ]]; then 1153 echo "Tried for at least 30 seconds, giving up..." 1154 exit 1 1155 fi 1156 count=$((count++)) 1157 done 1158 '' 1159 + optionalString cfg.provision.enable '' 1160 # Wait for server to fully come up. Not ideal to use sleep, but at least it works. 1161 sleep 1 1162 1163 ${loadSecretEnvironment "domain"} 1164 ln -sTf ${provisionStateJson} provision-state.json 1165 ${getExe cfg.domain.package} rpc 'Code.eval_file("${./provision.exs}")' 1166 ''; 1167 1168 environment = collectEnvironment "domain"; 1169 serviceConfig = commonServiceConfig; 1170 }; 1171 1172 systemd.services.firezone-server-web = mkIf cfg.web.enable { 1173 description = "Backend web server for the Firezone zero-trust access platform"; 1174 after = [ "firezone-initialize.service" ]; 1175 bindsTo = [ "firezone-initialize.service" ]; 1176 wantedBy = [ "firezone.target" ]; 1177 partOf = [ "firezone.target" ]; 1178 1179 script = '' 1180 ${loadSecretEnvironment "web"} 1181 exec ${getExe cfg.web.package} start; 1182 ''; 1183 1184 environment = collectEnvironment "web"; 1185 serviceConfig = commonServiceConfig; 1186 }; 1187 1188 systemd.services.firezone-server-api = mkIf cfg.api.enable { 1189 description = "Backend api server for the Firezone zero-trust access platform"; 1190 after = [ "firezone-initialize.service" ]; 1191 bindsTo = [ "firezone-initialize.service" ]; 1192 wantedBy = [ "firezone.target" ]; 1193 partOf = [ "firezone.target" ]; 1194 1195 script = '' 1196 ${loadSecretEnvironment "api"} 1197 exec ${getExe cfg.api.package} start; 1198 ''; 1199 1200 environment = collectEnvironment "api"; 1201 serviceConfig = commonServiceConfig; 1202 }; 1203 }) 1204 ]; 1205 1206 meta.maintainers = with lib.maintainers; [ 1207 oddlama 1208 patrickdag 1209 ]; 1210}