at master 43 kB view raw
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 AmbientCapabilities = [ ]; 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 assertion = cfg.provision.enable -> cfg.domain.enable; 891 message = "Provisioning must be done on a machine running the firezone domain server"; 892 } 893 ] 894 ++ concatLists ( 895 flip mapAttrsToList cfg.provision.accounts ( 896 accountName: accountCfg: 897 [ 898 { 899 assertion = (builtins.match "^[[:lower:]_-]+$" accountName) != null; 900 message = "An account name must contain only lowercase characters and underscores, as it will be used as the URL slug for this account."; 901 } 902 ] 903 ++ flip mapAttrsToList accountCfg.auth ( 904 authName: _: { 905 assertion = (builtins.match "^[[:alnum:]_-]+$" authName) != null; 906 message = "The authentication provider attribute key must contain only letters, numbers, underscores or dashes."; 907 } 908 ) 909 ) 910 ); 911 } 912 # Enable all components if the main server is enabled 913 (mkIf cfg.enable { 914 services.firezone.server.domain.enable = true; 915 services.firezone.server.web.enable = true; 916 services.firezone.server.api.enable = true; 917 }) 918 # Create (and configure) a local database if desired 919 (mkIf cfg.enableLocalDB { 920 services.postgresql = { 921 enable = true; 922 ensureUsers = [ 923 { 924 name = "firezone"; 925 ensureDBOwnership = true; 926 } 927 ]; 928 ensureDatabases = [ "firezone" ]; 929 }; 930 931 services.firezone.server.settings = { 932 DATABASE_SOCKET_DIR = "/run/postgresql"; 933 DATABASE_PORT = "5432"; 934 DATABASE_NAME = "firezone"; 935 DATABASE_USER = "firezone"; 936 DATABASE_PASSWORD = "firezone"; 937 }; 938 }) 939 # Create a local nginx reverse proxy 940 (mkIf cfg.nginx.enable { 941 services.nginx = mkMerge [ 942 { 943 enable = true; 944 } 945 ( 946 let 947 urlComponents = builtins.elemAt (builtins.split "https://([^/]*)(/?.*)" cfg.web.externalUrl) 1; 948 domain = builtins.elemAt urlComponents 0; 949 location = builtins.elemAt urlComponents 1; 950 in 951 { 952 virtualHosts.${domain} = { 953 forceSSL = mkDefault true; 954 locations.${location} = { 955 # The trailing slash is important to strip the location prefix from the request 956 proxyPass = "http://${cfg.web.address}:${toString cfg.web.port}/"; 957 proxyWebsockets = true; 958 }; 959 }; 960 } 961 ) 962 ( 963 let 964 urlComponents = builtins.elemAt (builtins.split "https://([^/]*)(/?.*)" cfg.api.externalUrl) 1; 965 domain = builtins.elemAt urlComponents 0; 966 location = builtins.elemAt urlComponents 1; 967 in 968 { 969 virtualHosts.${domain} = { 970 forceSSL = mkDefault true; 971 locations.${location} = { 972 # The trailing slash is important to strip the location prefix from the request 973 proxyPass = "http://${cfg.api.address}:${toString cfg.api.port}/"; 974 proxyWebsockets = true; 975 }; 976 }; 977 } 978 ) 979 ]; 980 }) 981 # Specify sensible defaults 982 { 983 services.firezone.server = { 984 settings = { 985 LOG_LEVEL = mkDefault "info"; 986 RELEASE_HOSTNAME = mkDefault "localhost.localdomain"; 987 988 ERLANG_CLUSTER_ADAPTER = mkDefault "Elixir.Cluster.Strategy.Epmd"; 989 ERLANG_CLUSTER_ADAPTER_CONFIG = mkDefault ( 990 builtins.toJSON { 991 hosts = cfg.clusterHosts; 992 } 993 ); 994 995 TZDATA_DIR = mkDefault "/var/lib/firezone/tzdata"; 996 TELEMETRY_ENABLED = mkDefault false; 997 998 # By default this will open nproc * 2 connections for each component, 999 # which can exceeds the (default) maximum of 100 connections for 1000 # postgresql on a 12 core +SMT machine. 16 connections will be 1001 # sufficient for small to medium deployments 1002 DATABASE_POOL_SIZE = "16"; 1003 1004 AUTH_PROVIDER_ADAPTERS = mkDefault (concatStringsSep "," availableAuthAdapters); 1005 1006 FEATURE_FLOW_ACTIVITIES_ENABLED = mkDefault true; 1007 FEATURE_POLICY_CONDITIONS_ENABLED = mkDefault true; 1008 FEATURE_MULTI_SITE_RESOURCES_ENABLED = mkDefault true; 1009 FEATURE_SELF_HOSTED_RELAYS_ENABLED = mkDefault true; 1010 FEATURE_IDP_SYNC_ENABLED = mkDefault true; 1011 FEATURE_REST_API_ENABLED = mkDefault true; 1012 FEATURE_INTERNET_RESOURCE_ENABLED = mkDefault true; 1013 FEATURE_TRAFFIC_FILTERS_ENABLED = mkDefault true; 1014 1015 FEATURE_SIGN_UP_ENABLED = mkDefault (!cfg.provision.enable); 1016 1017 WEB_EXTERNAL_URL = mkDefault cfg.web.externalUrl; 1018 API_EXTERNAL_URL = mkDefault cfg.api.externalUrl; 1019 }; 1020 1021 domain.settings = { 1022 ERLANG_DISTRIBUTION_PORT = mkDefault 9000; 1023 HEALTHZ_PORT = mkDefault 4000; 1024 BACKGROUND_JOBS_ENABLED = mkDefault true; 1025 }; 1026 1027 web.settings = { 1028 ERLANG_DISTRIBUTION_PORT = mkDefault 9001; 1029 HEALTHZ_PORT = mkDefault 4001; 1030 BACKGROUND_JOBS_ENABLED = mkDefault false; 1031 1032 PHOENIX_LISTEN_ADDRESS = mkDefault cfg.web.address; 1033 PHOENIX_EXTERNAL_TRUSTED_PROXIES = mkDefault (builtins.toJSON cfg.web.trustedProxies); 1034 PHOENIX_HTTP_WEB_PORT = mkDefault cfg.web.port; 1035 PHOENIX_HTTP_API_PORT = mkDefault cfg.api.port; 1036 PHOENIX_SECURE_COOKIES = mkDefault true; # enforce HTTPS on cookies 1037 }; 1038 1039 api.settings = { 1040 ERLANG_DISTRIBUTION_PORT = mkDefault 9002; 1041 HEALTHZ_PORT = mkDefault 4002; 1042 BACKGROUND_JOBS_ENABLED = mkDefault false; 1043 1044 PHOENIX_LISTEN_ADDRESS = mkDefault cfg.api.address; 1045 PHOENIX_EXTERNAL_TRUSTED_PROXIES = mkDefault (builtins.toJSON cfg.api.trustedProxies); 1046 PHOENIX_HTTP_WEB_PORT = mkDefault cfg.web.port; 1047 PHOENIX_HTTP_API_PORT = mkDefault cfg.api.port; 1048 PHOENIX_SECURE_COOKIES = mkDefault true; # enforce HTTPS on cookies 1049 }; 1050 }; 1051 } 1052 (mkIf (!cfg.smtp.configureManually) { 1053 services.firezone.server.settings = { 1054 OUTBOUND_EMAIL_ADAPTER = "Elixir.Swoosh.Adapters.Mua"; 1055 OUTBOUND_EMAIL_ADAPTER_OPTS = builtins.toJSON { }; 1056 OUTBOUND_EMAIL_FROM = cfg.smtp.from; 1057 OUTBOUND_EMAIL_SMTP_HOST = cfg.smtp.host; 1058 OUTBOUND_EMAIL_SMTP_PORT = toString cfg.smtp.port; 1059 OUTBOUND_EMAIL_SMTP_PROTOCOL = if cfg.smtp.implicitTls then "ssl" else "tcp"; 1060 OUTBOUND_EMAIL_SMTP_USERNAME = cfg.smtp.username; 1061 }; 1062 services.firezone.server.settingsSecret = { 1063 OUTBOUND_EMAIL_SMTP_PASSWORD = cfg.smtp.passwordFile; 1064 }; 1065 }) 1066 (mkIf cfg.provision.enable { 1067 # Load client secrets from authentication providers 1068 services.firezone.server.settingsSecret = flip concatMapAttrs cfg.provision.accounts ( 1069 accountName: accountCfg: 1070 flip concatMapAttrs accountCfg.auth ( 1071 authName: authCfg: 1072 optionalAttrs (authCfg.adapter_config.clientSecretFile != null) { 1073 "AUTH_CLIENT_SECRET_${toUpper accountName}_${toUpper authName}" = 1074 authCfg.adapter_config.clientSecretFile; 1075 } 1076 ) 1077 ); 1078 }) 1079 (mkIf (cfg.openClusterFirewall && cfg.domain.enable) { 1080 networking.firewall.allowedTCPPorts = [ 1081 cfg.domain.settings.ERLANG_DISTRIBUTION_PORT 1082 ]; 1083 }) 1084 (mkIf (cfg.openClusterFirewall && cfg.web.enable) { 1085 networking.firewall.allowedTCPPorts = [ 1086 cfg.web.settings.ERLANG_DISTRIBUTION_PORT 1087 ]; 1088 }) 1089 (mkIf (cfg.openClusterFirewall && cfg.api.enable) { 1090 networking.firewall.allowedTCPPorts = [ 1091 cfg.api.settings.ERLANG_DISTRIBUTION_PORT 1092 ]; 1093 }) 1094 (mkIf (cfg.domain.enable || cfg.web.enable || cfg.api.enable) { 1095 systemd.slices.system-firezone = { 1096 description = "Firezone Slice"; 1097 }; 1098 1099 systemd.targets.firezone = { 1100 description = "Common target for all Firezone services."; 1101 wantedBy = [ "multi-user.target" ]; 1102 }; 1103 1104 systemd.services.firezone-initialize = { 1105 description = "Backend initialization service for the Firezone zero-trust access platform"; 1106 1107 after = mkIf cfg.enableLocalDB [ "postgresql.target" ]; 1108 requires = mkIf cfg.enableLocalDB [ "postgresql.target" ]; 1109 wantedBy = [ "firezone.target" ]; 1110 partOf = [ "firezone.target" ]; 1111 1112 script = '' 1113 mkdir -p "$TZDATA_DIR" 1114 1115 # Generate and load secrets 1116 ${generateSecrets} 1117 ${loadSecretEnvironment "domain"} 1118 1119 echo "Running migrations" 1120 ${getExe cfg.domain.package} eval Domain.Release.migrate 1121 ''; 1122 1123 # We use the domain environment to be able to run migrations 1124 environment = collectEnvironment "domain"; 1125 serviceConfig = commonServiceConfig // { 1126 Type = "oneshot"; 1127 RemainAfterExit = true; 1128 }; 1129 }; 1130 1131 systemd.services.firezone-server-domain = mkIf cfg.domain.enable { 1132 description = "Backend domain server for the Firezone zero-trust access platform"; 1133 after = [ "firezone-initialize.service" ]; 1134 bindsTo = [ "firezone-initialize.service" ]; 1135 wantedBy = [ "firezone.target" ]; 1136 partOf = [ "firezone.target" ]; 1137 1138 script = '' 1139 ${loadSecretEnvironment "domain"} 1140 exec ${getExe cfg.domain.package} start; 1141 ''; 1142 1143 path = [ pkgs.curl ]; 1144 postStart = '' 1145 # Wait for the firezone server to come online 1146 count=0 1147 while [[ "$(curl -s "http://localhost:${toString cfg.domain.settings.HEALTHZ_PORT}" 2>/dev/null || echo)" != '{"status":"ok"}' ]] 1148 do 1149 sleep 1 1150 if [[ "$count" -eq 30 ]]; then 1151 echo "Tried for at least 30 seconds, giving up..." 1152 exit 1 1153 fi 1154 count=$((count++)) 1155 done 1156 '' 1157 + optionalString cfg.provision.enable '' 1158 # Wait for server to fully come up. Not ideal to use sleep, but at least it works. 1159 sleep 1 1160 1161 ${loadSecretEnvironment "domain"} 1162 ln -sTf ${provisionStateJson} provision-state.json 1163 ${getExe cfg.domain.package} rpc 'Code.eval_file("${./provision.exs}")' 1164 ''; 1165 1166 environment = collectEnvironment "domain"; 1167 serviceConfig = commonServiceConfig; 1168 }; 1169 1170 systemd.services.firezone-server-web = mkIf cfg.web.enable { 1171 description = "Backend web server for the Firezone zero-trust access platform"; 1172 after = [ "firezone-initialize.service" ]; 1173 bindsTo = [ "firezone-initialize.service" ]; 1174 wantedBy = [ "firezone.target" ]; 1175 partOf = [ "firezone.target" ]; 1176 1177 script = '' 1178 ${loadSecretEnvironment "web"} 1179 exec ${getExe cfg.web.package} start; 1180 ''; 1181 1182 environment = collectEnvironment "web"; 1183 serviceConfig = commonServiceConfig; 1184 }; 1185 1186 systemd.services.firezone-server-api = mkIf cfg.api.enable { 1187 description = "Backend api server for the Firezone zero-trust access platform"; 1188 after = [ "firezone-initialize.service" ]; 1189 bindsTo = [ "firezone-initialize.service" ]; 1190 wantedBy = [ "firezone.target" ]; 1191 partOf = [ "firezone.target" ]; 1192 1193 script = '' 1194 ${loadSecretEnvironment "api"} 1195 exec ${getExe cfg.api.package} start; 1196 ''; 1197 1198 environment = collectEnvironment "api"; 1199 serviceConfig = commonServiceConfig; 1200 }; 1201 }) 1202 ]; 1203 1204 meta.maintainers = with lib.maintainers; [ 1205 oddlama 1206 patrickdag 1207 ]; 1208}