the_prg_server_configuration/nix-system-configs/modules/system/traefik.nix

793 lines
27 KiB
Nix

{
config,
pkgs,
lib,
...
}: let
choose = paths: builtins.head (builtins.filter (p: builtins.pathExists p) paths);
# Domain and Cloudflare DDNS records configured here. Update this list to add/remove records.
domain = "prg-radio.org";
records = ["git" "grafana" "anubis" "wavelog" "partdb" "mail" "mailadmin" "@" "test"];
recordsStr = lib.concatStringsSep " " records;
zoneId = "9fde8d0fa53502f2d1b7e0b1d3765d49";
envFile = "/home/traefikprg/cloudflare/cloudflare.env";
in {
options.local = {
hostname = lib.mkOption {
type = lib.types.str;
default = "nixos-default";
description = "System hostname";
};
username = lib.mkOption {
type = lib.types.str;
default = "user";
description = "Primary user username";
};
userDescription = lib.mkOption {
type = lib.types.str;
default = "NixOS User";
description = "Primary user description";
};
address = lib.mkOption {
type = lib.types.str;
default = "10.1.1.100";
description = "Static IP address";
};
};
imports = [
(choose [./modules/desktop-manager/sway_greetd_homemanager.nix ../desktop-manager/sway_greetd_homemanager.nix])
(choose [./modules/local/hostname_username.nix ../local/hostname_username.nix])
(choose [./modules/local/networking_local.nix ../local/networking_local.nix])
(choose [./modules/bootloader/seabios-assigned-iso-at-birth.nix ../bootloader/seabios-assigned-iso-at-birth.nix])
(choose [./modules/lix-default.nix ../lix-default.nix])
# Optionally: (choose [ ./modules/toolsets/remote_building.nix ../toolsets/remote_building.nix ])
];
config = {
local.hostname = "nixos-traefik";
local.username = "traefikprg";
local.userDescription = "NixOS PRG Traefik Service";
local.address = "10.1.1.250";
# Configure Anubis service
services.anubis = {
instances = {
"" = {
enable = true;
settings = {
# Bind to TCP instead of Unix socket for Docker-style integration
BIND_NETWORK = "tcp";
BIND = "127.0.0.1:8090";
# Empty TARGET for redirect mode
TARGET = " ";
# Configure redirect domains - ALL domains that should be able to redirect back after challenge
REDIRECT_DOMAINS = "prg-radio.org,anubis.prg-radio.org,git.prg-radio.org,lgbtq.prg-radio.org,wavelog.prg-radio.org,partdb.prg-radio.org,grafana.prg-radio.org";
# Public URL for Anubis
PUBLIC_URL = "https://anubis.prg-radio.org";
# Cookie domain for proper scoping
COOKIE_DOMAIN = ".prg-radio.org";
# Difficulty level
DIFFICULTY = 7;
#ALGOTIHM = "slow";
# Optional: serve robots.txt
SERVE_ROBOTS_TXT = true;
# Optional: webmaster email for error pages
WEBMASTER_EMAIL = "dtu.prg@gmail.com";
# Prevents infinite loop of doom:
# See: https://github.com/TecharoHQ/anubis/issues/970
JWT_RESTRICTION_HEADER = "CF-Connecting-IP";
# Metrics on separate port
METRICS_BIND_NETWORK = "tcp";
METRICS_BIND = "127.0.0.1:8091";
SLOG_LEVEL = "DEBUG";
COOKIE_SECURE = true;
COOKIE_SAME_SITE = "None"; # Only if Secure=true and you need cross-site
};
};
};
};
services.loki = {
enable = true;
configuration = {
auth_enabled = false;
server = {
http_listen_port = 3100;
};
ingester = {
lifecycler = {
address = "0.0.0.0";
ring = {
kvstore = {
store = "inmemory";
};
replication_factor = 1;
};
final_sleep = "0s";
};
chunk_idle_period = "1h";
max_chunk_age = "1h";
chunk_target_size = 1048576;
chunk_retain_period = "30s";
};
schema_config = {
configs = [
{
from = "2020-10-24";
store = "boltdb-shipper";
object_store = "filesystem";
schema = "v11";
index = {
prefix = "index_";
period = "24h";
};
}
];
};
storage_config = {
boltdb_shipper = {
active_index_directory = "/var/lib/loki/boltdb-shipper-active";
cache_location = "/var/lib/loki/boltdb-shipper-cache";
cache_ttl = "24h";
};
filesystem = {
directory = "/var/lib/loki/chunks";
};
};
limits_config = {
reject_old_samples = true;
reject_old_samples_max_age = "168h";
# Disable structured metadata/OTLP native ingestion until schema is upgraded to v13+ and tsdb index is used.
allow_structured_metadata = false;
};
table_manager = {
retention_deletes_enabled = false;
retention_period = "0s";
};
};
};
services.alloy = {
enable = true;
};
services.prometheus = {
enable = true;
port = 9001;
exporters.node = {
enabledCollectors = [
"ethtool"
"softirqs"
"systemd"
"tcpstat"
];
enable = true;
port = 9002;
};
globalConfig.scrape_interval = "5s"; # "1m"
scrapeConfigs = [
{
job_name = "traefik";
static_configs = [
{
targets = ["localhost:8080"];
}
];
}
];
};
# See: https://github.com/grafana/alloy/blob/bbe11b0f6a3bd3391108b2d27122c654c535dd8d/docs/sources/set-up/migrate/from-promtail.md
environment.etc."alloy/config.alloy" = {
text = ''
discovery.relabel "metrics_integrations_integrations_traefik" {
targets = [{
__address__ = "localhost:8080",
}]
rule {
target_label = "instance"
replacement = constants.hostname
}
}
prometheus.scrape "metrics_integrations_integrations_traefik" {
targets = discovery.relabel.metrics_integrations_integrations_traefik.output
job_name = "integrations/traefik"
forward_to = [prometheus.remote_write.default.receiver]
}
prometheus.remote_write "default" {
endpoint {
url = "http://127.0.0.1:9001/api/v1/write"
}
}
'';
group = "root";
mode = "0644";
};
services.traefik = {
enable = true;
group = "acme";
staticConfigOptions = {
metrics = {
prometheus = {
addEntryPointsLabels = true;
addRoutersLabels = true;
addServicesLabels = true;
buckets = [0.1 0.3 1.2 5.0];
};
};
entryPoints = {
web = {
# Bind on IPv6 as well as IPv4 by using [::]:80
address = "[::]:80";
asDefault = true;
http.redirections.entrypoint = {
to = "websecure";
scheme = "https";
};
};
websecure = {
# Bind on IPv6 as well as IPv4 by using [::]:443
address = "[::]:443";
asDefault = true;
http.tls = {
domains = [
{
main = "prg-radio.org";
sans = ["*.prg-radio.org"];
}
];
};
};
# Federation entrypoint: external TLS on 8448
federation = {
# Bind on IPv6 as well as IPv4 by using [::]:8448
address = "[::]:8448";
};
# TeamSpeak entry points
teamspeak-voice = {
# UDP entrypoint bind to IPv6 as well
address = "[::]:9987/udp";
};
teamspeak-squery = {
address = "[::]:10022/tcp";
};
teamspeak-data = {
address = "[::]:30033/tcp";
};
# Mail entrypoints: plain SMTP, implicit SMTPS (465) and IMAPS (993)
smtp = {
# plain SMTP (port 25) - TCP passthrough to backend
address = "[::]:25";
};
smtps = {
# implicit TLS SMTP (port 465) - passthrough to backend
address = "[::]:465";
};
imaps = {
# implicit TLS IMAP (port 993) - passthrough to backend
address = "[::]:993";
};
};
log = {
level = "INFO";
filePath = "${config.services.traefik.dataDir}/traefik.log";
format = "json";
};
api.dashboard = true;
api.insecure = true;
};
dynamicConfigOptions = {
tls.certificates = [
{
certFile = "/var/lib/acme/prg-radio.org/cert.pem";
keyFile = "/var/lib/acme/prg-radio.org/key.pem";
}
];
# Traefik middlewares: Anubis forwardAuth
http.middlewares = {
anubisForwardAuth = {
forwardAuth = {
# point at the Anubis check endpoint (the subrequest/UI instance)
address = "http://127.0.0.1:8090/.within.website/x/cmd/anubis/api/check";
};
};
redirect-to-https = {
redirectScheme = {
scheme = "https";
};
};
# Middleware to strip the /management prefix when proxying to the mail admin
mail-strip-management = {
stripPrefix = {prefixes = ["/management"];};
};
};
http.routers = {
# Anubis router (for challenge page)
anubis = {
rule = "Host(`anubis.prg-radio.org`)";
service = "anubis";
entryPoints = ["websecure"];
tls = {
certresolver = "acme";
};
};
# Protected service example: Forgejo (attach Anubis forwardAuth middleware)
forgejo = {
rule = "Host(`git.prg-radio.org`)";
service = "forgejo";
entryPoints = ["websecure"];
tls = {};
middlewares = ["anubisForwardAuth"];
};
# Matrix HTTP router for client requests (Element etc.)
matrix = {
rule = "Host(`lgbtq.prg-radio.org`)";
service = "matrix";
entryPoints = ["websecure"];
tls = {};
};
# Protected service: Wavelog
wavelog = {
rule = "Host(`wavelog.prg-radio.org`)";
service = "wavelog";
entryPoints = ["websecure"];
tls = {};
};
# Protected service: PartDB
partdb = {
rule = "Host(`partdb.prg-radio.org`)";
service = "partdb";
entryPoints = ["websecure"];
tls = {};
middlewares = ["anubisForwardAuth"];
};
# Protected service: Grafana
grafana = {
rule = "Host(`grafana.prg-radio.org`)";
service = "grafana";
entryPoints = ["websecure"];
tls = {};
middlewares = ["anubisForwardAuth"];
};
# Mail HTTP (JMAP / web) - terminate TLS at Traefik and forward to Stalwart JMAP HTTP listener
mail = {
rule = "Host(`mail.prg-radio.org`)";
service = "mail-jmap";
entryPoints = ["websecure"];
tls = {certresolver = "acme";};
};
# Mail web administration UI (Stalwart management) - exposed under /management
mail-webadmin = {
rule = "Host(`mailadmin.prg-radio.org`) && PathPrefix(`/management`)";
service = "mail-webadmin";
entryPoints = ["websecure"];
tls = {certresolver = "acme";};
# strip the /management prefix before proxying to the backend
middlewares = ["mail-strip-management"];
};
};
http.services = {
# Anubis service (challenge UI / redirect endpoint)
anubis.loadBalancer = {
servers = [
{url = "http://127.0.0.1:8090";}
];
};
forgejo.loadBalancer = {
servers = [
{url = "http://10.1.1.4:30000";}
];
};
matrix.loadBalancer = {
servers = [
{url = "http://10.1.1.248:12244";}
];
};
wavelog.loadBalancer = {
servers = [
{url = "http://10.1.1.249:8086";}
];
};
partdb.loadBalancer = {
servers = [
{url = "http://10.1.1.249:8087";}
];
};
grafana.loadBalancer = {
servers = [
{url = "http://10.1.1.10:3000";}
];
};
# Mail JMAP HTTP backend
mail-jmap.loadBalancer = {
servers = [{url = "http://10.1.1.15:8080";}];
};
# Mail webadmin backend (management). Traefik will strip the /management
# prefix and forward the remainder to the management listener on the mail
# server which is bound to port 8081.
mail-webadmin.loadBalancer = {
servers = [{url = "http://10.1.1.15:8081";}];
};
};
# TCP routing for TeamSpeak
tcp.routers = {
teamspeak-squery = {
rule = "HostSNI(`*`)";
service = "teamspeak-squery";
entryPoints = ["teamspeak-squery"];
};
teamspeak-data = {
rule = "HostSNI(`*`)";
service = "teamspeak-data";
entryPoints = ["teamspeak-data"];
};
# Federation TCP router: incoming on :8448 -> Conduit backend
matrix-federation = {
rule = "HostSNI(`*`)";
service = "matrix-federation";
entryPoints = ["federation"];
# Pass TLS through to the backend (Conduit handles TLS on port 12244)
tls = {
passthrough = true;
};
};
# Also accept TLS passthrough on port 443 for the Matrix host so
# other servers that contact :443/_matrix/*
# will be forwarded to Conduit as well.
# I guess I had to declare the rule explicitly here again?
matrix-tls443 = {
# match by SNI to avoid interfering with other HTTPS sites
rule = "HostSNI(`lgbtq.prg-radio.org`)";
service = "matrix-federation";
entryPoints = ["websecure"];
tls = {
passthrough = true;
};
};
# Mail TCP routers: SMTP (25), SMTPS (465 implicit TLS), IMAPS (993 implicit TLS)
mail-smtp = {
# catch-all TCP router for port 25
rule = "HostSNI(`*`)";
service = "mail-smtp";
entryPoints = ["smtp"];
};
mail-smtps = {
# SMTPS implicit TLS - passthrough to backend
rule = "HostSNI(`*`)";
service = "mail-smtps";
entryPoints = ["smtps"];
tls = {passthrough = true;};
};
mail-imaps = {
# IMAPS implicit TLS - passthrough to backend
rule = "HostSNI(`*`)";
service = "mail-imaps";
entryPoints = ["imaps"];
tls = {passthrough = true;};
};
};
tcp.services = {
teamspeak-squery.loadBalancer = {
servers = [
{address = "10.1.1.248:10022";}
];
};
teamspeak-data.loadBalancer = {
servers = [
{address = "10.1.1.248:30033";}
];
};
matrix-federation.loadBalancer = {
servers = [
{address = "10.1.1.248:12244";}
];
};
# Mail TCP services
mail-smtp.loadBalancer = {
proxyProtocol = {version = 2;}; # Add this line
servers = [{address = "10.1.1.15:25";}];
};
mail-smtps.loadBalancer = {
proxyProtocol = {version = 2;}; # Add this line
servers = [{address = "10.1.1.15:465";}];
};
mail-imaps.loadBalancer = {
proxyProtocol = {version = 2;}; # Add this line
servers = [{address = "10.1.1.15:993";}];
};
};
# UDP routing for TeamSpeak voice
udp.routers = {
teamspeak-voice = {
entryPoints = ["teamspeak-voice"];
service = "teamspeak-voice";
};
};
udp.services = {
teamspeak-voice.loadBalancer = {
servers = [
{address = "10.1.1.248:9987";}
];
};
};
};
};
security.acme = {
acceptTerms = true;
defaults.email = "dtu.prg@gmail.com";
certs."prg-radio.org" = {
domain = "*.prg-radio.org";
group = "acme";
dnsProvider = "cloudflare";
environmentFile = "/home/traefikprg/cloudflare/cloudflare.env";
reloadServices = ["traefik.service"];
};
};
environment.systemPackages = with pkgs; [
util-linux
];
# PRG Cloudflare DDNS updater - split into a single-run upstream script and a wrapper that loops records
environment.etc."cloudflare-ddns/update-single.sh" = {
text = ''
#!${pkgs.bash}/bin/bash
## K0p1-Git cloudflare-ddns-updater (packaged copy)
## Upstream: https://github.com/K0p1-Git/cloudflare-ddns-updater
## Author: K0p1-Git
## License: MIT
## change to "bin/sh" when necessary
auth_email="" # The email used to login 'https://dash.cloudflare.com'
auth_method="token" # Set to "global" for Global API Key or "token" for Scoped API Token
auth_key="" # Your API Token or Global API Key
zone_identifier="" # Can be found in the "Overview" tab of your domain
record_name="" # Which record you want to be synced
ttl=3600 # Set the DNS TTL (seconds)
proxy="false" # Set the proxy to true or false
sitename="" # Title of site "Example Site"
slackchannel="" # Slack Channel #example
slackuri="" # URI for Slack WebHook "https://hooks.slack.com/services/xxxxx"
discorduri="" # URI for Discord WebHook "https://discordapp.com/api/webhooks/xxxxx"
###########################################
## Check if we have a public IP
###########################################
REGEX_IPV4="^(0*(1?[0-9]{1,2}|2([0-4][0-9]|5[0-5]))\\.){3}0*(1?[0-9]{1,2}|2([0-4][0-9]|5[0-5]))$"
IP_SERVICES=(
"https://api.ipify.org"
"https://ipv4.icanhazip.com"
"https://ipinfo.io/ip"
)
# Try all the ip services for a valid IPv4 address
for service in $${IP_SERVICES[@]}; do
RAW_IP=$(${pkgs.curl}/bin/curl -s $service)
if [[ $RAW_IP =~ $REGEX_IPV4 ]]; then
CURRENT_IP=$BASH_REMATCH
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Fetched IP $CURRENT_IP"
break
else
${pkgs.util-linux}/bin/logger -s "DDNS Updater: IP service $service failed."
fi
done
# Exit if IP fetching failed
if [[ -z "$${CURRENT_IP}" ]]; then
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Failed to find a valid IP."
exit 2
fi
###########################################
## Check and set the proper auth header
###########################################
if [[ "$${auth_method}" == "global" ]]; then
auth_header="X-Auth-Key:"
else
auth_header="Authorization: Bearer"
fi
###########################################
## Seek for the A record
###########################################
${pkgs.util-linux}/bin/logger "DDNS Updater: Check Initiated"
record=$(${pkgs.curl}/bin/curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$${zone_identifier}/dns_records?type=A&name=$${record_name}" \
-H "X-Auth-Email: $${auth_email}" \
-H "$${auth_header} $${auth_key}" \
-H "Content-Type: application/json")
###########################################
## Check if the domain has an A record
###########################################
if [[ $record == *"\\"count\\":0"* ]]; then
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Record does not exist, perhaps create one first? ($${CURRENT_IP} for $${record_name})"
exit 1
fi
###########################################
## Get existing IP
###########################################
old_ip=$(echo "$record" | sed -E 's/.*"content":"(([0-9]{1,3}\\.){3}[0-9]{1,3})".*/\\1/')
# Compare if they're the same
if [[ $CURRENT_IP == $old_ip ]]; then
${pkgs.util-linux}/bin/logger "DDNS Updater: IP ($CURRENT_IP) for $${record_name} has not changed."
exit 0
fi
###########################################
## Set the record identifier from result
###########################################
record_identifier=$(echo "$record" | sed -E 's/.*"id":"([A-Za-z0-9_]+)".*/\\1/')
###########################################
## Change the IP@Cloudflare using the API
###########################################
update=$(${pkgs.curl}/bin/curl -s -X PATCH "https://api.cloudflare.com/client/v4/zones/$${zone_identifier}/dns_records/$${record_identifier}" \
-H "X-Auth-Email: $${auth_email}" \
-H "$${auth_header} $${auth_key}" \
-H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"$${record_name}\",\"content\":\"$${CURRENT_IP}\",\"ttl\":$${ttl},\"proxied\":$${proxy}}")
###########################################
## Report the status (simplified payload construction to avoid nested-quote issues)
###########################################
case "$update" in
*"\\\"success\\\":false"*)
echo -e "DDNS Updater: $${CURRENT_IP} $${record_name} DDNS failed for $${record_identifier} ($${CURRENT_IP}). DUMPING RESULTS:\n$update" | ${pkgs.util-linux}/bin/logger -s
if [[ -n "$${slackuri}" ]]; then
msg="$${sitename} DDNS Update Failed: $${record_name}: $${record_identifier} ($${CURRENT_IP})."
${pkgs.curl}/bin/curl -L -X POST "$${slackuri}" --data-raw "{\"channel\":\"$${slackchannel}\",\"text\":\"$${msg}\"}"
fi
if [[ -n "$${discorduri}" ]]; then
msg="$${sitename} DDNS Update Failed: $${record_name}: $${record_identifier} ($${CURRENT_IP})."
${pkgs.curl}/bin/curl -i -H "Accept: application/json" -H "Content-Type:application/json" -X POST --data-raw "{\"content\":\"$${msg}\"}" "$${discorduri}"
fi
exit 1;;
*)
msg="$${sitename} Updated: $${record_name}'s new IP Address is $${CURRENT_IP}"
${pkgs.util-linux}/bin/logger "DDNS Updater: $${CURRENT_IP} $${record_name} DDNS updated."
if [[ -n "$${slackuri}" ]]; then
${pkgs.curl}/bin/curl -L -X POST "$${slackuri}" --data-raw "{\"channel\":\"$${slackchannel}\",\"text\":\"$${msg}\"}"
fi
if [[ -n "$${discorduri}" ]]; then
${pkgs.curl}/bin/curl -i -H "Accept: application/json" -H "Content-Type:application/json" -X POST --data-raw "{\"content\":\"$${msg}\"}" "$${discorduri}"
fi
exit 0;;
esac
'';
mode = "0755";
group = "root";
};
environment.etc."cloudflare-ddns/update.sh" = {
text = ''
#!${pkgs.bash}/bin/bash
set -euo pipefail
# Wrapper: source env, map tokens, loop declared records and call the upstream single-run script
if [ -f "${envFile}" ]; then
# shellcheck disable=SC1090
source "${envFile}"
fi
# Map env variables from the env file into auth_key/auth_email used by the upstream script
if [ -n "$${CLOUDFLARE_DNS_API_TOKEN:-}" ]; then
export auth_key="$${CLOUDFLARE_DNS_API_TOKEN:-}"
elif [ -n "$${CLOUDFLARE_API_TOKEN:-}" ]; then
export auth_key="$${CLOUDFLARE_API_TOKEN:-}"
fi
if [ -n "$${CLOUDFLARE_USERNAME:-}" ]; then
export auth_email="$${CLOUDFLARE_USERNAME:-}"
fi
# Ensure zone id is exported for the single-run script
export zone_identifier="${zoneId}"
# Loop records from the Nix list. "@" maps to the base domain
for r in ${recordsStr}; do
if [ "$r" = "@" ]; then
export record_name="${domain}"
else
export record_name="$r.${domain}"
fi
# Invoke the single-run script explicitly with the system's bash via env to avoid /bin/bash shebang issues
${pkgs.bash}/bin/bash /etc/cloudflare-ddns/update-single.sh || true
done
'';
mode = "0755";
group = "root";
};
# Enable Tailscale for remote access to Traefik dashboard and configuration
services.tailscale.enable = true;
systemd.services.traefik = {
after = ["acme-finished-prg-radio.org.target"];
wants = ["acme-finished-prg-radio.org.target"];
};
systemd.services.prg-cloudflare-ddns-updater = {
description = "PRG Cloudflare DDNS updater";
after = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.bash}/bin/bash /etc/cloudflare-ddns/update.sh";
User = "traefikprg";
# Make sure the service doesn't fail the boot if the script exits non-zero
Restart = "no";
};
};
systemd.timers.prg-cloudflare-ddns-updater = {
description = "Run PRG Cloudflare DDNS updater hourly";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "1m"; # run shortly after boot
OnUnitActiveSec = "1h"; # then every hour
AccuracySec = "1m";
};
};
networking.firewall.allowedTCPPorts = [80 443 10022 30033 8448 9001 3001 993 465 25];
networking.firewall.allowedUDPPorts = [80 443 9987 9001 3001 993 465 25];
system.stateVersion = "25.11";
};
}