mirror of
https://codeberg.org/polyteknisk-radiogruppe/the_prg_server_configuration.git
synced 2026-06-13 18:28:55 +02:00
925 lines
32 KiB
Nix
925 lines
32 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" "pelican" "partdb" "mail" "mailadmin" "@" "test" "minecraft"];
|
|
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,pelican.prg-radio.org,mail.prg-radio.org,mailadmin.prg-radio.org,minecraft.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";
|
|
};
|
|
|
|
# Minecraft TCP entrypoint (Minecraft Java Edition default port)
|
|
minecraft = {
|
|
address = "[::]:25565";
|
|
};
|
|
|
|
# Minecraft VoiceChat UDP entrypoint (VoiceChat mod default port)
|
|
minecraft-voice = {
|
|
address = "[::]:24454/udp";
|
|
};
|
|
};
|
|
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"];
|
|
};
|
|
|
|
# Pelican panel - exposes the Pelican panel UI
|
|
pelican = {
|
|
rule = "Host(`pelican.prg-radio.org`)";
|
|
service = "pelican";
|
|
entryPoints = ["websecure"];
|
|
tls = {certresolver = "acme";};
|
|
};
|
|
|
|
# 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"];
|
|
};
|
|
|
|
bluemap = {
|
|
rule = "Host(`minecraft.prg-radio.org`)";
|
|
service = "bluemap";
|
|
entryPoints = ["websecure"];
|
|
tls = {};
|
|
middlewares = ["anubisForwardAuth"];
|
|
};
|
|
};
|
|
|
|
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";}
|
|
];
|
|
};
|
|
|
|
# Pelican panel backend - the wavelog host exposes the container on host port 8070
|
|
pelican.loadBalancer = {
|
|
servers = [
|
|
{url = "http://10.1.1.249:8070";}
|
|
];
|
|
passHostHeader = true;
|
|
};
|
|
|
|
# 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";}];
|
|
};
|
|
|
|
# BlueMap HTTP backend - forward to the Minecraft host webserver on port 8100
|
|
bluemap.loadBalancer = {
|
|
servers = [
|
|
{url = "http://10.1.1.244:8100";}
|
|
];
|
|
};
|
|
};
|
|
|
|
# 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 router for Minecraft (plain TCP on 25565)
|
|
minecraft = {
|
|
# Use catch-all SNI rule because Minecraft clients don't use TLS/SNI
|
|
rule = "HostSNI(`*`)";
|
|
service = "minecraft";
|
|
entryPoints = ["minecraft"];
|
|
};
|
|
};
|
|
|
|
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";}];
|
|
};
|
|
|
|
# TCP service for Minecraft
|
|
minecraft.loadBalancer = {
|
|
servers = [
|
|
{address = "10.1.1.244:25565";}
|
|
];
|
|
};
|
|
};
|
|
|
|
# UDP routing for TeamSpeak voice
|
|
udp.routers = {
|
|
teamspeak-voice = {
|
|
entryPoints = ["teamspeak-voice"];
|
|
service = "teamspeak-voice";
|
|
};
|
|
|
|
# UDP router for Minecraft VoiceChat mod
|
|
minecraft-voice = {
|
|
entryPoints = ["minecraft-voice"];
|
|
service = "minecraft-voice";
|
|
};
|
|
};
|
|
|
|
udp.services = {
|
|
teamspeak-voice.loadBalancer = {
|
|
servers = [
|
|
{address = "10.1.1.248:9987";}
|
|
];
|
|
};
|
|
|
|
minecraft-voice.loadBalancer = {
|
|
servers = [
|
|
{address = "10.1.1.244:24454";}
|
|
];
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
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="" # 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
|
|
echo "Testing service: $service"
|
|
RAW_IP=$(curl -s "$service")
|
|
RAW_IP=$(echo "$RAW_IP" | tr -d '\n' | tr -d '\r' | xargs)
|
|
echo "RAW_IP: '$RAW_IP'"
|
|
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. Raw: '$RAW_IP'"
|
|
fi
|
|
done
|
|
|
|
# If a record name was passed as first argument, use it (wrapper now passes it)
|
|
if [ -n "$1" ]; then
|
|
record_name="$1"
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Using record_name from arg: '$record_name'"
|
|
else
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: No record_name arg provided; using record_name env: '$record_name'"
|
|
fi
|
|
|
|
# If a zone id was passed as second argument, use it (wrapper now passes it)
|
|
if [ -n "$2" ]; then
|
|
zone_identifier="$2"
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Using zone_identifier from arg: '$zone_identifier'"
|
|
else
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: No zone_identifier arg provided; using zone_identifier env: '$zone_identifier'"
|
|
fi
|
|
|
|
# If auth_email/auth_key were passed as the 3rd/4th arguments, use them (wrapper now passes them)
|
|
if [ -n "$3" ]; then
|
|
auth_email="$3"
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Using auth_email from arg: '$auth_email'"
|
|
else
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: No auth_email arg provided; using auth_email env: '$auth_email'"
|
|
fi
|
|
|
|
if [ -n "$4" ]; then
|
|
auth_key="$4"
|
|
# avoid printing the key itself to logs; show length instead
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Using auth_key from arg (length=''${#auth_key})"
|
|
else
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: No auth_key arg provided; using auth_key env (length=''${#auth_key})"
|
|
fi
|
|
|
|
# Fail fast if we still don't have a zone identifier
|
|
if [ -z "$zone_identifier" ]; then
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: zone_identifier is empty — cannot proceed (invalid zone)."
|
|
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
|
|
# Output for debugging
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Using auth_method ''${auth_method} with header ''${auth_header}"
|
|
|
|
|
|
###########################################
|
|
## 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")
|
|
|
|
# Output for debugging
|
|
CURL_EXIT=$?
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Cloudflare GET exit=$CURL_EXIT response=$record"
|
|
|
|
###########################################
|
|
## 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
|
|
# Output for debugging
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Record found for ''${record_name}, checking IP..."
|
|
|
|
|
|
###########################################
|
|
## Get existing IP
|
|
###########################################
|
|
old_ip=$(echo "$record" | ${pkgs.jq}/bin/jq -r '.result[0].content')
|
|
# 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
|
|
# Output for debugging
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Current IP ($CURRENT_IP) is different from old IP ($old_ip) for ''${record_name}, updating record..."
|
|
|
|
|
|
###########################################
|
|
## Set the record identifier from result
|
|
###########################################
|
|
record_identifier=$(echo "$record" | ${pkgs.jq}/bin/jq -r '.result[0].id')
|
|
# Output for debugging
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Record identifier for ''${record_name} is ''${record_identifier}"
|
|
|
|
|
|
###########################################
|
|
## Change the IP@Cloudflare using the API
|
|
###########################################
|
|
update=$(${pkgs.curl}/bin/curl -sS -o - -w '\nHTTP_STATUS:%{http_code}' -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}}" 2>&1)
|
|
CURL_EXIT=$?
|
|
${pkgs.util-linux}/bin/logger -s "DDNS Updater: Cloudflare PATCH exit=$CURL_EXIT response=$update"
|
|
|
|
###########################################
|
|
## 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 [[ $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 [[ $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 [[ $slackuri != "" ]]; then
|
|
${pkgs.curl}/bin/curl -L -X POST "''${slackuri}" --data-raw "{\"channel\":\"''${slackchannel}\",\"text\":\"''${msg}\"}"
|
|
fi
|
|
if [[ $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
|
|
|
|
# Default to Cloudflare proxy enabled for all records, but explicitly
|
|
# disable the proxy for the Minecraft record so clients connect
|
|
# directly to the origin on port 25565 (Cloudflare does not proxy
|
|
# arbitrary Minecraft TCP without Spectrum).
|
|
if [ "$r" = "minecraft" ]; then
|
|
export proxy="false"
|
|
else
|
|
export proxy="true"
|
|
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 "$record_name" "$zone_identifier" "$auth_email" "$auth_key" || 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 25565 24454];
|
|
networking.firewall.allowedUDPPorts = [80 443 9987 9001 3001 993 465 25 25565 24454];
|
|
|
|
system.stateVersion = "25.11";
|
|
};
|
|
}
|