feat(refactor): WIP refactor server modules
This commit is contained in:
133
modules/server/infra/authentik/default.nix
Normal file
133
modules/server/infra/authentik/default.nix
Normal file
@@ -0,0 +1,133 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
self,
|
||||
...
|
||||
}: let
|
||||
unit = "authentik";
|
||||
cfg = config.server.infra.${unit};
|
||||
srv = config.server.infra.www.domain;
|
||||
in {
|
||||
options.server.infra.${unit} = {
|
||||
enable = lib.mkEnableOption {
|
||||
description = "Enable ${unit}";
|
||||
};
|
||||
url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "auth.${srv.www.domain}";
|
||||
};
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
description = "The local port the service runs on";
|
||||
};
|
||||
cloudflared = {
|
||||
credentialsFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = lib.literalExpression ''
|
||||
pkgs.writeText "cloudflare-credentials.json" '''
|
||||
{"AccountTag":"secret"."TunnelSecret":"secret","TunnelID":"secret"}
|
||||
'''
|
||||
'';
|
||||
};
|
||||
tunnelId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "00000000-0000-0000-0000-000000000000";
|
||||
};
|
||||
};
|
||||
homepage = {
|
||||
name = "Authentik";
|
||||
description = "An open-source IdP for modern SSO";
|
||||
icon = "authentik.svg";
|
||||
category = "Services";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
age.secrets = {
|
||||
authentikEnv = {
|
||||
file = "${self}/secrets/authentikEnv.age";
|
||||
};
|
||||
authentikCloudflared = {
|
||||
file = "${self}/secrets/authentikCloudflared.age";
|
||||
};
|
||||
};
|
||||
|
||||
server.infra = {
|
||||
fail2ban = {
|
||||
jails = {
|
||||
authentik = {
|
||||
serviceName = "authentik";
|
||||
failRegex = ''^.*Username or password is incorrect.*IP:\s*<HOST>'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services = {
|
||||
authentik = {
|
||||
enable = true;
|
||||
environmentFile = config.age.secrets.authentikEnv.path;
|
||||
settings = {
|
||||
email = {
|
||||
};
|
||||
disable_startup_analytics = true;
|
||||
avatars = "initials";
|
||||
};
|
||||
};
|
||||
|
||||
cloudflared = {
|
||||
enable = true;
|
||||
tunnels.${cfg.cloudflared.tunnelId} = {
|
||||
credentialsFile = cfg.cloudflared.credentialsFile;
|
||||
default = "http_status:404";
|
||||
ingress."${cfg.url}".service = "http://127.0.0.1:9000";
|
||||
};
|
||||
};
|
||||
|
||||
traefik = {
|
||||
dynamicConfigOptions = {
|
||||
http = {
|
||||
middlewares = {
|
||||
authentik = {
|
||||
forwardAuth = {
|
||||
address = "https://localhost:9443/outpost.goauthentik.io/auth/traefik";
|
||||
trustForwardHeader = true;
|
||||
authResponseHeaders = [
|
||||
"X-authentik-username"
|
||||
"X-authentik-groups"
|
||||
"X-authentik-email"
|
||||
"X-authentik-name"
|
||||
"X-authentik-uid"
|
||||
"X-authentik-jwt"
|
||||
"X-authentik-meta-jwks"
|
||||
"X-authentik-meta-outpost"
|
||||
"X-authentik-meta-provider"
|
||||
"X-authentik-meta-app"
|
||||
"X-authentik-meta-version"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services = {
|
||||
auth.loadBalancer.servers = [
|
||||
{
|
||||
url = "http://localhost:9000";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
routers = {
|
||||
auth = {
|
||||
entryPoints = ["websecure"];
|
||||
rule = "Host(`${cfg.url}`) && PathPrefix(`/outpost.goauthentik.io/`)";
|
||||
service = "auth";
|
||||
tls.certResolver = "letsencrypt";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
131
modules/server/infra/fail2ban/default.nix
Normal file
131
modules/server/infra/fail2ban/default.nix
Normal file
@@ -0,0 +1,131 @@
|
||||
# from @notthebee
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
pkgs,
|
||||
...
|
||||
}: let
|
||||
cfg = config.server.infra.fail2ban;
|
||||
in {
|
||||
options.server.infra.fail2ban = {
|
||||
enable = lib.mkEnableOption {
|
||||
description = "Enable cloudflare fail2ban";
|
||||
};
|
||||
apiKeyFile = lib.mkOption {
|
||||
description = "File containing your API key, scoped to Firewall Rules: Edit";
|
||||
type = lib.types.str;
|
||||
example = lib.literalExpression ''
|
||||
Authorization: Bearer vH6-p0y=i4w3n7TjKqZ@x8D_lR!A9b2cOezXgUuJdE5F
|
||||
'''
|
||||
'';
|
||||
};
|
||||
zoneId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
};
|
||||
jails = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
serviceName = lib.mkOption {
|
||||
example = "vaultwarden";
|
||||
type = lib.types.str;
|
||||
};
|
||||
_groupsre = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
example = ''(?:(?:,?\s*"\w+":(?:"[^"]+"|\w+))*)'';
|
||||
default = "";
|
||||
};
|
||||
failRegex = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
example = ''
|
||||
^Login failed from IP: <HOST>$
|
||||
^Two-factor challenge failed from <HOST>$
|
||||
'';
|
||||
};
|
||||
ignoreRegex = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
};
|
||||
datePattern = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = '',?\s*"time"\s*:\s*"%%Y-%%m-%%d[T ]%%H:%%M:%%S(%%z)?"'';
|
||||
description = "Optional datepattern line for the fail2ban filter.";
|
||||
};
|
||||
maxRetry = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 3;
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services.fail2ban = {
|
||||
enable = true;
|
||||
extraPackages = [
|
||||
pkgs.curl
|
||||
pkgs.jq
|
||||
];
|
||||
|
||||
jails =
|
||||
lib.attrsets.mapAttrs (name: value: {
|
||||
settings = {
|
||||
bantime = "24h";
|
||||
findtime = "10m";
|
||||
enabled = true;
|
||||
backend = "systemd";
|
||||
journalmatch = "_SYSTEMD_UNIT=${value.serviceName}.service";
|
||||
port = "http,https";
|
||||
filter = "${name}";
|
||||
maxretry = 3;
|
||||
action = "cloudflare-token-agenix";
|
||||
};
|
||||
})
|
||||
cfg.jails;
|
||||
};
|
||||
|
||||
environment.etc = lib.attrsets.mergeAttrsList [
|
||||
(lib.attrsets.mapAttrs' (
|
||||
name: value: (lib.nameValuePair "fail2ban/filter.d/${name}.conf" {
|
||||
text =
|
||||
''
|
||||
[Definition]
|
||||
failregex = ${value.failRegex}
|
||||
ignoreregex = ${value.ignoreRegex}
|
||||
''
|
||||
+ lib.optionalString (value.datePattern != "") ''
|
||||
datepattern = ${value.datePattern}
|
||||
''
|
||||
+ lib.optionalString (value._groupsre != "") ''
|
||||
_groupsre = ${value._groupsre}
|
||||
'';
|
||||
})
|
||||
)
|
||||
cfg.jails)
|
||||
{
|
||||
"fail2ban/action.d/cloudflare-token-agenix.conf".text = let
|
||||
notes = "Fail2Ban on ${config.networking.hostName}";
|
||||
cfapi = "https://api.cloudflare.com/client/v4/zones/${cfg.zoneId}/firewall/access_rules/rules";
|
||||
in ''
|
||||
[Definition]
|
||||
actionstart =
|
||||
actionstop =
|
||||
actioncheck =
|
||||
actionunban = id=$(curl -s -X GET "${cfapi}" \
|
||||
-H @${cfg.apiKeyFile} -H "Content-Type: application/json" \
|
||||
| jq -r '.result[] | select(.notes == "${notes}" and .configuration.target == "ip" and .configuration.value == "<ip>") | .id')
|
||||
if [ -z "$id" ]; then echo "id for <ip> cannot be found"; exit 0; fi; \
|
||||
curl -s -X DELETE "${cfapi}/$id" \
|
||||
-H @${cfg.apiKeyFile} -H "Content-Type: application/json" \
|
||||
--data '{"cascade": "none"}'
|
||||
actionban = curl -X POST "${cfapi}" -H @${cfg.apiKeyFile} -H "Content-Type: application/json" --data '{"mode":"block","configuration":{"target":"ip","value":"<ip>"},"notes":"${notes}"}'
|
||||
[Init]
|
||||
name = cloudflare-token-agenix
|
||||
'';
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
71
modules/server/infra/keepalived/default.nix
Normal file
71
modules/server/infra/keepalived/default.nix
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
self,
|
||||
...
|
||||
}: let
|
||||
unit = "keepalived";
|
||||
cfg = config.server.infra.${unit};
|
||||
|
||||
hostCfg = hostname:
|
||||
if hostname == "sobotka"
|
||||
then {
|
||||
ip = "192.168.88.14";
|
||||
priority = 20;
|
||||
state = "MASTER";
|
||||
}
|
||||
else if hostname == "ziggy"
|
||||
then {
|
||||
ip = "192.168.88.12";
|
||||
priority = 10;
|
||||
state = "BACKUP";
|
||||
}
|
||||
else throw "No keepalived config defined for host ${hostname}";
|
||||
|
||||
_self = hostCfg config.networking.hostName;
|
||||
|
||||
allPeers = [
|
||||
"192.168.88.12"
|
||||
"192.168.88.14"
|
||||
];
|
||||
|
||||
# Remove self from peers
|
||||
peers = builtins.filter (ip: ip != _self.ip) allPeers;
|
||||
in {
|
||||
options.server.infra.${unit} = {
|
||||
enable = lib.mkEnableOption {
|
||||
description = "Enable ${unit}";
|
||||
};
|
||||
interface = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "eth0";
|
||||
description = "The network interface keepalived should bind to.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
age.secrets.keepalived.file = "${self}/secrets/keepalived.age";
|
||||
services.keepalived = {
|
||||
enable = true;
|
||||
vrrpInstances.VI = {
|
||||
state = _self.state;
|
||||
interface = cfg.interface;
|
||||
virtualRouterId = 69;
|
||||
priority = _self.priority;
|
||||
unicastSrcIp = _self.ip;
|
||||
unicastPeers = peers;
|
||||
virtualIps = [
|
||||
{
|
||||
addr = "192.168.88.69/24";
|
||||
}
|
||||
];
|
||||
extraConfig = ''
|
||||
authentication {
|
||||
auth_type PASS
|
||||
auth_pass ${config.age.secrets.keepalived.path}
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
6
modules/server/infra/podman/acquis.yaml
Normal file
6
modules/server/infra/podman/acquis.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
filenames:
|
||||
- /var/log/traefik/access.log
|
||||
poll_without_inotify: true
|
||||
labels:
|
||||
type: traefik
|
||||
162
modules/server/infra/podman/default.nix
Normal file
162
modules/server/infra/podman/default.nix
Normal file
@@ -0,0 +1,162 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
self,
|
||||
...
|
||||
}: let
|
||||
infra = config.server.infra;
|
||||
cfg = config.server.services;
|
||||
|
||||
getPiholeSecret = hostname:
|
||||
if hostname == "ziggy"
|
||||
then [config.age.secrets.piholeZiggy.path]
|
||||
else if hostname == "sobotka"
|
||||
then [config.age.secrets.pihole.path]
|
||||
else throw "Unknown hostname: ${hostname}";
|
||||
in {
|
||||
options.server.infra = {
|
||||
podman.enable = lib.mkEnableOption "Enables Podman";
|
||||
gluetun.enable = lib.mkEnableOption "Enables gluetun";
|
||||
};
|
||||
config = lib.mkIf infra.podman.enable {
|
||||
age.secrets = {
|
||||
pihole.file = "${self}/secrets/${config.networking.hostName}Pihole.age";
|
||||
slskd.file = "${self}/secrets/slskd.age";
|
||||
};
|
||||
|
||||
virtualisation = {
|
||||
containers.enable = true;
|
||||
podman.enable = true;
|
||||
};
|
||||
|
||||
networking.firewall = lib.mkIf cfg.pihole.enable {
|
||||
allowedTCPPorts = [
|
||||
53
|
||||
5335
|
||||
];
|
||||
allowedUDPPorts = [
|
||||
53
|
||||
5335
|
||||
];
|
||||
};
|
||||
|
||||
virtualisation.oci-containers.containers = lib.mkMerge [
|
||||
(lib.mkIf infra.gluetun.enable {
|
||||
gluetun = {
|
||||
image = "qmcgaw/gluetun";
|
||||
ports = [
|
||||
"8388:8388"
|
||||
"58846:58846"
|
||||
"8080:8080"
|
||||
"5030:5030"
|
||||
"5031:5031"
|
||||
"50300:50300"
|
||||
];
|
||||
devices = ["/dev/net/tun:/dev/net/tun"];
|
||||
autoStart = true;
|
||||
extraOptions = [
|
||||
"--cap-add=NET_ADMIN"
|
||||
];
|
||||
volumes = ["/var:/gluetun"];
|
||||
environmentFiles = [
|
||||
config.age.secrets.gluetunEnvironment.path
|
||||
];
|
||||
environment = {
|
||||
DEV_MODE = "false";
|
||||
VPN_SERVICE_PROVIDER = "mullvad";
|
||||
VPN_TYPE = "wireguard";
|
||||
SERVER_CITIES = "Stockholm";
|
||||
};
|
||||
};
|
||||
})
|
||||
|
||||
(lib.mkIf cfg.qbittorrent.enable {
|
||||
qbittorrent = {
|
||||
image = "ghcr.io/hotio/qbittorrent:latest";
|
||||
autoStart = true;
|
||||
dependsOn = ["gluetun"];
|
||||
ports = [
|
||||
"8080:8080"
|
||||
"58846:58846"
|
||||
];
|
||||
extraOptions = [
|
||||
"--network=container:gluetun"
|
||||
];
|
||||
volumes = [
|
||||
"/var/lib/qbittorrent:/config:rw"
|
||||
"/mnt/data/media/downloads:/downloads:rw"
|
||||
];
|
||||
environmentFiles = [
|
||||
config.age.secrets.gluetunEnvironment.path
|
||||
];
|
||||
environment = {
|
||||
PUID = "994";
|
||||
PGID = "993";
|
||||
TZ = "Europe/Stockholm";
|
||||
WEBUI_PORT = "${builtins.toString cfg.qbittorrent.port}";
|
||||
};
|
||||
};
|
||||
})
|
||||
|
||||
(lib.mkIf cfg.slskd.enable {
|
||||
slskd = {
|
||||
image = "slskd/slskd:latest";
|
||||
autoStart = true;
|
||||
dependsOn = ["gluetun"];
|
||||
ports = [
|
||||
"5030:5030"
|
||||
"5031:5031"
|
||||
"50300:50300"
|
||||
];
|
||||
extraOptions = [
|
||||
"--network=container:gluetun"
|
||||
];
|
||||
volumes = [
|
||||
"/var/lib/slskd:/app:rw"
|
||||
"/mnt/data/media/downloads:/downloads:rw"
|
||||
];
|
||||
environmentFiles = [
|
||||
config.age.secrets.gluetunEnvironment.path
|
||||
config.age.secrets.slskd.path
|
||||
];
|
||||
environment = {
|
||||
TZ = "Europe/Stockholm";
|
||||
PUID = "981";
|
||||
PGID = "982";
|
||||
SLSKD_REMOTE_CONFIGURATION = "true";
|
||||
SLSKD_REMOTE_FILE_MANAGEMENT = "true";
|
||||
SLSKD_DOWNLOADS_DIR = "/downloads";
|
||||
SLSKD_UMASK = "022";
|
||||
};
|
||||
};
|
||||
})
|
||||
|
||||
(lib.mkIf cfg.pihole.enable {
|
||||
pihole = {
|
||||
autoStart = true;
|
||||
image = "pihole/pihole:2025.08.0";
|
||||
volumes = [
|
||||
"/var/lib/pihole:/etc/pihole/"
|
||||
"/var/lib/dnsmasq.d:/etc/dnsmasq.d/"
|
||||
];
|
||||
environment = {
|
||||
TZ = "Europe/Stockholm";
|
||||
CUSTOM_CACHE_SIZE = "0";
|
||||
WEBTHEME = "default-darker";
|
||||
};
|
||||
environmentFiles = getPiholeSecret config.networking.hostName;
|
||||
ports = [
|
||||
"53:53/tcp"
|
||||
"53:53/udp"
|
||||
"8053:80/tcp"
|
||||
];
|
||||
extraOptions = [
|
||||
"--cap-add=NET_ADMIN"
|
||||
"--cap-add=SYS_NICE"
|
||||
"--cap-add=SYS_TIME"
|
||||
];
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
}
|
||||
6
modules/server/infra/postgres/default.nix
Normal file
6
modules/server/infra/postgres/default.nix
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
imports = [
|
||||
./postgres.nix
|
||||
./postgres-upgrade.nix
|
||||
];
|
||||
}
|
||||
48
modules/server/infra/postgres/postgres-upgrade.nix
Normal file
48
modules/server/infra/postgres/postgres-upgrade.nix
Normal file
@@ -0,0 +1,48 @@
|
||||
# taken from @jtojnar
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}: let
|
||||
inherit (lib) types mkOption;
|
||||
|
||||
cfg = config.server.infra.postgresql;
|
||||
in {
|
||||
options = {
|
||||
server.infra.postgresql = {
|
||||
upgradeTargetPackage = mkOption {
|
||||
type = types.nullOr types.package;
|
||||
default = null;
|
||||
description = "PostgreSQL package that we want to upgrade to. When set, an update script will be installed.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
# https://nixos.org/manual/nixos/unstable/#module-services-postgres-upgrading
|
||||
environment.systemPackages = lib.mkIf (cfg.upgradeTargetPackage != null) [
|
||||
(pkgs.writeScriptBin "upgrade-pg-cluster" ''
|
||||
set -eux
|
||||
# XXX it's perhaps advisable to stop all services that depend on postgresql
|
||||
systemctl stop postgresql
|
||||
|
||||
export NEWDATA="/var/lib/postgresql/${cfg.upgradeTargetPackage.psqlSchema}"
|
||||
|
||||
export NEWBIN="${cfg.upgradeTargetPackage}/bin"
|
||||
|
||||
export OLDDATA="${config.services.postgresql.dataDir}"
|
||||
export OLDBIN="${config.services.postgresql.package}/bin"
|
||||
|
||||
install -d -m 0700 -o postgres -g postgres "$NEWDATA"
|
||||
cd "$NEWDATA"
|
||||
sudo -u postgres $NEWBIN/initdb -D "$NEWDATA"
|
||||
|
||||
sudo -u postgres $NEWBIN/pg_upgrade \
|
||||
--old-datadir "$OLDDATA" --new-datadir "$NEWDATA" \
|
||||
--old-bindir $OLDBIN --new-bindir $NEWBIN \
|
||||
"$@"
|
||||
'')
|
||||
];
|
||||
};
|
||||
}
|
||||
153
modules/server/infra/postgres/postgres.nix
Normal file
153
modules/server/infra/postgres/postgres.nix
Normal file
@@ -0,0 +1,153 @@
|
||||
# taken from @jtojnar
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}: let
|
||||
inherit (lib) types mkOption;
|
||||
|
||||
cfg = config.server.infra.postgresql;
|
||||
|
||||
database = {name, ...}: {
|
||||
options = {
|
||||
database = mkOption {
|
||||
type = types.str;
|
||||
description = "Database name";
|
||||
};
|
||||
|
||||
extraUsers = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [];
|
||||
description = "List of extra users with access to this database.";
|
||||
};
|
||||
|
||||
extensions = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [];
|
||||
description = "List of extensions to install and enable.";
|
||||
};
|
||||
};
|
||||
};
|
||||
in {
|
||||
options = {
|
||||
server.infra.postgresql = {
|
||||
databases = mkOption {
|
||||
type = types.listOf (types.submodule database);
|
||||
default = [];
|
||||
description = "List of databases to set up.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf (cfg.databases != []) {
|
||||
services.postgresql = {
|
||||
enable = true;
|
||||
package = pkgs.postgresql_17;
|
||||
extensions = lib.filter (x: x != null) (
|
||||
lib.concatMap (
|
||||
{extensions, ...}: map (ext: config.services.postgresql.package.pkgs.${ext} or null) extensions
|
||||
)
|
||||
cfg.databases
|
||||
);
|
||||
authentication = lib.mkForce ''
|
||||
local all postgres peer
|
||||
local sameuser all peer
|
||||
|
||||
# local peer access for extra users
|
||||
${lib.concatMapStringsSep "\n" (
|
||||
{
|
||||
database,
|
||||
extraUsers,
|
||||
...
|
||||
}:
|
||||
lib.concatMapStringsSep "\n" (user: "local ${database} ${user} peer") ([database] ++ extraUsers)
|
||||
)
|
||||
cfg.databases}
|
||||
|
||||
# host access (TCP) for databases and their users
|
||||
${lib.concatMapStringsSep "\n" (
|
||||
{
|
||||
database,
|
||||
extraUsers,
|
||||
...
|
||||
}:
|
||||
lib.concatMapStringsSep "\n" (user: ''
|
||||
host ${database} ${user} 127.0.0.1/32 trust
|
||||
host ${database} ${user} ::1/128 trust
|
||||
'') ([database] ++ extraUsers)
|
||||
)
|
||||
cfg.databases}
|
||||
'';
|
||||
ensureUsers = let
|
||||
dbToUsers = {
|
||||
database,
|
||||
extraUsers,
|
||||
...
|
||||
}:
|
||||
# we use same username as dbname
|
||||
[database] ++ extraUsers;
|
||||
in
|
||||
map (name: {inherit name;}) (lib.unique (builtins.concatMap dbToUsers cfg.databases));
|
||||
};
|
||||
|
||||
systemd.services = {
|
||||
postgres-setup = let
|
||||
pgsql = config.services.postgresql;
|
||||
in {
|
||||
after = ["postgresql.service"];
|
||||
wantedBy = ["multi-user.target"];
|
||||
path = [pgsql.package];
|
||||
script =
|
||||
lib.concatMapStringsSep "\n" (
|
||||
{
|
||||
database,
|
||||
extensions,
|
||||
extraUsers,
|
||||
...
|
||||
}: let
|
||||
createExtensionsSql =
|
||||
lib.concatMapStringsSep "; " (
|
||||
ext: ''CREATE EXTENSION IF NOT EXISTS "${ext}"''
|
||||
)
|
||||
extensions;
|
||||
createExtensionsIfAny = lib.optionalString (extensions != []) ''
|
||||
$PSQL -d '${database}' -c '${createExtensionsSql}'
|
||||
'';
|
||||
in ''
|
||||
set -eu
|
||||
|
||||
PSQL="${pkgs.util-linux}/bin/runuser -u ${pgsql.superUser} -- psql --port=${toString pgsql.settings.port} --tuples-only --no-align"
|
||||
|
||||
if ! $PSQL -c "SELECT 1 FROM pg_database WHERE datname = '${database}'" | grep --quiet 1; then
|
||||
$PSQL -c 'CREATE DATABASE "${database}" WITH OWNER = "${database}"'
|
||||
${createExtensionsIfAny}
|
||||
fi
|
||||
${
|
||||
lib.optionalString (extraUsers != [])
|
||||
"$PSQL '${database}' -c '${
|
||||
lib.concatMapStringsSep "\n" (
|
||||
user: "GRANT ALL ON ALL TABLES IN SCHEMA public TO ${user};"
|
||||
)
|
||||
extraUsers
|
||||
}'"
|
||||
}
|
||||
''
|
||||
)
|
||||
cfg.databases;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
};
|
||||
};
|
||||
|
||||
postgresql.serviceConfig = {
|
||||
# Required by PLV8.
|
||||
MemoryDenyWriteExecute = false;
|
||||
SystemCallFilter = [
|
||||
"@pkey"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
26
modules/server/infra/tailscale/default.nix
Normal file
26
modules/server/infra/tailscale/default.nix
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
self,
|
||||
...
|
||||
}:
|
||||
with lib; let
|
||||
cfg = config.server.infra.tailscale;
|
||||
in {
|
||||
options.server.infra.tailscale = {
|
||||
enable = mkEnableOption "Enable tailscale server configuration";
|
||||
};
|
||||
config = mkIf cfg.enable {
|
||||
age.secrets.sobotkaTsAuth.file = "${self}/secrets/sobotkaTsAuth.age";
|
||||
|
||||
services.tailscale = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
useRoutingFeatures = "server";
|
||||
authKeyFile = config.age.secrets.sobotkaTsAuth.path;
|
||||
extraSetFlags = [
|
||||
"--advertise-exit-node"
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
192
modules/server/infra/traefik/default.nix
Normal file
192
modules/server/infra/traefik/default.nix
Normal file
@@ -0,0 +1,192 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
pkgs,
|
||||
self,
|
||||
...
|
||||
}: let
|
||||
inherit (lib) mkEnableOption mkIf types;
|
||||
|
||||
cfg = config.server.infra.traefik;
|
||||
srv = config.server;
|
||||
|
||||
# Generates all Traefik routers from the central service list
|
||||
generateRouters = services:
|
||||
lib.mapAttrs' (
|
||||
name: service: let
|
||||
domain =
|
||||
if service.exposure == "tunnel"
|
||||
then "cnst.dev"
|
||||
else if service.exposure == "tailscale"
|
||||
then "ts.cnst.dev"
|
||||
else srv.domain;
|
||||
in
|
||||
lib.nameValuePair "${service.subdomain}" {
|
||||
entryPoints = ["websecure"];
|
||||
rule = "Host(`${service.subdomain}.${domain}`)";
|
||||
service = service.subdomain;
|
||||
tls.certResolver = "letsencrypt";
|
||||
}
|
||||
) (lib.filterAttrs (name: service: service.enable) services);
|
||||
|
||||
# Generates all Traefik backend services
|
||||
generateServices = services:
|
||||
lib.mapAttrs' (name: service:
|
||||
lib.nameValuePair "${service.subdomain}" {
|
||||
loadBalancer.servers = [{url = "http://localhost:${toString service.port}";}];
|
||||
}) (lib.filterAttrs (name: service: service.enable) services);
|
||||
|
||||
getCloudflareCredentials = hostname:
|
||||
if hostname == "ziggy"
|
||||
then config.age.secrets.cloudflareDnsCredentialsZiggy.path
|
||||
else if hostname == "sobotka"
|
||||
then config.age.secrets.cloudflareDnsCredentials.path
|
||||
else throw "Unknown hostname: ${hostname}";
|
||||
in {
|
||||
options.server.infra.traefik = {
|
||||
enable = mkEnableOption "Enable global Traefik reverse proxy with ACME";
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
age.secrets = {
|
||||
traefikEnv = {
|
||||
file = "${self}/secrets/traefikEnv.age";
|
||||
mode = "640";
|
||||
owner = "traefik";
|
||||
group = "traefik";
|
||||
};
|
||||
crowdsecApi.file = "${self}/secrets/crowdsecApi.age";
|
||||
};
|
||||
|
||||
systemd.services.traefik = {
|
||||
serviceConfig = {
|
||||
EnvironmentFile = [config.age.secrets.traefikEnv.path];
|
||||
};
|
||||
};
|
||||
networking.firewall.allowedTCPPorts = [80 443];
|
||||
|
||||
services = {
|
||||
tailscale.permitCertUid = "traefik";
|
||||
traefik = {
|
||||
enable = true;
|
||||
|
||||
staticConfigOptions = {
|
||||
log = {
|
||||
level = "DEBUG";
|
||||
};
|
||||
|
||||
accesslog = {filepath = "/var/lib/traefik/logs/access.log";};
|
||||
|
||||
tracing = {};
|
||||
api = {
|
||||
dashboard = true;
|
||||
insecure = false;
|
||||
};
|
||||
|
||||
certificatesResolvers = {
|
||||
vpn.tailscale = {};
|
||||
letsencrypt = {
|
||||
acme = {
|
||||
email = "adam@cnst.dev";
|
||||
storage = "/var/lib/traefik/cert.json";
|
||||
dnsChallenge = {
|
||||
provider = "cloudflare";
|
||||
resolvers = [
|
||||
"1.1.1.1:53"
|
||||
"1.0.0.1:53"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
entryPoints = {
|
||||
# redis = {
|
||||
# address = "0.0.0.0:6381";
|
||||
# };
|
||||
# postgres = {
|
||||
# address = "0.0.0.0:5433";
|
||||
# };
|
||||
web = {
|
||||
address = ":80";
|
||||
forwardedHeaders.insecure = true;
|
||||
http.redirections.entryPoint = {
|
||||
to = "websecure";
|
||||
scheme = "https";
|
||||
permanent = true;
|
||||
};
|
||||
# http.middlewares = "crowdsec@file";
|
||||
};
|
||||
|
||||
websecure = {
|
||||
address = ":443";
|
||||
forwardedHeaders.insecure = true;
|
||||
http.tls = {
|
||||
certResolver = "letsencrypt";
|
||||
domains = [
|
||||
{
|
||||
main = "cnix.dev";
|
||||
sans = ["*.cnix.dev"];
|
||||
}
|
||||
{
|
||||
main = "ts.cnst.dev";
|
||||
sans = ["*ts.cnst.dev"];
|
||||
}
|
||||
];
|
||||
};
|
||||
# http.middlewares = "crowdsec@file";
|
||||
};
|
||||
|
||||
experimental = {
|
||||
address = ":1111";
|
||||
forwardedHeaders.insecure = true;
|
||||
};
|
||||
};
|
||||
|
||||
experimental = {
|
||||
# Install the Crowdsec Bouncer plugin
|
||||
plugins = {
|
||||
#enabled = "true";
|
||||
bouncer = {
|
||||
moduleName = "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin";
|
||||
version = "v1.4.5";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
dynamicConfigOptions = {
|
||||
http = {
|
||||
# Generate the services from your central list
|
||||
services = generateServices srv.services;
|
||||
|
||||
# Generate the routers and manually add the special 'api' router
|
||||
routers =
|
||||
(generateRouters srv.services)
|
||||
// {
|
||||
api = {
|
||||
entryPoints = ["websecure"];
|
||||
rule = "Host(`traefik.${srv.domain}`)";
|
||||
service = "api@internal";
|
||||
tls.certResolver = "letsencrypt";
|
||||
};
|
||||
};
|
||||
# middlewares = {
|
||||
# crowdsec = {
|
||||
# plugin = {
|
||||
# bouncer = {
|
||||
# enabled = "true";
|
||||
# logLevel = "DEBUG";
|
||||
# crowdsecLapiKeyFile = config.age.secrets.crowdsecApi.path;
|
||||
# crowdsecMode = "live";
|
||||
# crowdsecLapiHost = ":4223";
|
||||
# };
|
||||
# };
|
||||
# };
|
||||
# };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
116
modules/server/infra/unbound/default.nix
Normal file
116
modules/server/infra/unbound/default.nix
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
config,
|
||||
...
|
||||
}: let
|
||||
unit = "unbound";
|
||||
cfg = config.server.infra.${unit};
|
||||
srv = config.server;
|
||||
|
||||
generateLocalRecords = services:
|
||||
lib.mapAttrsToList (
|
||||
name: service: "local-data: \"${service.subdomain}.${srv.domain}. A ${srv.ip}\""
|
||||
) (lib.filterAttrs (name: service: service.enable) services);
|
||||
|
||||
hostIp = hostname:
|
||||
if hostname == "ziggy"
|
||||
then "192.168.88.12"
|
||||
else if hostname == "sobotka"
|
||||
then "192.168.88.14"
|
||||
else throw "No IP defined for host ${hostname}";
|
||||
in {
|
||||
options.server.infra.${unit} = {
|
||||
enable = lib.mkEnableOption {
|
||||
description = "Enable ${unit}";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
services = {
|
||||
# resolved.enable = lib.mkForce false;
|
||||
unbound = {
|
||||
enable = true;
|
||||
enableRootTrustAnchor = true;
|
||||
localControlSocketPath = "/run/unbound/unbound.ctl";
|
||||
resolveLocalQueries = true;
|
||||
package = pkgs.unbound-full;
|
||||
settings = {
|
||||
server = {
|
||||
access-control = [
|
||||
"127.0.0.0/8 allow"
|
||||
"10.88.0.0/24 allow"
|
||||
"::1 allow"
|
||||
"192.168.88.0/24 allow"
|
||||
];
|
||||
aggressive-nsec = true;
|
||||
cache-max-ttl = 86400;
|
||||
cache-min-ttl = 300;
|
||||
delay-close = 10000;
|
||||
deny-any = true;
|
||||
do-ip4 = true;
|
||||
do-ip6 = true;
|
||||
do-tcp = true;
|
||||
do-udp = true;
|
||||
prefer-ip6 = false;
|
||||
edns-buffer-size = "1232";
|
||||
extended-statistics = true;
|
||||
harden-algo-downgrade = true;
|
||||
harden-below-nxdomain = true;
|
||||
harden-dnssec-stripped = true;
|
||||
harden-glue = true;
|
||||
harden-large-queries = true;
|
||||
harden-short-bufsize = true;
|
||||
infra-cache-slabs = 8;
|
||||
interface = [
|
||||
"127.0.0.1@5335"
|
||||
"${hostIp config.networking.hostName}@5335"
|
||||
"::@5335"
|
||||
];
|
||||
key-cache-slabs = 8;
|
||||
msg-cache-size = "256m";
|
||||
msg-cache-slabs = 8;
|
||||
neg-cache-size = "256m";
|
||||
num-queries-per-thread = 4096;
|
||||
num-threads = 4;
|
||||
outgoing-range = 8192;
|
||||
prefetch = true;
|
||||
prefetch-key = true;
|
||||
qname-minimisation = true;
|
||||
rrset-cache-size = "256m";
|
||||
rrset-cache-slabs = 8;
|
||||
rrset-roundrobin = true;
|
||||
serve-expired = true;
|
||||
so-rcvbuf = "2m";
|
||||
so-reuseport = true;
|
||||
so-sndbuf = "2m";
|
||||
statistics-cumulative = true;
|
||||
statistics-interval = 0;
|
||||
tls-cert-bundle = "/etc/ssl/certs/ca-certificates.crt";
|
||||
unwanted-reply-threshold = 100000;
|
||||
use-caps-for-id = false;
|
||||
verbosity = 1;
|
||||
private-address = [
|
||||
"10.0.0.0/8"
|
||||
"169.254.0.0/16"
|
||||
"172.16.0.0/12"
|
||||
"192.168.0.0/16"
|
||||
"fd00::/8"
|
||||
"fe80::/10"
|
||||
|
||||
"192.0.2.0/24"
|
||||
"198.51.100.0/24"
|
||||
"203.0.113.0/24"
|
||||
"255.255.255.255/32"
|
||||
"2001:db8::/32"
|
||||
];
|
||||
local-data = generateLocalRecords srv.services;
|
||||
local-data-ptr = [
|
||||
"local-data: \"traefik.${srv.domain}. A ${srv.ip}\""
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
135
modules/server/infra/www/default.nix
Normal file
135
modules/server/infra/www/default.nix
Normal file
@@ -0,0 +1,135 @@
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
self,
|
||||
...
|
||||
}: let
|
||||
inherit (lib) mkIf mkEnableOption mkOption types;
|
||||
cfg = config.server.infra.www;
|
||||
in {
|
||||
options.server.infra.www = {
|
||||
enable = mkEnableOption {
|
||||
description = "Enable personal website";
|
||||
};
|
||||
url = mkOption {
|
||||
default = "";
|
||||
type = types.str;
|
||||
description = ''
|
||||
Public domain name to be used to access the server services via Traefik reverse proxy
|
||||
'';
|
||||
};
|
||||
port = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 8283;
|
||||
description = "The port to host webservice on.";
|
||||
};
|
||||
|
||||
cloudflared = {
|
||||
credentialsFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = lib.literalExpression ''
|
||||
pkgs.writeText "cloudflare-credentials.json" '''
|
||||
{"AccountTag":"secret"."TunnelSecret":"secret","TunnelID":"secret"}
|
||||
'''
|
||||
'';
|
||||
};
|
||||
tunnelId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "00000000-0000-0000-0000-000000000000";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
age.secrets = {
|
||||
wwwCloudflared.file = "${self}/secrets/wwwCloudflared.age";
|
||||
};
|
||||
|
||||
server.infra = {
|
||||
fail2ban = {
|
||||
jails = {
|
||||
nginx-404 = {
|
||||
serviceName = "nginx";
|
||||
failRegex = ''^.*\[error\].*directory index of.* is forbidden.*client: <HOST>.*$'';
|
||||
ignoreRegex = '''';
|
||||
maxRetry = 5;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services = {
|
||||
nginx = {
|
||||
enable = true;
|
||||
defaultListen = [
|
||||
{
|
||||
addr = "127.0.0.1";
|
||||
port = 8283;
|
||||
}
|
||||
];
|
||||
virtualHosts."webfinger" = {
|
||||
forceSSL = false;
|
||||
serverName = cfg.url;
|
||||
root = "/var/www/webfinger";
|
||||
|
||||
locations."= /.well-known/webfinger" = {
|
||||
root = "/var/www/webfinger";
|
||||
extraConfig = ''
|
||||
default_type application/jrd+json;
|
||||
try_files /.well-known/webfinger =404;
|
||||
'';
|
||||
};
|
||||
|
||||
locations."= /robots.txt" = {
|
||||
root = "/var/www/webfinger";
|
||||
extraConfig = ''
|
||||
default_type text/plain;
|
||||
try_files /robots.txt =404;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
cloudflared = {
|
||||
enable = true;
|
||||
tunnels.${cfg.cloudflared.tunnelId} = {
|
||||
credentialsFile = cfg.cloudflared.credentialsFile;
|
||||
default = "http_status:404";
|
||||
ingress."${cfg.url}".service = "http://127.0.0.1:8283";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
environment.etc = {
|
||||
"webfinger/.well-known/webfinger".text = ''
|
||||
{
|
||||
"subject": "acct:adam@${cfg.url}",
|
||||
"links": [
|
||||
{
|
||||
"rel": "http://openid.net/specs/connect/1.0/issuer",
|
||||
"href": "https://auth.${cfg.url}/application/o/tailscale/"
|
||||
}
|
||||
]
|
||||
}
|
||||
'';
|
||||
|
||||
"webfinger/robots.txt".text = ''
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
'';
|
||||
};
|
||||
|
||||
services.traefik.dynamicConfigOptions.http = {
|
||||
routers.webfinger = {
|
||||
entryPoints = ["websecure"];
|
||||
rule = "Host(`${cfg.url}`) && Path(`/.well-known/webfinger`)";
|
||||
service = "webfinger";
|
||||
tls.certResolver = "letsencrypt";
|
||||
};
|
||||
|
||||
services.webfinger.loadBalancer.servers = [
|
||||
{url = "http://127.0.0.1:8283";}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user