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}