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}