Files
2026-06-08 10:35:38 +02:00

759 lines
25 KiB
Nix

{
lib,
pkgs,
config,
...
}:
let
inherit (lib)
attrNames
boolToString
concatLines
concatStringsSep
filterAttrs
forEach
getExe
isBool
mapAttrs
mapAttrsToList
mkDefault
mkEnableOption
mkIf
mkMerge
mkOption
mkPackageOption
subtractLists
types
;
cfg = config.services.firezone.server;
availableAuthAdapters = [
"email"
"openid_connect"
"userpass"
"token"
"google_workspace"
"microsoft_entra"
"okta"
"jumpcloud"
];
# All non-secret environment variables or the given component
collectEnvironment =
component:
mapAttrs (_: v: if isBool v then boolToString v else toString v) (
cfg.settings // cfg.${component}.settings
);
# All mandatory secrets which were not explicitly provided by the user will
# have to be generated, if they do not yet exist.
generateSecrets =
let
requiredSecrets = filterAttrs (_: v: v == null) cfg.settingsSecret;
in
''
mkdir -p secrets
chmod 700 secrets
''
+ concatLines (
forEach (attrNames requiredSecrets) (secret: ''
if [[ ! -e secrets/${secret} ]]; then
echo "Generating ${secret}"
# Some secrets like TOKENS_KEY_BASE require a value >=64 bytes.
head -c 64 /dev/urandom | base64 -w 0 > secrets/${secret}
chmod 600 secrets/${secret}
fi
'')
);
# All secrets given in `cfg.settingsSecret` must be loaded from a file and
# exported into the environment. Also exclude any variables that were
# overwritten by the local component settings.
loadSecretEnvironment =
component:
let
relevantSecrets = subtractLists (attrNames cfg.${component}.settings) (
attrNames cfg.settingsSecret
);
in
concatLines (
forEach relevantSecrets (
secret:
"export ${secret}=$(< ${
if cfg.settingsSecret.${secret} == null then
"secrets/${secret}"
else
"\"$CREDENTIALS_DIRECTORY/${secret}\""
})"
)
);
commonServiceConfig = {
AmbientCapabilities = [ ];
CapabilityBoundingSet = [ ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateMounts = true;
PrivateTmp = true;
PrivateUsers = false;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK"
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = "@system-service";
UMask = "077";
DynamicUser = true;
User = "firezone";
Slice = "system-firezone.slice";
StateDirectory = "firezone";
WorkingDirectory = "/var/lib/firezone";
LoadCredential = mapAttrsToList (secretName: secretFile: "${secretName}:${secretFile}") (
filterAttrs (_: v: v != null) cfg.settingsSecret
);
Type = "exec";
Restart = "on-failure";
RestartSec = 10;
};
componentOptions = component: {
enable = mkEnableOption "the Firezone ${component} server";
package = mkPackageOption pkgs "firezone-server-${component}" { };
settings = mkOption {
description = ''
Environment variables for this component of the Firezone server. For a
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).
Some variables like `OUTBOUND_EMAIL_ADAPTER_OPTS` require json values
for which you can use `VAR = builtins.toJSON { /* ... */ }`.
This component will automatically inherit all variables defined via
{option}`services.firezone.server.settings` and
{option}`services.firezone.server.settingsSecret`, but which can be
overwritten by this option.
'';
default = { };
type = types.submodule {
freeformType = types.attrsOf (
types.oneOf [
types.bool
types.float
types.int
types.str
types.path
types.package
]
);
};
};
};
in
{
imports = [
(lib.mkRemovedOptionModule [ "services" "firezone" "server" "provision" ] ''
Firezone provisioning support has been removed due to outsized maintenance efforts. See https://github.com/NixOS/nixpkgs/pull/529428.
'')
];
options.services.firezone.server = {
enable = mkEnableOption "all Firezone components";
enableLocalDB = mkEnableOption "a local postgresql database for Firezone";
nginx.enable = mkEnableOption "nginx virtualhost definition";
openClusterFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Opens up the erlang distribution port of all enabled components to
allow reaching the server cluster from the internet. You only need to
set this if you are actually distributing your cluster across multiple
machines.
'';
};
clusterHosts = mkOption {
type = types.listOf types.str;
default = [
"api@localhost.localdomain"
"web@localhost.localdomain"
"domain@localhost.localdomain"
];
description = ''
A list of components and their hosts that are part of this cluster. For
a single-machine setup, the default value will be sufficient. This
value will automatically set `ERLANG_CLUSTER_ADAPTER_CONFIG`.
The format is `<COMPONENT_NAME>@<HOSTNAME>`.
'';
};
settingsSecret = mkOption {
default = { };
description = ''
This is a convenience option which allows you to set secret values for
environment variables by specifying a file which will contain the value
at runtime. Before starting the server, the content of each file will
be loaded into the respective environment variable.
Otherwise, this option is equivalent to
{option}`services.firezone.server.settings`. Refer to the settings
option for more information regarding the actual variables and how
filtering rules are applied for each component.
'';
type = types.submodule {
freeformType = types.attrsOf types.path;
options = {
RELEASE_COOKIE = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
A file containing a unique secret identifier for the Erlang
cluster. All Firezone components in your cluster must use the
same value.
If this is `null`, a shared value will automatically be generated
on startup and used for all components on this machine. You do
not need to set this except when you spread your cluster over
multiple hosts.
'';
};
TOKENS_KEY_BASE = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
A file containing a unique base64 encoded secret for the
`TOKENS_KEY_BASE`. All Firezone components in your cluster must
use the same value.
If this is `null`, a shared value will automatically be generated
on startup and used for all components on this machine. You do
not need to set this except when you spread your cluster over
multiple hosts.
'';
};
SECRET_KEY_BASE = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
A file containing a unique base64 encoded secret for the
`SECRET_KEY_BASE`. All Firezone components in your cluster must
use the same value.
If this is `null`, a shared value will automatically be generated
on startup and used for all components on this machine. You do
not need to set this except when you spread your cluster over
multiple hosts.
'';
};
TOKENS_SALT = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
A file containing a unique base64 encoded secret for the
`TOKENS_SALT`. All Firezone components in your cluster must
use the same value.
If this is `null`, a shared value will automatically be generated
on startup and used for all components on this machine. You do
not need to set this except when you spread your cluster over
multiple hosts.
'';
};
LIVE_VIEW_SIGNING_SALT = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
A file containing a unique base64 encoded secret for the
`LIVE_VIEW_SIGNING_SALT`. All Firezone components in your cluster must
use the same value.
If this is `null`, a shared value will automatically be generated
on startup and used for all components on this machine. You do
not need to set this except when you spread your cluster over
multiple hosts.
'';
};
COOKIE_SIGNING_SALT = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
A file containing a unique base64 encoded secret for the
`COOKIE_SIGNING_SALT`. All Firezone components in your cluster must
use the same value.
If this is `null`, a shared value will automatically be generated
on startup and used for all components on this machine. You do
not need to set this except when you spread your cluster over
multiple hosts.
'';
};
COOKIE_ENCRYPTION_SALT = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
A file containing a unique base64 encoded secret for the
`COOKIE_ENCRYPTION_SALT`. All Firezone components in your cluster must
use the same value.
If this is `null`, a shared value will automatically be generated
on startup and used for all components on this machine. You do
not need to set this except when you spread your cluster over
multiple hosts.
'';
};
};
};
};
settings = mkOption {
description = ''
Environment variables for the Firezone server. For a 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).
Some variables like `OUTBOUND_EMAIL_ADAPTER_OPTS` require json values
for which you can use `VAR = builtins.toJSON { /* ... */ }`.
Each component has an additional `settings` option which allows you to
override specific variables passed to that component.
'';
default = { };
type = types.submodule {
freeformType = types.attrsOf (
types.oneOf [
types.bool
types.float
types.int
types.str
types.path
types.package
]
);
};
};
smtp = {
configureManually = mkOption {
type = types.bool;
default = false;
description = ''
Outbound email configuration is mandatory for Firezone and supports
many different delivery adapters. Yet, most users will only need an
SMTP relay to send emails, so this configuration enforced by default.
If you want to utilize an alternative way to send emails (e.g. via a
supportd API-based service), enable this option and define
`OUTBOUND_EMAIL_FROM`, `OUTBOUND_EMAIL_ADAPTER` and
`OUTBOUND_EMAIL_ADAPTER_OPTS` manually via
{option}`services.firezone.server.settings` and/or
{option}`services.firezone.server.settingsSecret`.
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).
'';
};
from = mkOption {
type = types.str;
example = "firezone@example.com";
description = "Outbound SMTP FROM address";
};
host = mkOption {
type = types.str;
example = "mail.example.com";
description = "Outbound SMTP host";
};
port = mkOption {
type = types.port;
example = 465;
description = "Outbound SMTP port";
};
implicitTls = mkOption {
type = types.bool;
default = false;
description = "Whether to use implicit TLS instead of STARTTLS (usually port 465)";
};
username = mkOption {
type = types.str;
example = "firezone@example.com";
description = "Username to authenticate against the SMTP relay";
};
passwordFile = mkOption {
type = types.path;
example = "/run/secrets/smtp-password";
description = "File containing the password for the given username. Beware that a file in the nix store will be world readable.";
};
};
domain = componentOptions "domain";
web = componentOptions "web" // {
externalUrl = mkOption {
type = types.strMatching "^https://.+/$";
example = "https://firezone.example.com/";
description = ''
The external URL under which you will serve the web interface. You
need to setup a reverse proxy for TLS termination, either with
{option}`services.firezone.server.nginx.enable` or manually.
'';
};
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "The address to listen on";
};
port = mkOption {
type = types.port;
default = 8080;
description = "The port under which the web interface will be served locally";
};
trustedProxies = mkOption {
type = types.listOf types.str;
default = [ ];
description = "A list of trusted proxies";
};
};
api = componentOptions "api" // {
externalUrl = mkOption {
type = types.strMatching "^https://.+/$";
example = "https://firezone.example.com/api/";
description = ''
The external URL under which you will serve the api. You need to
setup a reverse proxy for TLS termination, either with
{option}`services.firezone.server.nginx.enable` or manually.
'';
};
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "The address to listen on";
};
port = mkOption {
type = types.port;
default = 8081;
description = "The port under which the api will be served locally";
};
trustedProxies = mkOption {
type = types.listOf types.str;
default = [ ];
description = "A list of trusted proxies";
};
};
};
config = mkMerge [
# Enable all components if the main server is enabled
(mkIf cfg.enable {
services.firezone.server.domain.enable = true;
services.firezone.server.web.enable = true;
services.firezone.server.api.enable = true;
})
# Create (and configure) a local database if desired
(mkIf cfg.enableLocalDB {
services.postgresql = {
enable = true;
ensureUsers = [
{
name = "firezone";
ensureDBOwnership = true;
ensureClauses.superuser = true;
}
];
ensureDatabases = [ "firezone" ];
# Firezone uses an internal replication strategy
# that depends on a logical wal
settings.wal_level = "logical";
};
services.firezone.server.settings = {
DATABASE_SOCKET_DIR = "/run/postgresql";
DATABASE_PORT = "5432";
DATABASE_NAME = "firezone";
DATABASE_USER = "firezone";
DATABASE_PASSWORD = "firezone";
};
})
# Create a local nginx reverse proxy
(mkIf cfg.nginx.enable {
services.nginx = mkMerge [
{
enable = true;
}
(
let
urlComponents = builtins.elemAt (builtins.split "https://([^/]*)(/?.*)" cfg.web.externalUrl) 1;
domain = builtins.elemAt urlComponents 0;
location = builtins.elemAt urlComponents 1;
in
{
virtualHosts.${domain} = {
forceSSL = mkDefault true;
locations.${location} = {
# The trailing slash is important to strip the location prefix from the request
proxyPass = "http://${cfg.web.address}:${toString cfg.web.port}/";
proxyWebsockets = true;
};
};
}
)
(
let
urlComponents = builtins.elemAt (builtins.split "https://([^/]*)(/?.*)" cfg.api.externalUrl) 1;
domain = builtins.elemAt urlComponents 0;
location = builtins.elemAt urlComponents 1;
in
{
virtualHosts.${domain} = {
forceSSL = mkDefault true;
locations.${location} = {
# The trailing slash is important to strip the location prefix from the request
proxyPass = "http://${cfg.api.address}:${toString cfg.api.port}/";
proxyWebsockets = true;
};
};
}
)
];
})
# Specify sensible defaults
{
services.firezone.server = {
settings = {
LOG_LEVEL = mkDefault "info";
RELEASE_HOSTNAME = mkDefault "localhost.localdomain";
ERLANG_CLUSTER_ADAPTER = mkDefault "Elixir.Cluster.Strategy.Epmd";
ERLANG_CLUSTER_ADAPTER_CONFIG = mkDefault (
builtins.toJSON {
hosts = cfg.clusterHosts;
}
);
TZDATA_DIR = mkDefault "/var/lib/firezone/tzdata";
TELEMETRY_ENABLED = mkDefault false;
# By default this will open nproc * 2 connections for each component,
# which can exceeds the (default) maximum of 100 connections for
# postgresql on a 12 core +SMT machine. 16 connections will be
# sufficient for small to medium deployments
DATABASE_POOL_SIZE = "16";
AUTH_PROVIDER_ADAPTERS = mkDefault (concatStringsSep "," availableAuthAdapters);
FEATURE_FLOW_ACTIVITIES_ENABLED = mkDefault true;
FEATURE_POLICY_CONDITIONS_ENABLED = mkDefault true;
FEATURE_MULTI_SITE_RESOURCES_ENABLED = mkDefault true;
FEATURE_SELF_HOSTED_RELAYS_ENABLED = mkDefault true;
FEATURE_IDP_SYNC_ENABLED = mkDefault true;
FEATURE_REST_API_ENABLED = mkDefault true;
FEATURE_INTERNET_RESOURCE_ENABLED = mkDefault true;
FEATURE_TRAFFIC_FILTERS_ENABLED = mkDefault true;
FEATURE_SIGN_UP_ENABLED = mkDefault true;
WEB_EXTERNAL_URL = mkDefault cfg.web.externalUrl;
API_EXTERNAL_URL = mkDefault cfg.api.externalUrl;
};
domain.settings = {
ERLANG_DISTRIBUTION_PORT = mkDefault 9000;
HEALTHZ_PORT = mkDefault 4000;
BACKGROUND_JOBS_ENABLED = mkDefault true;
};
web.settings = {
ERLANG_DISTRIBUTION_PORT = mkDefault 9001;
HEALTHZ_PORT = mkDefault 4001;
BACKGROUND_JOBS_ENABLED = mkDefault false;
PHOENIX_LISTEN_ADDRESS = mkDefault cfg.web.address;
PHOENIX_EXTERNAL_TRUSTED_PROXIES = mkDefault (builtins.toJSON cfg.web.trustedProxies);
PHOENIX_HTTP_WEB_PORT = mkDefault cfg.web.port;
PHOENIX_HTTP_API_PORT = mkDefault cfg.api.port;
PHOENIX_SECURE_COOKIES = mkDefault true; # enforce HTTPS on cookies
};
api.settings = {
ERLANG_DISTRIBUTION_PORT = mkDefault 9002;
HEALTHZ_PORT = mkDefault 4002;
BACKGROUND_JOBS_ENABLED = mkDefault false;
PHOENIX_LISTEN_ADDRESS = mkDefault cfg.api.address;
PHOENIX_EXTERNAL_TRUSTED_PROXIES = mkDefault (builtins.toJSON cfg.api.trustedProxies);
PHOENIX_HTTP_WEB_PORT = mkDefault cfg.web.port;
PHOENIX_HTTP_API_PORT = mkDefault cfg.api.port;
PHOENIX_SECURE_COOKIES = mkDefault true; # enforce HTTPS on cookies
};
};
}
(mkIf (!cfg.smtp.configureManually) {
services.firezone.server.settings = {
OUTBOUND_EMAIL_ADAPTER = "Elixir.Swoosh.Adapters.Mua";
OUTBOUND_EMAIL_ADAPTER_OPTS = builtins.toJSON { };
OUTBOUND_EMAIL_FROM = cfg.smtp.from;
OUTBOUND_EMAIL_SMTP_HOST = cfg.smtp.host;
OUTBOUND_EMAIL_SMTP_PORT = toString cfg.smtp.port;
OUTBOUND_EMAIL_SMTP_PROTOCOL = if cfg.smtp.implicitTls then "ssl" else "tcp";
OUTBOUND_EMAIL_SMTP_USERNAME = cfg.smtp.username;
};
services.firezone.server.settingsSecret = {
OUTBOUND_EMAIL_SMTP_PASSWORD = cfg.smtp.passwordFile;
};
})
(mkIf (cfg.openClusterFirewall && cfg.domain.enable) {
networking.firewall.allowedTCPPorts = [
cfg.domain.settings.ERLANG_DISTRIBUTION_PORT
];
})
(mkIf (cfg.openClusterFirewall && cfg.web.enable) {
networking.firewall.allowedTCPPorts = [
cfg.web.settings.ERLANG_DISTRIBUTION_PORT
];
})
(mkIf (cfg.openClusterFirewall && cfg.api.enable) {
networking.firewall.allowedTCPPorts = [
cfg.api.settings.ERLANG_DISTRIBUTION_PORT
];
})
(mkIf (cfg.domain.enable || cfg.web.enable || cfg.api.enable) {
systemd.slices.system-firezone = {
description = "Firezone Slice";
};
systemd.targets.firezone = {
description = "Common target for all Firezone services.";
wantedBy = [ "multi-user.target" ];
};
systemd.services.firezone-initialize = {
description = "Backend initialization service for the Firezone zero-trust access platform";
after = mkIf cfg.enableLocalDB [ "postgresql.target" ];
requires = mkIf cfg.enableLocalDB [ "postgresql.target" ];
wantedBy = [ "firezone.target" ];
partOf = [ "firezone.target" ];
script = ''
mkdir -p "$TZDATA_DIR"
# Generate and load secrets
${generateSecrets}
${loadSecretEnvironment "domain"}
echo "Running migrations"
${getExe cfg.domain.package} eval Domain.Release.migrate
'';
# We use the domain environment to be able to run migrations
environment = collectEnvironment "domain";
serviceConfig = commonServiceConfig // {
Type = "oneshot";
RemainAfterExit = true;
};
};
systemd.services.firezone-server-domain = mkIf cfg.domain.enable {
description = "Backend domain server for the Firezone zero-trust access platform";
after = [ "firezone-initialize.service" ];
bindsTo = [ "firezone-initialize.service" ];
wantedBy = [ "firezone.target" ];
partOf = [ "firezone.target" ];
script = ''
${loadSecretEnvironment "domain"}
exec ${getExe cfg.domain.package} start;
'';
path = [ pkgs.curl ];
postStart = ''
# Wait for the firezone server to come online
count=0
while [[ "$(curl -s "http://localhost:${toString cfg.domain.settings.HEALTHZ_PORT}" 2>/dev/null || echo)" != '{"status":"ok"}' ]]
do
sleep 1
if [[ "$count" -eq 30 ]]; then
echo "Tried for at least 30 seconds, giving up..."
exit 1
fi
count=$((count++))
done
'';
environment = collectEnvironment "domain";
serviceConfig = commonServiceConfig;
};
systemd.services.firezone-server-web = mkIf cfg.web.enable {
description = "Backend web server for the Firezone zero-trust access platform";
after = [ "firezone-initialize.service" ];
bindsTo = [ "firezone-initialize.service" ];
wantedBy = [ "firezone.target" ];
partOf = [ "firezone.target" ];
script = ''
${loadSecretEnvironment "web"}
exec ${getExe cfg.web.package} start;
'';
environment = collectEnvironment "web";
serviceConfig = commonServiceConfig;
};
systemd.services.firezone-server-api = mkIf cfg.api.enable {
description = "Backend api server for the Firezone zero-trust access platform";
after = [ "firezone-initialize.service" ];
bindsTo = [ "firezone-initialize.service" ];
wantedBy = [ "firezone.target" ];
partOf = [ "firezone.target" ];
script = ''
${loadSecretEnvironment "api"}
exec ${getExe cfg.api.package} start;
'';
environment = collectEnvironment "api";
serviceConfig = commonServiceConfig;
};
})
];
meta.maintainers = with lib.maintainers; [
oddlama
patrickdag
];
}