This repository has been archived on 2026-04-18. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
server-config/services/caddy/caddy.nix
Simon Gardling ce1c335230
Some checks failed
Build and Deploy / deploy (push) Failing after 31m3s
caddy: wildcard TLS via DNS-01 challenge + ddns-updater for Njalla
Build Caddy with the caddy-dns/njalla plugin to enable DNS-01 ACME
challenges. This issues a single wildcard certificate for
*.sigkill.computer instead of per-subdomain certificates, reducing
Let's Encrypt API calls and certificate management overhead.

Add ddns-updater service (nixpkgs services.ddns-updater) configured
with Njalla provider to automatically update DNS records when the
server's public IP changes.
2026-04-09 19:54:57 -04:00

163 lines
4.4 KiB
Nix

{
config,
service_configs,
pkgs,
lib,
inputs,
...
}:
let
theme = pkgs.fetchFromGitHub {
owner = "kaiiiz";
repo = "hugo-theme-monochrome";
rev = "d17e05715e91f41a842f2656e6bdd70cba73de91";
sha256 = "h9I2ukugVrldIC3SXefS0L3R245oa+TuRChOCJJgF24=";
};
hugo-neko = pkgs.fetchFromGitHub {
owner = "ystepanoff";
repo = "hugo-neko";
rev = "5a50034acbb1ae0cec19775af64e7167ca22725e";
sha256 = "VLwr4zEeFQU/b+vj0XTLSuEiosuNFu2du4uud7m8bnw=";
};
hugoWebsite = pkgs.stdenv.mkDerivation {
pname = "hugo-site";
version = "0.1";
src = inputs.website;
nativeBuildInputs = with pkgs; [
hugo
go
git
];
installPhase = ''
rm -fr themes/theme modules/hugo-neko
cp -r ${theme} themes/theme
cp -r ${hugo-neko} modules/hugo-neko
hugo --minify -d $out;
'';
};
newDomain = service_configs.https.domain;
oldDomain = service_configs.https.old_domain;
in
{
imports = [
(lib.serviceMountWithZpool "caddy" service_configs.zpool_ssds [
config.services.caddy.dataDir
])
];
services.caddy = {
enable = true;
email = "titaniumtown@proton.me";
# Build with Njalla DNS provider for DNS-01 ACME challenges (wildcard certs)
package = pkgs.caddy.withPlugins {
plugins = [ "github.com/caddy-dns/njalla@v0.0.0-20250823094507-f709141f1fe6" ];
hash = "sha256-rrOAR6noTDpV/I/hZXxhz0OXVJKu0mFQRq87RUrpmzw=";
};
globalConfig = ''
# Wildcard cert for *.${newDomain} via DNS-01 challenge
acme_dns njalla {
api_token {env.NJALLA_API_TOKEN}
}
# On-demand TLS for old domain redirects
on_demand_tls {
ask http://localhost:9123/check
}
'';
# Internal endpoint to validate on-demand TLS requests
# Only allows certs for *.${oldDomain}
extraConfig = ''
http://localhost:9123 {
@allowed expression {query.domain}.endsWith(".${oldDomain}") || {query.domain} == "${oldDomain}" || {query.domain} == "www.${oldDomain}"
respond @allowed 200
respond 403
}
'';
virtualHosts = {
${newDomain} = {
extraConfig = ''
root * ${hugoWebsite}
file_server browse
'';
serverAliases = [ "www.${newDomain}" ];
};
# Redirect old domain (bare + www) to new domain
${oldDomain} = {
extraConfig = ''
redir https://${newDomain}{uri} permanent
'';
serverAliases = [ "www.${oldDomain}" ];
};
# Wildcard redirect for all old domain subdomains
# Uses on-demand TLS - certs issued automatically on first request
"*.${oldDomain}" = {
extraConfig = ''
tls {
on_demand
}
# {labels.2} extracts subdomain from *.gardling.com
redir https://{labels.2}.${newDomain}{uri} permanent
'';
};
};
};
# Inject Njalla API token for DNS-01 challenge
systemd.services.caddy.serviceConfig.EnvironmentFile = config.age.secrets.njalla-api-token-env.path;
systemd.tmpfiles.rules = [
"d ${config.services.caddy.dataDir} 700 ${config.services.caddy.user} ${config.services.caddy.group}"
];
systemd.packages = with pkgs; [ nssTools ];
networking.firewall.allowedTCPPorts = [
service_configs.ports.public.https.port
# http (but really acmeCA challenges)
service_configs.ports.public.http.port
];
networking.firewall.allowedUDPPorts = [
service_configs.ports.public.https.port
];
# Protect Caddy basic auth endpoints from brute force attacks
services.fail2ban.jails.caddy-auth = {
enabled = true;
settings = {
backend = "auto";
port = "http,https";
logpath = "/var/log/caddy/access-*.log";
# defaults: maxretry=5, findtime=10m, bantime=10m
# Ignore local network IPs - NAT hairpinning causes all LAN traffic to
# appear from the router IP (192.168.1.1). Banning it blocks all internal access.
ignoreip = "127.0.0.1/8 ::1 192.168.1.0/24";
};
filter.Definition = {
# Only match 401s where an Authorization header was actually sent.
# Without this, the normal HTTP Basic Auth challenge-response flow
# (browser probes without credentials, gets 401, then resends with
# credentials) counts every page visit as a "failure."
failregex = ''^.*"remote_ip":"<HOST>".*"Authorization":\["REDACTED"\].*"status":401.*$'';
ignoreregex = "";
datepattern = ''"ts":{Epoch}\.'';
};
};
}