nixpkgs/nixos/modules/services/networking/pangolin.nix

560 lines
18 KiB
Nix

{
utils,
config,
options,
lib,
pkgs,
...
}:
let
cfg = config.services.pangolin;
format = pkgs.formats.yaml { };
finalSettings = lib.attrsets.recursiveUpdate pangolinConf cfg.settings;
cfgFile = format.generate "config.yml" finalSettings;
# override the type to allow for optionality
nullOrOpt = t: lib.types.nullOr t // { _optional = true; };
gerbil-wg0-fix-script = pkgs.writeShellApplication {
name = "gerbil-wg0-fix-script";
runtimeInputs = with pkgs; [
coreutils
iproute2
];
# will not work if the interface is renamed
# https://github.com/fosrl/newt/issues/37#issuecomment-3193385911
text = ''
if [ ! -f /var/lib/pangolin/config/wg0 ]; then
until ip l d wg0
do
sleep 2
done
touch /var/lib/pangolin/config/wg0
systemctl restart gerbil --no-block
fi
'';
};
pangolinConf = {
app.dashboard_url = "https://${cfg.dashboardDomain}";
domains.domain1 = {
base_domain = cfg.baseDomain;
prefer_wildcard_cert = false;
};
server = {
external_port = 3000;
internal_port = 3001;
next_port = 3002;
integration_port = 3004;
# needs to be set, otherwise this fails silently
# see https://github.com/fosrl/newt/issues/37
internal_hostname = "localhost";
};
gerbil.base_endpoint = cfg.dashboardDomain;
flags.enable_integration_api = false;
};
in
{
options.services = {
pangolin = {
enable = lib.mkEnableOption "Pangolin reverse proxy server";
package = lib.mkPackageOption pkgs "fosrl-pangolin" { };
settings = lib.mkOption {
inherit (format) type;
default = { };
description = ''
Additional attributes to be merged with the configuration options and written to Pangolin's `config.yml` file.
'';
example = {
app = {
save_logs = true;
};
server = {
external_port = 3007;
internal_port = 3008;
};
domains.domain1 = {
prefer_wildcard_cert = true;
};
};
};
openFirewall = lib.mkEnableOption "opening TCP ports 80 and 443, and UDP port 51820 in the firewall for the Pangolin service(s)";
baseDomain = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = ''
Your base fully qualified domain name (without any subdomains).
'';
example = "example.com";
};
dashboardDomain = lib.mkOption {
type = lib.types.str;
default = if (isNull cfg.baseDomain) then "" else "pangolin.${cfg.baseDomain}";
defaultText = "pangolin.\${config.services.pangolin.baseDomain}";
description = ''
The domain where the application will be hosted. This is used for many things, including generating links. You can run Pangolin on a subdomain or root domain. Do not prefix with `http` or `https`.
'';
example = "auth.example.com";
};
letsEncryptEmail = lib.mkOption {
type = with lib.types; nullOr str;
default = config.security.acme.defaults.email;
defaultText = lib.literalExpression "config.security.acme.defaults.email";
description = ''
An email address for SSL certificate registration with Let's Encrypt. This should be an email you have access to.
'';
};
# this assumes that all domains are hosted by the same provider
dnsProvider = lib.mkOption {
type = nullOrOpt lib.types.str;
default = null;
description = ''
The DNS provider Traefik will request wildcard certificates from. See the [Traefik Documentation](https://doc.traefik.io/traefik/https/acme/#providers) for more information.
'';
};
# provide path to file to keep secrets out of the nix store
environmentFile = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
description = ''
Path to a file containing sensitive environment variables for Pangolin. See the [Pangolin Documentation](https://docs.fossorial.io/Pangolin/Configuration/config) for more information.
These will overwrite anything defined in the config.
The file should contain environment-variable assignments like:
```
SERVER_SECRET=1234567890abc
```
'';
example = "/etc/nixos/secrets/pangolin.env";
};
dataDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/pangolin";
example = "/srv/pangolin";
description = "Path to variable state data directory for Pangolin.";
};
};
gerbil = {
port = lib.mkOption {
type = lib.types.port;
default = 3003;
description = ''
Specifies the port to listen on for Gerbil.
'';
};
environmentFile = lib.mkOption {
type = nullOrOpt lib.types.path;
default = null;
description = ''
Path to a file containing sensitive environment variables for Gerbil. See the [Gerbil Documentation](https://docs.fossorial.io/Pangolin/Configuration/config) for more information.
These will overwrite anything defined in the config.
'';
example = "/etc/nixos/secrets/gerbil.env";
};
};
};
config = lib.mkIf cfg.enable {
assertions =
(lib.mapAttrsToList (name: value: {
# check if the value is optional by looking at the type
assertion = (value == null) -> options.services.pangolin."${name}".type._optional or false;
message = "services.pangolin.${name} must be provided when Pangolin is enabled.";
}) cfg)
++ [
{
# wildcards implies (dnsProvider and traefikEnvironmentFile)
assertion =
(finalSettings.traefik.prefer_wildcard_cert or finalSettings.domains.domain1.prefer_wildcard_cert)
-> (cfg.dnsProvider != "" && config.services.traefik.environmentFiles != [ ]);
message = "services.pangolin.dnsProvider and services.traefik.environmentFile must be provided when prefer_wildcard_cert is true.";
}
];
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
80
443
];
allowedUDPPorts = [ 51820 ];
};
users = {
users = {
pangolin = {
description = "Pangolin service user";
group = "fossorial";
isSystemUser = true;
packages = [ cfg.package ];
};
gerbil = {
description = "Gerbil service user";
group = "fossorial";
isSystemUser = true;
};
};
groups.fossorial = {
members = [
"pangolin"
"gerbil"
"traefik"
];
};
};
# order is as follows
# "pangolin.service"
# "gerbil.service"
# "traefik.service"
### TODO:
# make tunnels declarative by calling API
###
systemd = {
tmpfiles.settings."10-fossorial-paths" = {
"${cfg.dataDir}".d = {
user = "pangolin";
group = "fossorial";
mode = "0770";
};
"${cfg.dataDir}/config".d = {
user = "pangolin";
group = "fossorial";
mode = "0770";
};
"${cfg.dataDir}/config/letsencrypt".d = {
user = "traefik";
group = "fossorial";
mode = "0700";
};
};
services = {
pangolin = {
description = "Pangolin reverse proxy tunneling service";
wantedBy = [ "multi-user.target" ];
requires = [ "network.target" ];
after = [ "network.target" ];
preStart = ''
mkdir -p ${cfg.dataDir}/config
cp -f ${cfgFile} ${cfg.dataDir}/config/config.yml
'';
serviceConfig = {
User = "pangolin";
Group = "fossorial";
WorkingDirectory = cfg.dataDir;
Restart = "always";
EnvironmentFile = cfg.environmentFile;
# hardening
ProtectSystem = "full";
ProtectHome = true;
PrivateTmp = "disconnected";
PrivateDevices = true;
PrivateMounts = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
LockPersonality = true;
RestrictRealtime = true;
ProtectClock = true;
ProtectProc = "noaccess";
ProtectHostname = true;
NoNewPrivileges = true;
RestrictSUIDSGID = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK"
"AF_UNIX"
];
SocketBindDeny = [
"ipv4:tcp"
"ipv4:udp"
"ipv6:udp"
];
CapabilityBoundingSet = [
"~CAP_BLOCK_SUSPEND"
"~CAP_BPF"
"~CAP_CHOWN"
"~CAP_MKNOD"
"~CAP_NET_RAW"
"~CAP_PERFMON"
"~CAP_SYS_BOOT"
"~CAP_SYS_CHROOT"
"~CAP_SYS_MODULE"
"~CAP_SYS_NICE"
"~CAP_SYS_PACCT"
"~CAP_SYS_PTRACE"
"~CAP_SYS_TIME"
"~CAP_SYSLOG"
"~CAP_WAKE_ALARM"
];
SystemCallFilter = [
"~@chown:EPERM"
"~@clock:EPERM"
"~@cpu-emulation:EPERM"
"~@debug:EPERM"
"~@keyring:EPERM"
"~@memlock:EPERM"
"~@module:EPERM"
"~@mount:EPERM"
"~@obsolete:EPERM"
"~@pkey:EPERM"
"~@privileged:EPERM"
"~@raw-io:EPERM"
"~@reboot:EPERM"
"~@resources:EPERM"
"~@sandbox:EPERM"
"~@setuid:EPERM"
"~@swap:EPERM"
"~@timer:EPERM"
];
ExecStart = lib.getExe cfg.package;
};
};
gerbil = {
description = "Gerbil Service";
wantedBy = [ "multi-user.target" ];
after = [ "pangolin.service" ];
requires = [ "pangolin.service" ];
before = [ "traefik.service" ];
requiredBy = [ "traefik.service" ];
# restarting gerbil restarts traefik
upholds = [ "traefik.service" ];
# provide default to use correct port without envfile
environment = {
LISTEN = "localhost:" + toString config.services.gerbil.port;
};
serviceConfig = {
User = "gerbil";
Group = "fossorial";
WorkingDirectory = cfg.dataDir;
Restart = "always";
EnvironmentFile = cfg.environmentFile;
ReadWritePaths = "${cfg.dataDir}/config";
# hardening
AmbientCapabilities = [
"CAP_NET_ADMIN"
"CAP_SYS_MODULE"
];
CapabilityBoundingSet = [
"CAP_NET_ADMIN"
"CAP_SYS_MODULE"
"~CAP_BLOCK_SUSPEND"
"~CAP_BPF"
"~CAP_CHOWN"
"~CAP_MKNOD"
"~CAP_PERFMON"
"~CAP_SYS_BOOT"
"~CAP_SYS_CHROOT"
"~CAP_SYS_NICE"
"~CAP_SYS_PACCT"
"~CAP_SYS_PTRACE"
"~CAP_SYS_TIME"
"~CAP_SYS_TTY_CONFIG"
"~CAP_SYSLOG"
"~CAP_WAKE_ALARM"
];
ProtectSystem = "full";
ProtectHome = true;
PrivateTmp = "disconnected";
PrivateDevices = true;
PrivateMounts = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
LockPersonality = true;
RestrictRealtime = true;
ProtectClock = true;
ProtectProc = "noaccess";
ProtectHostname = true;
NoNewPrivileges = true;
RestrictSUIDSGID = true;
MemoryDenyWriteExecute = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK"
"AF_UNIX"
];
SystemCallFilter = [
"~@aio:EPERM"
"~@chown:EPERM"
"~@clock:EPERM"
"~@cpu-emulation:EPERM"
"~@debug:EPERM"
"~@keyring:EPERM"
"~@memlock:EPERM"
"~@mount:EPERM"
"~@obsolete:EPERM"
"~@pkey:EPERM"
"~@privileged:EPERM"
"~@raw-io:EPERM"
"~@reboot:EPERM"
"~@resources:EPERM"
"~@sandbox:EPERM"
"~@setuid:EPERM"
"~@swap:EPERM"
"~@sync:EPERM"
"~@timer:EPERM"
];
ExecStart = utils.escapeSystemdExecArgs [
(lib.getExe pkgs.fosrl-gerbil)
"--reachableAt=http://localhost:${toString config.services.gerbil.port}"
"--generateAndSaveKeyTo=${toString cfg.dataDir}/config/key"
"--remoteConfig=http://localhost:${toString finalSettings.server.internal_port}/api/v1/gerbil/get-config"
];
# will not work if the interface is renamed
# https://github.com/fosrl/newt/issues/37#issuecomment-3193385911
ExecStartPost = lib.getExe gerbil-wg0-fix-script;
};
};
traefik = {
wantedBy = [ "multi-user.target" ];
after = [ "gerbil.service" ];
requires = [ "gerbil.service" ];
partOf = [ "gerbil.service" ];
};
};
};
services.traefik = {
enable = true;
group = "fossorial";
dataDir = "${cfg.dataDir}/config/traefik";
staticConfigOptions = {
providers.http = {
endpoint = "http://localhost:${toString finalSettings.server.internal_port}/api/v1/traefik-config";
pollInterval = "5s";
};
# TODO to change this once #437073 is merged.
experimental.plugins.badger = {
moduleName = "github.com/fosrl/badger";
version = "v1.2.0";
};
certificatesResolvers.letsencrypt.acme =
(
if finalSettings.domains.domain1.prefer_wildcard_cert then
{
# see https://doc.traefik.io/traefik/https/acme/#providers
dnsChallenge.provider = cfg.dnsProvider;
}
else
{
httpChallenge.entryPoint = "web";
}
)
//
# common
{
email = cfg.letsEncryptEmail;
storage = "${cfg.dataDir}/config/letsencrypt/acme.json";
caServer = "https://acme-v02.api.letsencrypt.org/directory";
};
entryPoints = {
web.address = ":80";
websecure = {
address = ":443";
transport.respondingTimeouts.readTimeout = "30m";
http.tls.certResolver = "letsencrypt";
};
};
};
dynamicConfigOptions = {
http = {
middlewares.redirect-to-https.redirectScheme.scheme = "https";
routers = {
# HTTP to HTTPS redirect router
main-app-router-redirect = {
rule = "Host(`${cfg.dashboardDomain}`)";
service = "next-service";
entryPoints = [ "web" ];
middlewares = [ "redirect-to-https" ];
};
# Next.js router (handles everything except API and WebSocket paths)
next-router = {
rule = "Host(`${cfg.dashboardDomain}`) && !PathPrefix(`/api/v1`)";
service = "next-service";
entryPoints = [ "websecure" ];
tls =
lib.optionalAttrs (finalSettings.domains.domain1.prefer_wildcard_cert) {
domains = [
{ main = cfg.baseDomain; }
{ sans = "*.${cfg.baseDomain}"; }
];
}
//
# common
{
certResolver = "letsencrypt";
};
};
# API router (handles /api/v1 paths)
api-router = {
rule = "Host(`${cfg.dashboardDomain}`) && PathPrefix(`/api/v1`)";
service = "api-service";
entryPoints = [ "websecure" ];
tls.certResolver = "letsencrypt";
};
# WebSocket router
ws-router = {
rule = "Host(`${cfg.dashboardDomain}`)";
service = "api-service";
entryPoints = [ "websecure" ];
tls.certResolver = "letsencrypt";
};
# Integration API router
int-api-router-redirect = {
rule = "Host(`api.${cfg.baseDomain}`)";
service = "int-api-service";
entryPoints = [ "web" ];
middlewares = [ "redirect-to-https" ];
};
int-api-router = {
rule = "Host(`api.${cfg.baseDomain}`)";
service = "int-api-service";
entryPoints = [ "websecure" ];
tls.certResolver = "letsencrypt";
};
};
# needs to be a mkMerge otherwise will give error about standalone element
services = lib.mkMerge [
{
# Next.js server
next-service.loadBalancer.servers = [
{ url = "http://localhost:${toString finalSettings.server.next_port}"; }
];
# API/WebSocket server
api-service.loadBalancer.servers = [
{ url = "http://localhost:${toString finalSettings.server.external_port}"; }
];
}
(lib.mkIf (finalSettings.flags.enable_integration_api) {
# Integration API server
int-api-service.loadBalancer.servers = [
{ url = "http://localhost:${toString finalSettings.server.integration_port}"; }
];
})
];
};
};
};
};
meta.maintainers = with lib.maintainers; [
jackr
sigmasquadron
];
}