mirror of
https://github.com/NixOS/nixpkgs.git
synced 2025-11-10 17:54:53 +01:00
560 lines
18 KiB
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
|
|
];
|
|
}
|