pfSense: LAN-side NAT redirect for mail ports landing on Traefik LB IP

Technitium's split-horizon rewrites *.viktorbarzin.me to 10.0.20.203
(Traefik LB) for the 192.168.1.0/24 Barzini WiFi (TP-Link router has
no hairpin NAT). The rule is name-agnostic so mail.viktorbarzin.me
(and imap./smtp.) get sent to .203 too — where Traefik does not
listen on 25/465/587/993. iOS Mail on Barzini WiFi silently hangs
while Roundcube (port 443 via Traefik) keeps working.

Adds pfSense NAT rdr rules so traffic to 10.0.20.203:{25,465,587,993}
gets redirected to 10.0.20.1 (the mail HAProxy listener already
serving the public path). Loaded on every incoming interface by
pfSense rule generation, so any LAN/VPN client falling into the
split-horizon answer lands on the right service unchanged.

Includes idempotent reproducer script (mirrors the existing
pfsense-haproxy-bootstrap.php pattern) and the networking.md
mail carve-out paragraph plus the stale .200 → .203 reference.
This commit is contained in:
Viktor Barzin 2026-06-02 23:09:07 +00:00
parent ff26d1c957
commit fd35c4f303
2 changed files with 92 additions and 1 deletions

View file

@ -180,11 +180,12 @@ VMs tag traffic on vmbr1 to isolate workloads. pfSense bridges VLAN 20 to the up
**Split Horizon / Hairpin NAT fix (192.168.1.0/24 → *.viktorbarzin.me)**:
- TP-Link router does NOT support hairpin NAT — LAN clients can't reach the public IP (176.12.22.76) for non-proxied domains
- Technitium's Split Horizon `AddressTranslation` post-processor translates `176.12.22.76 → 10.0.20.200` (Traefik LB) in DNS responses for 192.168.1.0/24 clients
- Technitium's Split Horizon `AddressTranslation` post-processor translates `176.12.22.76 → 10.0.20.203` (Traefik LB) in DNS responses for 192.168.1.0/24 clients (was `.200` until 2026-05-30 Traefik dedicated-IP move)
- DNS Rebinding Protection has `viktorbarzin.me` in `privateDomains` to allow the translated private IP
- Only affects non-proxied domains (ha-sofia, immich, headscale, etc.) — Cloudflare-proxied domains resolve to Cloudflare IPs and are unaffected
- Other clients (10.0.x.x, K8s pods) are NOT translated — they reach the public IP via pfSense outbound NAT
- Config synced to all 3 Technitium instances by CronJob `technitium-split-horizon-sync` (every 6h)
- **Mail port carve-out**: the translation sends `mail.viktorbarzin.me` (and `imap.`/`smtp.`) to `.203` too, but Traefik does not serve mail ports. A pfSense NAT rdr rule redirects `10.0.20.203:{25,465,587,993}``10.0.20.1` (mail HAProxy) on any incoming interface, so LAN mail clients land on the right service unchanged. Script: `scripts/pfsense-nat-mail-lan-redirect.php`
**K8s cluster DNS path**:
- CoreDNS forwards `.viktorbarzin.lan` to Technitium ClusterIP (10.96.0.53)

View file

@ -0,0 +1,90 @@
<?php
// pfSense NAT — LAN-side redirect for mail ports landing on the Traefik LB IP.
//
// WHY THIS EXISTS
// Technitium serves Barzini WiFi (192.168.1.0/24) clients a split-horizon
// answer: `mail.viktorbarzin.me CNAME viktorbarzin.me A 10.0.20.203`.
// .203 is Traefik's dedicated LB IP — it serves Roundcube on :443 but does
// NOT listen on mail ports. iOS Mail (which uses 993/465/587) silently
// hangs.
//
// Existing pfSense rules redirect WAN-IP:{25,465,587,993} -> 10.0.20.1
// (pfSense's mail HAProxy listener). But 192.168.1.x clients send to
// 10.0.20.203, not the WAN IP, so those rules don't match.
//
// This script adds 4 NAT rules that match dst=10.0.20.203 on mail ports
// and redirect them to 10.0.20.1 — same target as the public-Internet path.
// Roundcube traffic to :443 stays on Traefik (.203) untouched.
//
// USAGE (on pfSense host, via SSH as admin)
// scp infra/scripts/pfsense-nat-mail-lan-redirect.php admin@10.0.20.1:/tmp/
// ssh admin@10.0.20.1 'php /tmp/pfsense-nat-mail-lan-redirect.php'
//
// IDEMPOTENT — removes prior copies of our rules (by descr prefix) before
// re-adding. Safe to re-run.
require_once('/etc/inc/config.inc');
require_once('/etc/inc/filter.inc');
global $config;
parse_config(true);
$TRAEFIK_LB = '10.0.20.203';
$MAIL_HAPROXY = '10.0.20.1';
$DESCR_PREFIX = 'mail-lan-redirect-';
// One rule per port; protocols match the existing WAN-IP rules so we
// behave identically once the dst is rewritten.
$PORTS = [
['25', 'tcp', 'mail-lan-redirect-25 (SMTP)'],
['465', 'tcp/udp', 'mail-lan-redirect-465 (SMTPS)'],
['587', 'tcp', 'mail-lan-redirect-587 (submission)'],
['993', 'tcp/udp', 'mail-lan-redirect-993 (IMAPS)'],
];
// Strip any prior copies we added (descr starts with our prefix).
$kept = [];
$removed = 0;
foreach (($config['nat']['rule'] ?? []) as $r) {
if (strpos($r['descr'] ?? '', $DESCR_PREFIX) === 0) {
$removed++;
continue;
}
$kept[] = $r;
}
printf("Removed %d prior copies\n", $removed);
// Append new rules.
foreach ($PORTS as [$port, $proto, $descr]) {
$kept[] = [
'source' => ['any' => ''],
'destination' => [
'address' => $TRAEFIK_LB,
'port' => $port,
],
'ipprotocol' => 'inet',
'protocol' => $proto,
'target' => $MAIL_HAPROXY,
'local-port' => $port,
'interface' => 'wan',
'descr' => $descr,
'associated-rule-id'=> 'pass',
'created' => [
'time' => (string)time(),
'username' => 'pfsense-nat-mail-lan-redirect.php',
],
'updated' => [
'time' => (string)time(),
'username' => 'pfsense-nat-mail-lan-redirect.php',
],
];
printf("Added: %s (%s %s:%s -> %s:%s)\n", $descr, $proto, $TRAEFIK_LB, $port, $MAIL_HAPROXY, $port);
}
$config['nat']['rule'] = $kept;
write_config('NAT: LAN-side mail redirects — 10.0.20.203:{25,465,587,993} -> 10.0.20.1 for Barzini WiFi clients');
$rc = filter_configure();
printf("filter_configure rc=%s\n", var_export($rc, true));
echo "Done.\n";