Add web01 public reverse proxy with DNS-01 wildcard TLS
web01 terminates TLS for grafana.cnx.network and proxies to Grafana on control over the mesh. Caddy serves a *.cnx.network wildcard cert obtained via ACME DNS-01, using a dedicated acme_web01 TSIG key scoped on ns1 to _acme-challenge on the cnx.network zone only. Ports 80/443 are the only public exposure (80 just redirects); admin and the backend ride ZeroTier. Also reload Caddy on cert renewal for both web01 and mx1, since both reference the cert via explicit tls file paths and would otherwise keep serving a stale cert after a silent renewal.
This commit is contained in:
@@ -24,6 +24,7 @@ in
|
||||
ns1 = { };
|
||||
ns2 = { };
|
||||
mx1 = { };
|
||||
web01 = { };
|
||||
};
|
||||
|
||||
inventory.instances = {
|
||||
|
||||
@@ -13,6 +13,7 @@ in
|
||||
imports = [
|
||||
../../modules/dns/authoritative.nix
|
||||
../../modules/dns/acme-mx1-secret.nix
|
||||
../../modules/dns/acme-web01-secret.nix
|
||||
../../modules/static-ipv6.nix
|
||||
../../modules/monitoring/exporters.nix
|
||||
];
|
||||
@@ -74,9 +75,28 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
# ACME DNS-01, dedicated web01 key. A *separate* TSIG key (acme_web01) that only
|
||||
# web01 holds, rendered from the shared secret (generator dns-acme-web01-secret,
|
||||
# imported above). acl_acme_web01 scopes it to TXT updates at _acme-challenge on
|
||||
# the cnx.network zone — the owner the wildcard *.cnx.network challenge uses — so
|
||||
# this credential can write nothing but web01's own cert challenges.
|
||||
clan.core.vars.generators.dns-acme-web01-knot = {
|
||||
files."acme.conf" = {
|
||||
secret = true;
|
||||
owner = "knot";
|
||||
group = "knot";
|
||||
};
|
||||
dependencies = [ "dns-acme-web01-secret" ];
|
||||
script = ''
|
||||
printf 'key:\n - id: acme_web01\n algorithm: hmac-sha256\n secret: %s\n' \
|
||||
"$(cat "$in"/dns-acme-web01-secret/secret)" > "$out"/acme.conf
|
||||
'';
|
||||
};
|
||||
|
||||
services.knot.keyFiles = [
|
||||
config.clan.core.vars.generators.dns-acme-tsig.files."acme.conf".path
|
||||
config.clan.core.vars.generators.dns-acme-mx1-knot.files."acme.conf".path
|
||||
config.clan.core.vars.generators.dns-acme-web01-knot.files."acme.conf".path
|
||||
];
|
||||
|
||||
services.knot.settings.acl = [
|
||||
@@ -102,6 +122,17 @@ in
|
||||
"_acme-challenge.mail"
|
||||
];
|
||||
}
|
||||
{
|
||||
id = "acl_acme_web01";
|
||||
key = "acme_web01";
|
||||
action = [ "update" ];
|
||||
"update-type" = [ "TXT" ];
|
||||
"update-owner" = "name";
|
||||
"update-owner-match" = "sub-or-equal";
|
||||
# Wildcard *.cnx.network places its challenge at _acme-challenge.cnx.network,
|
||||
# i.e. _acme-challenge at the cnx.network apex (where this acl is attached).
|
||||
"update-owner-name" = [ "_acme-challenge" ];
|
||||
}
|
||||
];
|
||||
|
||||
# Automatic DNSSEC signing policy (primary only). ECDSA P-256/SHA-256 with
|
||||
@@ -136,6 +167,7 @@ in
|
||||
"acl_ns2"
|
||||
"acl_acme"
|
||||
]
|
||||
++ lib.optionals (d == "cnx.email") [ "acl_acme_mx1" ];
|
||||
++ lib.optionals (d == "cnx.email") [ "acl_acme_mx1" ]
|
||||
++ lib.optionals (d == "cnx.network") [ "acl_acme_web01" ];
|
||||
}) domains;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
{ config, ... }:
|
||||
let
|
||||
hosts = import ../../modules/hosts.nix;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
|
||||
../../modules/static-ipv6.nix
|
||||
../../modules/monitoring/exporters.nix
|
||||
../../modules/web-proxy.nix
|
||||
];
|
||||
|
||||
# New machine!
|
||||
clan.core.sops.defaultGroups = [ "admins" ];
|
||||
|
||||
# Public IPv6 (from modules/hosts.nix); SLAAC doesn't bring it up here.
|
||||
cnx.staticIPv6 = {
|
||||
enable = true;
|
||||
address = hosts.${config.networking.hostName}.ipv6;
|
||||
};
|
||||
|
||||
services.timesyncd.enable = true;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ---
|
||||
# schema = "single-disk"
|
||||
# [placeholders]
|
||||
# mainDisk = "/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_108706511"
|
||||
# mainDisk = "/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_108706511"
|
||||
# ---
|
||||
# This file was automatically generated!
|
||||
# CHANGING this configuration requires wiping and reinstalling the machine
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Shared TSIG secret for the dedicated acme_web01 key.
|
||||
#
|
||||
# This key lets web01 — and only web01 — write _acme-challenge.cnx.network TXT
|
||||
# records on ns1 to obtain its wildcard (*.cnx.network) TLS cert via ACME DNS-01.
|
||||
# ns1 scopes it with acl_acme_web01 (attached only to the cnx.network zone) so the
|
||||
# credential can touch nothing else. ns1 renders this secret into a Knot key file;
|
||||
# web01 into a lego rfc2136 env file; both must carry the same secret, hence one
|
||||
# shared generator with a per-host renderer that depends on it. Imported by ns1
|
||||
# and (via web-proxy.nix) web01.
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
clan.core.vars.generators.dns-acme-web01-secret = {
|
||||
share = true;
|
||||
files."secret".secret = true;
|
||||
runtimeInputs = [ pkgs.openssl ];
|
||||
# 32 random bytes, base64 — a valid hmac-sha256 TSIG secret.
|
||||
script = ''openssl rand -base64 32 | tr -d '\n' > "$out"/secret'';
|
||||
};
|
||||
}
|
||||
@@ -25,3 +25,10 @@ control IN AAAA fd06:1bad:ece2:92ad:ba99:9306:1bad:ece2
|
||||
;@ IN A <web-ipv4>
|
||||
;www IN CNAME cnx.network.
|
||||
monitor IN A 5.223.66.36
|
||||
|
||||
; ---- web01 (public reverse proxy / TLS termination) ----
|
||||
; Serves a wildcard *.cnx.network TLS cert (ACME DNS-01) and forwards to internal
|
||||
; services over the mesh. Add a vhost in modules/web-proxy.nix and a CNAME here.
|
||||
web01 IN A 5.223.55.246
|
||||
web01 IN AAAA 2a01:4ff:2f0:2d8f::1
|
||||
grafana IN CNAME web01.cnx.network.
|
||||
|
||||
@@ -44,6 +44,26 @@ let
|
||||
(mailPort "443" "MTA-STS policy (HTTPS)")
|
||||
];
|
||||
|
||||
# web01 is a public reverse proxy with TLS termination. 443 serves the proxy;
|
||||
# 80 only carries Caddy's HTTP->HTTPS redirect (the cert uses ACME DNS-01, not
|
||||
# HTTP-01). Admin rides the mesh.
|
||||
webRules = [
|
||||
{
|
||||
direction = "in";
|
||||
protocol = "tcp";
|
||||
port = "80";
|
||||
source_ips = world;
|
||||
description = "HTTP (redirect to HTTPS)";
|
||||
}
|
||||
{
|
||||
direction = "in";
|
||||
protocol = "tcp";
|
||||
port = "443";
|
||||
source_ips = world;
|
||||
description = "HTTPS (reverse proxy / TLS termination)";
|
||||
}
|
||||
];
|
||||
|
||||
dnsRules = [
|
||||
{
|
||||
direction = "in";
|
||||
@@ -74,4 +94,8 @@ in
|
||||
zerotier
|
||||
ping
|
||||
];
|
||||
"clan-web01" = webRules ++ [
|
||||
zerotier
|
||||
ping
|
||||
];
|
||||
}
|
||||
|
||||
@@ -25,4 +25,8 @@
|
||||
ipv4 = "5.223.65.38";
|
||||
ipv6 = "2a01:4ff:2f0:1963::1";
|
||||
};
|
||||
web01 = {
|
||||
ipv4 = "5.223.55.246";
|
||||
ipv6 = "2a01:4ff:2f0:2d8f::1";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,6 +117,10 @@ in
|
||||
# Keep the private key fixed across renewals so the DANE TLSA "3 1 1"
|
||||
# record (public-key digest, published in the zone) stays valid.
|
||||
extraLegoRenewFlags = [ "--reuse-key" ];
|
||||
# Caddy serves the MTA-STS endpoint from explicit cert file paths, so it
|
||||
# won't notice a renewal on its own — reload it whenever the cert changes.
|
||||
# (Merges with the postfix/dovecot reloads SNM wires up for this cert.)
|
||||
reloadServices = [ "caddy.service" ];
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Public reverse proxy with TLS termination for web01. Caddy fronts internal
|
||||
# services and forwards to them over the ZeroTier mesh, never the public net.
|
||||
# The cert is a single wildcard (*.cnx.network) obtained via ACME DNS-01, so
|
||||
# adding a vhost needs no new issuance. Public ports: 443 for the proxy and 80
|
||||
# only for Caddy's HTTP->HTTPS redirect (issuance never uses inbound HTTP).
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
mesh = import ./mesh-hosts.nix { inherit config lib; };
|
||||
hosts = import ./hosts.nix;
|
||||
certName = "cnx.network";
|
||||
in
|
||||
{
|
||||
imports = [ ./dns/acme-web01-secret.nix ];
|
||||
|
||||
# Render the shared acme_web01 TSIG secret into a lego rfc2136 env file. lego
|
||||
# (via security.acme below) uses it to write _acme-challenge.cnx.network TXT
|
||||
# records on ns1, which authorizes the acme_web01 key for exactly that owner.
|
||||
clan.core.vars.generators.dns-acme-web01-rfc2136 = {
|
||||
files."rfc2136.env".secret = true; # root-owned; systemd reads it as root
|
||||
dependencies = [ "dns-acme-web01-secret" ];
|
||||
script = ''
|
||||
printf 'RFC2136_NAMESERVER=${hosts.ns1.ipv4}:53\nRFC2136_TSIG_ALGORITHM=hmac-sha256.\nRFC2136_TSIG_KEY=acme_web01\nRFC2136_TSIG_SECRET=%s\n' \
|
||||
"$(cat "$in"/dns-acme-web01-secret/secret)" > "$out"/rfc2136.env
|
||||
'';
|
||||
};
|
||||
|
||||
security.acme = {
|
||||
acceptTerms = true;
|
||||
defaults.email = "postmaster@cnx.email";
|
||||
# One wildcard cert for every vhost this proxy serves, via DNS-01 (so issuance
|
||||
# never depends on inbound HTTP). Port 80 is open only for Caddy's
|
||||
# HTTP->HTTPS redirect, not for ACME.
|
||||
certs.${certName} = {
|
||||
domain = "*.cnx.network";
|
||||
extraDomainNames = [ "cnx.network" ];
|
||||
dnsProvider = "rfc2136";
|
||||
environmentFile = config.clan.core.vars.generators.dns-acme-web01-rfc2136.files."rfc2136.env".path;
|
||||
# ns1 is the only nameserver that accepts the acme_web01 UPDATE; check
|
||||
# propagation against it directly rather than a public resolver.
|
||||
dnsResolver = "${hosts.ns1.ipv4}:53";
|
||||
# Caddy reads the cert from explicit file paths (tls directive below), so it
|
||||
# won't notice a renewal on its own — reload it whenever the cert changes.
|
||||
reloadServices = [ "caddy.service" ];
|
||||
};
|
||||
};
|
||||
|
||||
# The lego-issued cert is owned group=acme; Caddy needs to read the key.
|
||||
users.users.caddy.extraGroups = [ "acme" ];
|
||||
|
||||
# Reverse proxy. The explicit `tls cert key` points Caddy at the wildcard cert
|
||||
# and disables its automatic ACME, so no extra issuance happens. Backends are
|
||||
# dialed over the mesh by their ZeroTier address (mesh.hosts.<name>).
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
virtualHosts."grafana.cnx.network".extraConfig = ''
|
||||
tls /var/lib/acme/${certName}/cert.pem /var/lib/acme/${certName}/key.pem
|
||||
reverse_proxy http://[${mesh.hosts.control}]:3000
|
||||
'';
|
||||
};
|
||||
|
||||
# 443 serves the proxy; 80 only carries Caddy's automatic HTTP->HTTPS redirect
|
||||
# (the Hetzner cloud firewall also scopes these in
|
||||
# modules/hetzner-firewall-rules.nix). Admin still rides the mesh.
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
80
|
||||
443
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user