Compare commits
10 Commits
91578a2b43
...
95b9375324
| Author | SHA1 | Date | |
|---|---|---|---|
| 95b9375324 | |||
| 70cbfe84b1 | |||
| a3482face5 | |||
| 8330eaa8ce | |||
| dc51cfbdb5 | |||
| 5864054b00 | |||
| 344f432640 | |||
| dbb67dbd9c | |||
| 2506b21ffa | |||
| 306a2cf61e |
@@ -15,6 +15,7 @@
|
||||
roles.default.tags.all = { };
|
||||
roles.default.settings.allowedKeys = {
|
||||
"berwn" = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIENAjhGQGraQoAjJzsomKP8GAmQPeGL1rNRNHgRcLqtT";
|
||||
"kurogeek" = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEcZ/p1Ofa9liwIzPWzNtONhJ7+FUWd2lCz33r81t8+w kurogeek@kurogeek";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,6 +27,11 @@
|
||||
tor = {
|
||||
roles.server.tags.nixos = { };
|
||||
};
|
||||
|
||||
# Recovery root password for console access when a machine fails to boot.
|
||||
emergency-access = {
|
||||
roles.default.tags.nixos = { };
|
||||
};
|
||||
};
|
||||
|
||||
machines = {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
{
|
||||
imports = [
|
||||
|
||||
../../modules/hetzner-firewall.nix
|
||||
];
|
||||
|
||||
# New machine!
|
||||
time.timeZone = "Etc/GMT-3"; # UTC+3 (fixed offset, no DST)
|
||||
services.timesyncd.enable = true;
|
||||
|
||||
# Public Hetzner Cloud firewalls, synced from this config on every deploy.
|
||||
# Rules live in their own data file; see that file for the no-public-SSH note.
|
||||
cnx.hetznerFirewall = {
|
||||
enable = true;
|
||||
firewalls = import ../../modules/hetzner-firewall-rules.nix;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{ ... }:
|
||||
{ config, pkgs, ... }:
|
||||
let
|
||||
domains = import ../../modules/dns/domains.nix;
|
||||
in
|
||||
@@ -7,14 +7,67 @@ in
|
||||
../../modules/dns/authoritative.nix
|
||||
];
|
||||
|
||||
# ns1 = primary (master): holds each master zone file, notifies ns2 and
|
||||
# allows it to pull the zone via AXFR/IXFR.
|
||||
time.timeZone = "Etc/GMT-1"; # UTC+1 (fixed offset, no DST)
|
||||
services.timesyncd.enable = true;
|
||||
|
||||
# ACME DNS-01 (RFC 2136): a dedicated TSIG key, scoped to ns1 only, that an
|
||||
# external ACME client uses to write _acme-challenge TXT records. acl_acme
|
||||
# (referenced by each zone below) limits the key to TXT updates at or under
|
||||
# _acme-challenge.<zone>; Knot then signs the record and transfers it to ns2,
|
||||
# which never needs this key. Retrieve the secret for the client with:
|
||||
# clan vars get ns1 dns-acme-tsig/acme.conf
|
||||
clan.core.vars.generators.dns-acme-tsig = {
|
||||
files."acme.conf" = {
|
||||
secret = true;
|
||||
owner = "knot";
|
||||
group = "knot";
|
||||
};
|
||||
runtimeInputs = [ pkgs.knot-dns ];
|
||||
script = ''
|
||||
keymgr -t acme_ddns hmac-sha256 > "$out"/acme.conf
|
||||
'';
|
||||
};
|
||||
|
||||
services.knot.keyFiles = [
|
||||
config.clan.core.vars.generators.dns-acme-tsig.files."acme.conf".path
|
||||
];
|
||||
|
||||
services.knot.settings.acl = [
|
||||
{
|
||||
id = "acl_acme";
|
||||
key = "acme_ddns";
|
||||
action = [ "update" ];
|
||||
"update-type" = [ "TXT" ];
|
||||
"update-owner" = "name";
|
||||
"update-owner-match" = "sub-or-equal";
|
||||
"update-owner-name" = [ "_acme-challenge" ];
|
||||
}
|
||||
];
|
||||
|
||||
# Automatic DNSSEC signing policy (primary only). ECDSA P-256/SHA-256 with
|
||||
# Knot's default key management: the ZSK auto-rolls and the KSK is kept stable,
|
||||
# so the DS at the registrar only changes on a manual KSK rollover.
|
||||
services.knot.settings.policy = [
|
||||
{
|
||||
id = "cnx";
|
||||
algorithm = "ecdsap256sha256";
|
||||
}
|
||||
];
|
||||
|
||||
# ns1 = primary (master): loads each zone from its file and serves it to ns2.
|
||||
# zonefile-load = difference-no-serial lets us edit records without touching the
|
||||
# SOA serial; Knot diffs the file, assigns a date-based serial, signs the zone,
|
||||
# then notifies ns2 and lets it pull the signed zone via AXFR/IXFR.
|
||||
services.knot.settings.zone = map (d: {
|
||||
domain = d;
|
||||
file = ../../modules/dns/zones + "/${d}.zone";
|
||||
"zonefile-load" = "whole";
|
||||
"zonefile-load" = "difference-no-serial";
|
||||
"zonefile-sync" = "-1";
|
||||
"journal-content" = "all"; # required by difference-no-serial; holds the live signed zone
|
||||
"serial-policy" = "dateserial";
|
||||
"dnssec-signing" = true;
|
||||
"dnssec-policy" = "cnx";
|
||||
notify = [ "ns2" ];
|
||||
acl = [ "acl_ns2" ];
|
||||
acl = [ "acl_ns2" "acl_acme" ]; # ns2 transfers; acme_ddns key does DNS-01 updates
|
||||
}) domains;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ in
|
||||
../../modules/dns/authoritative.nix
|
||||
];
|
||||
|
||||
time.timeZone = "Etc/GMT-3"; # UTC+3 (fixed offset, no DST)
|
||||
services.timesyncd.enable = true;
|
||||
|
||||
# ns2 = secondary (slave): pulls every zone from ns1 and accepts its NOTIFY.
|
||||
services.knot.settings.zone = map (d: {
|
||||
domain = d;
|
||||
|
||||
@@ -2,7 +2,7 @@ $ORIGIN buildfor.life.
|
||||
$TTL 3600
|
||||
|
||||
@ IN SOA ns1.cnx.network. hostmaster.cnx.network. (
|
||||
2026061401 ; serial (bump on every edit: YYYYMMDDnn)
|
||||
2026061401 ; serial (ignored: Knot auto-assigns a dateserial on signing)
|
||||
3600 ; refresh
|
||||
900 ; retry
|
||||
604800 ; expire
|
||||
|
||||
@@ -2,7 +2,7 @@ $ORIGIN cnx.email.
|
||||
$TTL 3600
|
||||
|
||||
@ IN SOA ns1.cnx.network. hostmaster.cnx.network. (
|
||||
2026061401 ; serial (bump on every edit: YYYYMMDDnn)
|
||||
2026061401 ; serial (ignored: Knot auto-assigns a dateserial on signing)
|
||||
3600 ; refresh
|
||||
900 ; retry
|
||||
604800 ; expire
|
||||
|
||||
@@ -2,7 +2,7 @@ $ORIGIN cnx.network.
|
||||
$TTL 3600
|
||||
|
||||
@ IN SOA ns1.cnx.network. hostmaster.cnx.network. (
|
||||
2026061402 ; serial (bump on every edit: YYYYMMDDnn)
|
||||
2026061402 ; serial (ignored: Knot auto-assigns a dateserial on signing)
|
||||
3600 ; refresh
|
||||
900 ; retry
|
||||
604800 ; expire
|
||||
@@ -14,9 +14,9 @@ $TTL 3600
|
||||
|
||||
; ---- Glue for the nameservers ----
|
||||
ns1 IN A 46.224.170.206
|
||||
ns1 IN AAAA fd06:1bad:ece2:92ad:ba99:939d:766d:8974
|
||||
ns1 IN AAAA 2a01:4f8:c014:b5c5::1
|
||||
ns2 IN A 157.180.70.82
|
||||
ns2 IN AAAA fd06:1bad:ece2:92ad:ba99:9323:61be:a09e
|
||||
ns2 IN AAAA 2a01:4f9:c014:6d87::1
|
||||
|
||||
; ---- control (ZeroTier controller) ----
|
||||
control IN AAAA fd06:1bad:ece2:92ad:ba99:9306:1bad:ece2
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Hetzner Cloud firewall rules, keyed by firewall name. Imported by
|
||||
# machines/control/configuration.nix and fed to cnx.hetznerFirewall.firewalls.
|
||||
#
|
||||
# Public SSH (22) is intentionally absent: admin access rides the ZeroTier mesh
|
||||
# (inside UDP 9993), with emergency-access as the console fallback.
|
||||
let
|
||||
world = [ "0.0.0.0/0" "::/0" ];
|
||||
|
||||
zerotier = {
|
||||
direction = "in";
|
||||
protocol = "udp";
|
||||
port = "9993";
|
||||
source_ips = world;
|
||||
description = "ZeroTier";
|
||||
};
|
||||
|
||||
ping = {
|
||||
direction = "in";
|
||||
protocol = "icmp";
|
||||
source_ips = world;
|
||||
description = "ICMP (ping / PMTUD)";
|
||||
};
|
||||
|
||||
dnsRules = [
|
||||
{ direction = "in"; protocol = "udp"; port = "53"; source_ips = world; description = "DNS (UDP)"; }
|
||||
{ direction = "in"; protocol = "tcp"; port = "53"; source_ips = world; description = "DNS (TCP)"; }
|
||||
zerotier
|
||||
ping
|
||||
];
|
||||
in
|
||||
{
|
||||
"clan-control" = [ zerotier ping ];
|
||||
"clan-ns1" = dnsRules;
|
||||
"clan-ns2" = dnsRules;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.cnx.hetznerFirewall;
|
||||
in
|
||||
{
|
||||
options.cnx.hetznerFirewall = {
|
||||
enable = lib.mkEnableOption "Hetzner Cloud firewall rule sync";
|
||||
|
||||
firewalls = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.listOf (lib.types.attrsOf lib.types.anything));
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
"clan-control" = [
|
||||
{ direction = "in"; protocol = "udp"; port = "9993"; source_ips = [ "0.0.0.0/0" "::/0" ]; }
|
||||
];
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Map of Hetzner Cloud firewall name to its full list of inbound rule
|
||||
objects, written with the Hetzner API field names (direction, protocol,
|
||||
port, source_ips, description). The entire rule set is replaced on each
|
||||
sync, so this is the single source of truth. A firewall that does not
|
||||
exist yet is created (unapplied); applying it to servers is done out of
|
||||
band.
|
||||
'';
|
||||
};
|
||||
|
||||
tokenFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = config.clan.core.vars.generators.hetzner-firewall.files.token.path;
|
||||
defaultText = lib.literalExpression
|
||||
"config.clan.core.vars.generators.hetzner-firewall.files.token.path";
|
||||
description = "File holding the Hetzner Cloud API token (Read & Write).";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
clan.core.vars.generators.hetzner-firewall = {
|
||||
prompts.token = {
|
||||
description = "Hetzner Cloud API token (Read & Write)";
|
||||
type = "hidden";
|
||||
persist = true;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.hetzner-firewall-sync = {
|
||||
description = "Sync Hetzner Cloud firewall rules from Nix config";
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
path = [ pkgs.curl pkgs.jq pkgs.coreutils ];
|
||||
environment.SSL_CERT_FILE = "/etc/ssl/certs/ca-certificates.crt";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
LoadCredential = "token:${cfg.tokenFile}";
|
||||
DynamicUser = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
NoNewPrivileges = true;
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
api="https://api.hetzner.cloud/v1"
|
||||
|
||||
# Keep the bearer token out of process argv: read it from the systemd
|
||||
# credential and stash it in a private (PrivateTmp) 0600 header file.
|
||||
hdr="$(mktemp)"
|
||||
printf 'Authorization: Bearer %s\n' "$(cat "$CREDENTIALS_DIRECTORY/token")" > "$hdr"
|
||||
hapi() {
|
||||
curl -fsS -H @"$hdr" -H "Content-Type: application/json" "$@"
|
||||
}
|
||||
|
||||
${lib.concatStringsSep "\n" (lib.mapAttrsToList (fwName: rules: ''
|
||||
name=${lib.escapeShellArg fwName}
|
||||
rules=${lib.escapeShellArg (builtins.toJSON rules)}
|
||||
id="$(hapi "$api/firewalls?name=$name" | jq -r '.firewalls[0].id // empty')"
|
||||
if [ -z "$id" ]; then
|
||||
echo "hetzner-firewall: creating $name"
|
||||
jq -n --arg name "$name" --argjson rules "$rules" '{name: $name, rules: $rules}' \
|
||||
| hapi -X POST --data-binary @- "$api/firewalls" > /dev/null
|
||||
else
|
||||
echo "hetzner-firewall: setting rules on $name (id $id)"
|
||||
jq -n --argjson rules "$rules" '{rules: $rules}' \
|
||||
| hapi -X POST --data-binary @- "$api/firewalls/$id/actions/set_rules" > /dev/null
|
||||
fi
|
||||
'') cfg.firewalls)}
|
||||
'';
|
||||
};
|
||||
|
||||
# Re-apply on every `clan machines update` (activation), not just at boot or
|
||||
# when the rule set changes. --no-block keeps it from blocking the switch;
|
||||
# a failed sync is logged to the journal but never fails the deploy.
|
||||
system.activationScripts.hetznerFirewallSync = ''
|
||||
${config.systemd.package}/bin/systemctl start --no-block hetzner-firewall-sync.service || true
|
||||
'';
|
||||
};
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"publickey": "age1hlzrpqqgndcthq5m5yj9egfgyet2fzrxwa6ynjzwx2r22uy6m3hqr3rd06",
|
||||
"type": "age"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../sops/machines/control
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:s27JIwgmLIHbkY9d4OdIGoohdX5ZV9J5U8MxOwJaDYXMiH8bg2knNuR8Hr6wpFtn677Y6TF+5MZ5Zou8QuApqw==,iv:lyPpJWSeyEM1R+EcQnCTEHJCIejOLLnyRoGa0XYKFdk=,tag:OojYwOEIvZa98qVlRLRj6g==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"recipient": "age1env5y2eey0p3dggs8tydwmtyhtwqylpg7spuququ4vvax6arzp2qjnm4tx",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBLUUJzTE9zemJIVlJ3aW83\ncjJPdXlyVUNUOTB1L29OWWFaK0JZazZYY0NjCngzb0J1OVE5dDZuWmFIRnd0WG1l\nSEx2c2lmYklBOVBzYzJGWHkxVTlMSFkKLS0tIHdmZEZUQjRJMUhubFpIYThwRlNF\nWjZQMzU0b0VzTEMzSXFIcW5GeVcwR3MKcTEEpaf7FzzekVOilBqK7js5wqrPeAx8\nt7bwVlo2/qOZm0N/gew+H9E7G0D/O02V/OdWfDOkWrZNWH5VcQlTZg==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||
},
|
||||
{
|
||||
"recipient": "age1yubikey1qtc8v5yqy3g5sh4trwwdp7elmavvkvkvzc4tfdnv2g8wyd8y5lc064mpv34",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IEZXQ2lTZyBBNkFSeVRM\nVkVWOU9pUS8yK09yQWRBbWh1MXI5M0gyWEVyaG9QR3JCQnA3UQpyem5pYkQvdWdt\nYy9kaTBQd2tqZGZ1bUhVdTJIajJmUkNYNU9ZU2kzT0JFCi0tLSB3UUEvMzJvdXc0\nWWhMb2dqQ1lqMXpEaUZHeUMwWkZZQW9tZld1b2x1ZFRZCgWvS0oFBZYd8DZmfFUF\n+yqFGppcKoIEaJPn1vQ39LvLaubc53YPJQb9wODLejxc1H9iPE/CMQhDU1W5Ccae\nooI=\n-----END AGE ENCRYPTED FILE-----\n"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-06-14T08:37:25Z",
|
||||
"mac": "ENC[AES256_GCM,data:P/XCNw+t81rsNdtiYgAtcFwF2pdKgmpqw+CjTLLacJvyAsN54WOLhPeBgow3vfZEHkgipHSR+IdRIOROe/eT8q5Aatqodq204IgRcY2MwsoriuZYOMXIgoC0jmPWjpL2CRKyNZLSuAcpsCFsqfT+KYMq5iUTH2MIsibZAOj9vow=,iv:BA6GNzzVlDAV/Fi+Me722oFPustMsFVXFUr7NuQKGkg=,tag:cHi0eck6B23BW+/Uy7dcCg==,type:str]",
|
||||
"version": "3.12.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../sops/users/berwn
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../sops/machines/ns1
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:qscfDPjVE3jrkW1N8iAYwZgErzq6BO83UxKw1+ORoeFtSLVxbu+1iVJG9akWI8wVTyNOq+3JEBpIaATFETmcwW+sob4i9DWynPwLs1oaB+mD+tWXxmi4vwmdz/35QIjXXqQB8d976b9ntzWY3Uu1k59xFMwToPU57sZ1UcbV6+nbqJ6JF6bvT3meBTjU9fgv51/NyLgDpPfWo/SWfPGbrXnOwDBJUt2YgK/8hWGZWWs=,iv:kp9eGbypc2suOZqawNjRcUfUMOfepFIo1XSCJ+AdPOc=,tag:DXygUwvpOiQ9M00mB/O8tg==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"recipient": "age1fanu282vm7njjweqhrpcfcwpttuhce8js4tsyfry98l0neaqpewqs5s7nt",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBLUk4wT2VoMGJEa1lZaURT\nc3ZxYitraFNzUm1FbUpabGNLUHFnUHhHalhjCjJJakpNMzI3NTBaRmZRRE5FdWxS\nbzRsVyttRnB5bEl3TjV0NjZLcGNTeE0KLS0tIEZaQUEwZ3Q1eEsrSXU4OFozYXpC\nTUhpRWZlRFdMRGtRZ212WUNxS3pZTmsKVhvr/8sMRdze0m23f21OF+tlHBHCFk9b\nBsx92OEfOFLvwS17MjwXHWQNcun17/ed1W1eDuDkRtM9TS//xwkH9A==\n-----END AGE ENCRYPTED FILE-----\n"
|
||||
},
|
||||
{
|
||||
"recipient": "age1yubikey1qtc8v5yqy3g5sh4trwwdp7elmavvkvkvzc4tfdnv2g8wyd8y5lc064mpv34",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHBpdi1wMjU2IEZXQ2lTZyBBMHBmQk5F\naEo3N3psTERJM2pjOFdnczd4MkdmYThtL0hrU1ozbWtBQmVZUQpHQSs2ZmcvSE5z\nQk9UaXFIc2s3bnVZdndDMkppOGxvQXRlNEZkK1hwSkE4Ci0tLSBicWRhWWRVZlV6\na1hPbm15UkJ0SkVoY1hKTkFocjFkV3ZBcytMU2VQb3hVCrrAI4cIFTaUVoyDfrrl\n7rmmSUwirg3oRUoHWXT87hXq2gwTcnVnzDMIQbzMCKay8owQtn8YdCO39F7KuyGn\n2Jw=\n-----END AGE ENCRYPTED FILE-----\n"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-06-14T10:07:17Z",
|
||||
"mac": "ENC[AES256_GCM,data:2Rea0Lb2IeXFnZ6BmsDJPV6mFNUSXHdBM2ruxV/HHsxAW1kwn0SGc6GFfZfJmb4MnRyi7O1WIaioYyqkmqE6x9bUSnNcifKpJiTV/9zOE4GntCyNV34t14T8MTMPT9/p7ZOhJ+YlqRjxyTos4aejZULJ+e7t1wNK+IssIwFhy3Q=,iv:x61jf8V2j4znZ+1jdfJ5B/qM1/Z7W/gNjnRH+wERpvs=,tag:QaFo8fBgrghOdTN4G5n5qA==,type:str]",
|
||||
"version": "3.12.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../sops/users/berwn
|
||||
Reference in New Issue
Block a user