Onboard mx1 mail host and factor out per-host public IPs

- Register mx1 in the inventory and as a direct-SSH `internet` host; give it
  a static public IPv6 (2a01:4ff:2f0:1963::1).
- Point the cnx.email MX (plus SPF/DMARC) at mx1 and add its A record.
- Bring mx1 into monitoring: import exporters, add it to the mesh map and the
  node scrape job so its host metrics and journald reach control.
- Add a clan-mx1 Hetzner firewall: inbound SMTP + ZeroTier + ICMP, no public
  SSH (admin rides the mesh like the other hosts). 587/465/993 held for now.
- Extract per-host public IPv4/IPv6 into modules/hosts.nix, consumed by
  clan.nix's internet hosts and each machine's cnx.staticIPv6, so each address
  is declared once instead of being duplicated across configs.
- docs: add mx1 to the machines table.
This commit is contained in:
Berwn
2026-06-18 11:53:14 +07:00
parent 2c89ab913c
commit 6e4178df04
11 changed files with 94 additions and 23 deletions
+9 -8
View File
@@ -1,4 +1,6 @@
let let
hosts = import ./modules/hosts.nix;
# This clan-core pins the zerotier `allowedIps` interface (admit by network # This clan-core pins the zerotier `allowedIps` interface (admit by network
# IPv6), but node IDs are the stable per-device handle (what `zerotier-cli # IPv6), but node IDs are the stable per-device handle (what `zerotier-cli
# info` prints). Derive a member's IP on THIS network from the controller's # info` prints). Derive a member's IP on THIS network from the controller's
@@ -21,6 +23,7 @@ in
control = { }; control = { };
ns1 = { }; ns1 = { };
ns2 = { }; ns2 = { };
mx1 = { };
}; };
inventory.instances = { inventory.instances = {
@@ -51,14 +54,12 @@ in
}; };
# Direct SSH to public IPs — clan's priority-1 connection path, with the # Direct SSH to public IPs — clan's priority-1 connection path, with the
# ZeroTier mesh and Tor kept as automatic fallbacks. Raw IPs (not the # ZeroTier mesh and Tor kept as automatic fallbacks. Raw IPs (from
# ns1/ns2 DNS names) so reaching these hosts never depends on their own # modules/hosts.nix, not the ns1/ns2 DNS names) so reaching these hosts never
# DNS being up. # depends on their own DNS being up.
internet = { internet.roles.default.machines = builtins.mapAttrs (_: h: {
roles.default.machines.control.settings.host = "77.42.68.181"; settings.host = h.ipv4;
roles.default.machines.ns1.settings.host = "46.224.170.206"; }) hosts;
roles.default.machines.ns2.settings.host = "157.180.70.82";
};
# Recovery root password for console access when a machine fails to boot. # Recovery root password for console access when a machine fails to boot.
emergency-access = { emergency-access = {
+1
View File
@@ -11,6 +11,7 @@ this book is built from `docs/` and served on `control` over the ZeroTier mesh.
| `control` | ZeroTier controller, monitoring, docs | `77.42.68.181` | `2a01:4f9:c013:e6d0::1` | | `control` | ZeroTier controller, monitoring, docs | `77.42.68.181` | `2a01:4f9:c013:e6d0::1` |
| `ns1` | Knot DNS **primary** (master) | `46.224.170.206` | `2a01:4f8:c014:b5c5::1` | | `ns1` | Knot DNS **primary** (master) | `46.224.170.206` | `2a01:4f8:c014:b5c5::1` |
| `ns2` | Knot DNS **secondary** (slave) | `157.180.70.82` | `2a01:4f9:c014:6d87::1` | | `ns2` | Knot DNS **secondary** (slave) | `157.180.70.82` | `2a01:4f9:c014:6d87::1` |
| `mx1` | Mail server (**MX** for cnx.email) | `5.223.65.38` | `2a01:4ff:2f0:1963::1` |
## Access ## Access
+6 -2
View File
@@ -1,3 +1,7 @@
{ config, ... }:
let
hosts = import ../../modules/hosts.nix;
in
{ {
imports = [ imports = [
../../modules/hetzner-firewall.nix ../../modules/hetzner-firewall.nix
@@ -11,10 +15,10 @@
clan.core.sops.defaultGroups = [ "admins" ]; clan.core.sops.defaultGroups = [ "admins" ];
# Public IPv6; SLAAC doesn't bring it up here. # Public IPv6 (from modules/hosts.nix); SLAAC doesn't bring it up here.
cnx.staticIPv6 = { cnx.staticIPv6 = {
enable = true; enable = true;
address = "2a01:4f9:c013:e6d0::1"; address = hosts.${config.networking.hostName}.ipv6;
}; };
time.timeZone = "Etc/GMT-3"; # UTC+3 (fixed offset, no DST) time.timeZone = "Etc/GMT-3"; # UTC+3 (fixed offset, no DST)
+18 -2
View File
@@ -1,7 +1,23 @@
{ config, ... }:
let
hosts = import ../../modules/hosts.nix;
in
{ {
imports = [ imports = [
../../modules/static-ipv6.nix
../../modules/monitoring/exporters.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;
# Mail host backing the cnx.email MX (mx1.cnx.email -> 5.223.65.38).
# SMTP/IMAP services to be configured.
} }
+4 -2
View File
@@ -2,6 +2,7 @@
let let
domains = import ../../modules/dns/domains.nix; domains = import ../../modules/dns/domains.nix;
mesh = import ../../modules/mesh-hosts.nix { inherit config lib; }; mesh = import ../../modules/mesh-hosts.nix { inherit config lib; };
hosts = import ../../modules/hosts.nix;
in in
{ {
imports = [ imports = [
@@ -22,10 +23,11 @@ in
# resolution, so map the control machine name to its ZeroTier mesh address. # resolution, so map the control machine name to its ZeroTier mesh address.
networking.hosts.${mesh.hosts.control} = [ "control" ]; networking.hosts.${mesh.hosts.control} = [ "control" ];
# Public IPv6 (matches the ns1 AAAA glue); SLAAC doesn't bring it up here. # Public IPv6 (from modules/hosts.nix; matches the ns1 AAAA glue); SLAAC
# doesn't bring it up here.
cnx.staticIPv6 = { cnx.staticIPv6 = {
enable = true; enable = true;
address = "2a01:4f8:c014:b5c5::1"; address = hosts.${config.networking.hostName}.ipv6;
}; };
time.timeZone = "Etc/GMT-1"; # UTC+1 (fixed offset, no DST) time.timeZone = "Etc/GMT-1"; # UTC+1 (fixed offset, no DST)
+5 -3
View File
@@ -1,6 +1,7 @@
{ ... }: { config, ... }:
let let
domains = import ../../modules/dns/domains.nix; domains = import ../../modules/dns/domains.nix;
hosts = import ../../modules/hosts.nix;
in in
{ {
imports = [ imports = [
@@ -11,10 +12,11 @@ in
clan.core.sops.defaultGroups = [ "admins" ]; clan.core.sops.defaultGroups = [ "admins" ];
# Public IPv6 (matches the ns2 AAAA glue); SLAAC doesn't bring it up here. # Public IPv6 (from modules/hosts.nix; matches the ns2 AAAA glue); SLAAC
# doesn't bring it up here.
cnx.staticIPv6 = { cnx.staticIPv6 = {
enable = true; enable = true;
address = "2a01:4f9:c014:6d87::1"; address = hosts.${config.networking.hostName}.ipv6;
}; };
time.timeZone = "Etc/GMT-3"; # UTC+3 (fixed offset, no DST) time.timeZone = "Etc/GMT-3"; # UTC+3 (fixed offset, no DST)
+5 -5
View File
@@ -11,8 +11,8 @@ $TTL 3600
@ IN NS ns1.cnx.network. @ IN NS ns1.cnx.network.
@ IN NS ns2.cnx.network. @ IN NS ns2.cnx.network.
; ---- Mail (fill in once the mail host exists) ---- ; ---- Mail ----
;@ IN MX 10 mail.cnx.email. mx1 IN A 5.223.65.38
;mail IN A <mail-ipv4> @ IN MX 10 mx1.cnx.email.
;@ IN TXT "v=spf1 mx -all" @ IN TXT "v=spf1 mx -all"
;_dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@cnx.email" _dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@cnx.email"
+16
View File
@@ -24,6 +24,17 @@ let
description = "ICMP (ping / PMTUD)"; description = "ICMP (ping / PMTUD)";
}; };
# Inbound mail only. mx1 is the MX for cnx.email, so other servers deliver on
# 25. Submission (587/465) and IMAP (993) stay closed until the mail stack and
# mailboxes exist — admin access rides the mesh, same as the other hosts.
smtp = {
direction = "in";
protocol = "tcp";
port = "25";
source_ips = world;
description = "SMTP (inbound mail)";
};
dnsRules = [ dnsRules = [
{ {
direction = "in"; direction = "in";
@@ -50,4 +61,9 @@ in
]; ];
"clan-ns1" = dnsRules; "clan-ns1" = dnsRules;
"clan-ns2" = dnsRules; "clan-ns2" = dnsRules;
"clan-mx1" = [
smtp
zerotier
ping
];
} }
+28
View File
@@ -0,0 +1,28 @@
# Per-host public network facts: single source of truth for each machine's
# public IPv4 and its static public IPv6. Consumed by clan.nix's `internet`
# connection hosts (ipv4) and each machine's `cnx.staticIPv6` (ipv6), so an
# address is written once instead of being duplicated across configs.
#
# NOT a driver for the DNS zone files — those stay hand-edited text, so a record
# here that also appears as A/AAAA glue still needs a matching manual zone edit.
#
# ipv6 is the single address to assign from the host's allocated /64 (we take
# ::1), without prefix length; cnx.staticIPv6 supplies the /64 default.
{
control = {
ipv4 = "77.42.68.181";
ipv6 = "2a01:4f9:c013:e6d0::1";
};
ns1 = {
ipv4 = "46.224.170.206";
ipv6 = "2a01:4f8:c014:b5c5::1";
};
ns2 = {
ipv4 = "157.180.70.82";
ipv6 = "2a01:4f9:c014:6d87::1";
};
mx1 = {
ipv4 = "5.223.65.38";
ipv6 = "2a01:4ff:2f0:1963::1";
};
}
+1 -1
View File
@@ -14,7 +14,7 @@ let
machine: file: machine: file:
builtins.readFile "${dir}/vars/per-machine/${machine}/zerotier/${file}/value"; builtins.readFile "${dir}/vars/per-machine/${machine}/zerotier/${file}/value";
hosts = lib.genAttrs [ "control" "ns1" "ns2" ] (m: readVar m "zerotier-ip"); hosts = lib.genAttrs [ "control" "ns1" "ns2" "mx1" ] (m: readVar m "zerotier-ip");
# RFC 4193 prefix of this ZeroTier network: fd + the 8-byte network id + the # RFC 4193 prefix of this ZeroTier network: fd + the 8-byte network id + the
# 0x9993 marker. The network id is a public var on the controller (control). # 0x9993 marker. The network id is a public var on the controller (control).
+1
View File
@@ -45,6 +45,7 @@ in
(target "control" "127.0.0.1" 9100) (target "control" "127.0.0.1" 9100)
(target "ns1" (v6 mesh.hosts.ns1) 9100) (target "ns1" (v6 mesh.hosts.ns1) 9100)
(target "ns2" (v6 mesh.hosts.ns2) 9100) (target "ns2" (v6 mesh.hosts.ns2) 9100)
(target "mx1" (v6 mesh.hosts.mx1) 9100)
]; ];
} }
{ {