2026-10-08_mail-front-proxy-oci-micro

Mail Front Proxy — public MX front, Stalwart hidden behind headscale

(originally scoped to the Oracle micro; after the PTR finding the recommended front is a PTR-capable tiny VPS — see §3a/§5. Architecture is identical either way.)

Date: 2026-10-08 (execution day) · Author: Hermes (planned 2026-10-07) Status: PLAN — decisions confirmed (§0); front-host choice revised by the PTR finding (~§0.6). Goal (Kris): host email for three domains for ≤12 months such that outsiders cannot correlate the mail-server IP with the IP used for other projects. Outbound is already masked (hetzner1 → Plunk → SES, recipients see Plunk/SES, never hetzner1). The remaining leak is the inbound MX IP, which today points at hetzner1. Fix: put a cheap public front host in front as the only public face; the real Stalwart MTA stays on hetzner1, reachable only over headscale.


0. CONFIRMED (Kris, 2026-10-08) — execute on this basis

  1. Gateway design = L4 TCP proxy on the micro (HAProxy mode tcp), forwarding :25 over headscale to Stalwart on hetzner1. Micro never originates SMTP (OCI's blocked outbound-25 is irrelevant). No Postfix MTA on the micro.
  2. The 3 domains = MIGRATIONS from other providers. All three zones' DNS moves to Cloudflare. Stalwart on hetzner1 must host the 3 domains (users + DKIM + DKIM keys). The plan must therefore include a mail migration (existing messages/users), not just DNS.
  3. Ports = inbound :25 only. No 465/587/993/143 pass-through. (Optional later; not now.)
  4. PTR = needed, and covered — see §3a. Short answer: needed even for inbound-only (many receivers reject/enumerate a MX with no/ugly PTR and score it as spam), the single PTR for the one front IP → one canonical FQDN (not per-domain); it is a manual Oracle support request and Oracle validates the forward A record first. Oracle refusal is NOT a blocker — the design pivots (§3a "if Oracle refuses").
  5. ✅ Front domain = mail2.my (Kris, 2026-10-08 — deliberate, decoupled from the Wine2You brand domain). Canonical mail FQDN mail2.my; PTR + all three domains' MX point at it. Zone already on Cloudflare (Lumlys account) → A mail2.my → 147.93.120.158 (proxy OFF) created + verified (§3b). PTR → mail2.my pending (Kris, hPanel).
  6. ⚠️ PTR BLOCKER (discovered 2026-10-08) — the Always-Free micro CANNOT get a PTR. Oracle Free Tier FAQ: "Customers using only Always Free resources are not eligible for Oracle Support… not eligible to raise a service request." A PTR is a service request. So a purely-Always-Free tenancy has no support entitlement to file it at all (independent community reports confirm: "impossible for free-tier/always-free customers to request PTR entries"). ⇒ The micro is NOT the right front. OCI stays viable only via a PAYG upgrade.
  7. ✅ FRONT HOST DECIDED (Kris, 2026-10-08) — Hostinger VPS srv715158 replaces the micro. 147.93.120.158 / srv715158.hstgr.cloud, Ubuntu 24.04, 1 vCPU / 3.9 GB / 48 GB, Docker 29.8.0, root SSH (default key works). Verified live 2026-10-08 (§2a). Clears the PTR blocker: Hostinger hPanel allows a self-service custom PTR (current PTR is the generic srv715158.hstgr.cloud, forward-confirmed). Outbound :25 is OPEN on this box (unlike OCI/Hetzner). NB: the box also runs the Artland crawler stack (Postgres 5432, Redis 6379) — do not disturb; 5432 is exposed publicly by Docker [FIXED 2026-10-08, see §0.9].
  8. ✅ MTA vs L4 proxy — DECIDED: stay with the L4 TCP proxy, NOT a full MTA. Outbound :25 is open on this box, but that does not justify a full MTA; it would actively work against the concealment goal. Full reasoning in §3c. (The open :25 is still a nice-to-have: it keeps the door open for a full MTA later if requirements change.)
  9. ✅ PTR/FQDN = mail2.my (Kris owns the domain; NS at joker.com → move zone to Cloudflare first, §3b). Hostinger hPanel PTR → mail2.my.
  10. ✅ SECURITY FIX APPLIED 2026-10-08 (§2b): Artland's Postgres 5432 + Redis 6379 were publicly reachable on 147.93.120.158 (Docker publishes on 0.0.0.0, bypassing ufw). Fixed: bound both to 127.0.0.1 in the compose file and added a persistent DOCKER-USER guard in /etc/ufw/after.rules. Verified closed from outside; containers healthy.

1. Target topology

              Internet (MX lookup)
                    │
   <domain1..3>  MX 10 mail2.my
                    │
        ┌───────────▼──────────────────────┐
        │  Hostinger VPS srv715158          │  ← ONLY public face of mail
        │  147.93.120.158  :25              │     (custom PTR set in hPanel)
        │  HAProxy mode tcp                 │
        └───────────┬──────────────────────┘
                    │  headscale (WireGuard)
                    │  (VPS tailnet 100.64.0.x) → 100.64.0.1:25
        ┌───────────▼──────────────────────┐
        │  hetzner1  Stalwart :25           │  ← real MTA, NO public :25 exposure for these 3
        │  100.64.0.1                       │     (hetzner1 keeps its own MX for existing domains)
        └───────────────────────────────────┘

Outbound unchanged: Stalwart → Plunk → SES. Recipients see Plunk/SES. No origin leak.


2a. Front host — verified live 2026-10-08 (Hostinger srv715158)

Item Value
Host / IP srv715158.hstgr.cloud / 147.93.120.158
OS / init Ubuntu 24.04.4 LTS, systemd
Size 1 vCPU / 3.9 GB RAM / 48 GB disk (37 GB free) — ample for a :25 TCP proxy
Docker 29.8.0 present
SSH root@147.93.120.158 works with default key (BatchMode OK)
PTR now srv715158.hstgr.cloud (generic, forward-confirmed) → change to mail2.my in hPanel
Outbound :25 OPEN (PORT25_OUTBOUND_OK) — better than OCI/Hetzner (irrelevant to L4, but a bonus)
Inbound firewall ufw active, default-deny; open = 22, 80, 443 (Nginx Full), 8096. :25 will be added.
Other tenants ⚠️ Artland crawler stack: gami_postgres_buffer (5432) + gami_redis_queue (6379), compose /root/artland3/gami-crawler-core/. Do not disturb.

⚠️ Security note (pre-existing, not mail-related): Docker publishes 5432/6379 on 0.0.0.0 and Docker's iptables rules bypass ufw — so Postgres 5432 is reachable from the internet even though ufw shows no rule for it. Recommend binding those to 127.0.0.1: in the compose file (or a ufw-DOCKER-USER rule) — flagging for a separate fix; don't bundle it here.

2b. Security fix applied 2026-10-08 (Artland Postgres/Redis exposure)

Finding (verified from the Mac): both 147.93.120.158:5432 (Postgres) and :6379 (Redis) answered from the public internet — nc -z succeeded on both. Root cause: the compose file published '5432:5432' / '6379:6379' (all interfaces) and Docker's iptables rules bypass ufw, so the ufw default-deny never applied to them.

Fix (applied, verified):

  1. Backed up the compose file (docker-compose.yml.bak-20261008T061049Z).
  2. Rebound both to loopback: "127.0.0.1:5432:5432" / "127.0.0.1:6379:6379"; recreated the two containers (docker compose up -d).
  3. Added a persistent DOCKER-USER guard (Docker's documented ufw pattern) inside /etc/ufw/after.rules — -i eth0 -p tcp --dport 5432/6379 -j DROP — so the ports stay shut even if a future container re-publishes them. Backup: after.rules.bak-20261008T061123Z. (Also corrected /etc/ufw/ufw.conf to ENABLED=yes, which a failed reload had left out of sync with the running firewall.)
  4. Verified from outside: 5432 → CLOSED, 6379 → CLOSED, 22 → still OPEN. Containers healthy: pg_isready = accepting connections, redis-cli ping = PONG.

Non-goal: unrelated to mail; done as a standalone security fix per Kris #2. The mail work (:25 proxy) is separate and does not touch this stack.

3b. mail2.my DNS — ✅ DONE 2026-10-08

Correction to the original assumption: mail2.my is already on Cloudflare NS (lovisa.ns.cloudflare.com, margo.ns.cloudflare.com, status active) in the Lumlys CF account — the registrar move was already completed, so no joker.com interaction was needed.

Applied (verified):

Still to do (Kris, hPanel): set the PTR 147.93.120.158 → mail2.my (Hostinger → VPS srv715158 → Network/PTR). PTR lives at the IP provider, not CF. Forward (A mail2.my) is already in place, so this completes forward-confirmed rDNS.

Then: the 3 migrating domains' MX → 10 mail2.my.

3c. MTA vs L4 proxy — why we stay L4 (decision record)

Question (Kris): outbound :25 is open on this box — should we switch to a full MTA?

Answer: No. Stay with the L4 TCP proxy. Reasons, in order of weight:

  1. A full MTA re-introduces the exact leak we're hiding. The whole point is that the origin (hetzner1 / your other projects' IP) must not be correlatable. An L4 proxy passes the SMTP stream through untouched — Stalwart on hetzner1 stays the only MTA, and its Received: header (with the front's hostname) is the first hop. A second MTA on this box becomes the first hop, then hands off to Stalwart; now you have two MTAs, two queues, two sets of headers, two DKIM-signing points to keep clean — strictly more places for the origin to leak and for DKIM/alignment to break.
  2. It buys nothing you need. The one thing a full MTA adds is store-and-forward (mail queued if hetzner1 is down). That's a resilience nicety, not a requirement; for a ≤12-month, low-volume mail front it isn't worth the complexity. Your outbound already goes hetzner1→Plunk→SES, so a local MTA here wouldn't even carry outbound.
  3. Deliverability is worse, not better. A fresh MTA on a shared Hostinger IP inherits the IP's reputation either way; but a transparent L4 pass-through means receivers see Stalwart (an established, correctly-configured MTA) doing the SMTP — no new reputation surface. A new MTA here would have to earn reputation and would need its own SPF/DKIM/DMARC/PTR story.
  4. Operational surface. L4 = HAProxy mode tcp, ~10 lines, no queue, no mail spool, no bounces to manage, no user/mailbox config on the front. A full MTA = Postfix/Exim + queue + logs + spam handling + another DKIM key set. For a proxy whose only job is "accept :25, forward over the tunnel," that's all downside.

When a full MTA would be right: if you ever want this box to (a) queue mail during hetzner1 downtime, (b) run mailboxes locally, or (c) send mail from its own IP. None apply now. The open outbound :25 just means the option stays open — we don't have to take it.

Net: keep L4. The open port is a bonus, not a trigger.


2. Live facts already verified (2026-10-07)

Item Value
Oracle micro instance-20261005-1925, VM.Standard.E2.1.Micro, 1 OCPU/1 GB, AD-1 eu-zurich-1
Micro private IP 10.0.0.62 (VCN digid1) — no public IP yet, not on tailnet
Oracle A1 (separate) wine2you-dev-zrh, tailnet 100.64.0.6, tag:wine2you — not used for mail
Stalwart Docker container stalwart on hetzner1, binds 0.0.0.0:25 + 143/465/587/993
hetzner1 tailnet 100.64.0.1 (tailscale 1.102.4)
hetzner1 ufw ALLOW 100.64.0.0/10 (all ports) + 25/tcp ALLOW Anywhere — no firewall change needed
headscale systemd headscale.service, config /opt/headscale/config/config.yaml, policy /opt/headscale/config/policy.hujson (mode: database)
Existing tags tag:mail, tag:wine2you, tag:cb (owner kriswork@) — reuse tag:mail
DNS Cloudflare holds the zones (all 3 target domains' zones move to CF)
The 3 domains MIGRATIONS from other providers — need Stalwart registration + user/mailbox + DKIM + message migration, then MX repoint
OCI outbound 25 blocked (irrelevant — micro never originates SMTP in the L4 design)

3a. PTR — the whole answer (needed? what record? multi-domain? can Oracle do it?)

Q: Is PTR needed if the front only receives (inbound-only)? — YES. Not for sending, but for the inbound path the receiving world still checks the connecting IP's rDNS:

  1. FCrDNS / RBL / reputation gates. Receiving MTAs and every RBL do a forward-confirmed reverse DNS check on the peer IP before accepting. A missing or generic PTR (140-238-x-x.oraclecloud.com) raises the spam score and can cause outright rejection. The peer is the front, so the front's PTR is what's evaluated.
  2. Header hygiene. Without a clean PTR the Received: chain shows a bare IP — which also weakens the "clean front / hidden origin" story.

Q: With one front proxying MULTIPLE domains — is ONE PTR acceptable? — YES, it's the correct pattern, and per-domain is impossible anyway. PTR is 1:1 with the IP, not with the domain: Oracle: "You can only have a 1:1 connection between the PTR and the IP address. Associating multiple PTRs for the same IP address to multiple hostnames isn't supported." So there is one PTR, pointing at one FQDN, regardless of how many MX/domains feed it. Layout:

PTR   FRONT_IP         →  mail2.my     (the ONE canonical mail host)
A     mail2.my  →  FRONT_IP            (must exist BEFORE the PTR request)
MX    <domain1>  10    mail2.my
MX    <domain2>  10    mail2.my
MX    <domain3>  10    mail2.my

The three domains differ only in MX (all → the same front FQDN → same IP → one PTR). This is exactly how large providers work (one MTA name shared by many domains). A per-domain PTR is not possible and not needed; one PTR is both acceptable and correct.

Q: Will Oracle actually grant it? — NO, not on an Always-Free tenancy. This is a hard blocker.

Therefore the recommended change (Kris #3 approved the fallback trigger): put the front on a box with a self-service PTR field, not the OCI micro. Concretely:

Net: one PTR, mail2.my, is correct for all three domains; but the host that can actually get a PTR is a PTR-capable VPS, not the Always-Free micro.

Phase A — Give the front host its public identity (Hostinger hPanel, ~15 min)

  1. Set the custom PTR in hPanel: VPS srv715158 → Network / PTR (rDNS) → set 147.93.120.158 → mail2.my. (Self-service; that's why this box replaces the micro. Allow a few hours for propagation.) Do this early — it's the step the OCI micro couldn't do.
  2. Create the forward record first (Cloudflare): A mail2.my → 147.93.120.158, proxy OFF (grey cloud). Required for forward-confirmed rDNS.
  3. Open inbound :25 in ufw on the VPS: ufw allow 25/tcp. (Also ensure Hostinger's upstream firewall/panel doesn't block 25 — VPS plans typically don't.)
  4. Confirm the box: ssh root@147.93.120.158 works with your default key (verified). MAILGW_IP = 147.93.120.158.

Phase B — Join the VPS to headscale (~15 min on the VPS)

  1. Install tailscale on the VPS, join with the mail tag and the two guardrails that matter:
    tailscale up --login-server https://headscale.digid.co \
      --advertise-tags=tag:mail --accept-dns=false --accept-routes=false
    
    • --accept-dns=false is mandatory — otherwise tailscaled rewrites the resolver and the gateway's own MX/RBL/PTR lookups change silently.
    • Do not advertise routes or an exit node.
  2. Note the VPS's tailnet IP (call it 100.64.0.x, MAILGW_TS). Set WG MTU ≤ 1420 on the VPS's tailscale0 if large-attachment tests stall.

Phase C — headscale ACL (on hetzner1, ~5 min) — the silent-timeout trap

  1. Edit /opt/headscale/config/policy.hujson — add an accept rule:
    { "action": "accept",
      "src": ["tag:mail"],
      "dst": ["100.64.0.1:25"] }
    
    ⚠️ A missing ACL entry fails SILENTLY (hang, no RST). This is the #1 thing that will look like "Stalwart is down" tomorrow if forgotten.
  2. Back up first (cp policy.hujson policy.hujson.bak-mailgw-$(date -u +%Y%m%dT%H%M%SZ)), then reload headscale (systemctl reload headscale / restart). Verify with headscale nodes list.

Phase D — L4 proxy on the front host (~15 min)

  1. Install HAProxy. Minimal frontend/backend:
    frontend smtp_in
        bind *:25
        mode tcp
        default_backend stalwart
    backend stalwart
        mode tcp
        server stalwart 100.64.0.1:25 check
    
    (Equivalent alternative: socat TCP-LISTEN:25,fork,reuseaddr TCP:100.64.0.1:25, or iptables DNAT to 100.64.0.1. HAProxy is preferred: health-checked, restartable, visible logs.)
    • Also install tailscale + haproxy as services; enable on boot.
    • Do not run a full Postfix here — that's the rejected design.

Phase E — Stalwart accepts the relayed source (hetzner1, ~10 min)

  1. Harden Stalwart so the tunnel source is trusted for inbound delivery only, still auth-only for submission — never add 100.64.0.0/10 to a relay-permit list (open-relay risk; classic misread of 100.64/10 as "private").
    • Since the front proxies at L4, the SMTP session to Stalwart arrives from the front's tailnet IP — Stalwart sees a normal inbound MTA connection on :25, which is what its MX listener already expects. No relay config needed in the L4 design (this is precisely why L4 is simpler than the MTA design). Ver Stalwart's IncomingHostname / banner if you want the EHLO name to read as the gateway.
  2. Header hygiene (the actual concealment): ensure no component appends a Received: line exposing 100.64.0.1 or hetzner1's public hostname. In the L4 design Stalwart is the first MTA, so its Received: header uses its own hostname. Set Stalwart's advertised hostname to the front domain (e.g. mail.example.tld), not hetzner1/digid.co.

Phase F — the 3 domains: migrate mail + repoint DNS (~per domain)

F1 — Prepare the target (hetzner1 Stalwart), per domain: 12. Register the domain in Stalwart, create mailboxes/users, generate DKIM keys, and record the DKIM selector TXT value. This must be done before MX cutover so mail lands somewhere the moment DNS flips. 13. Migrate existing mail from the current provider: use imapsync (or the provider's export) to pull each mailbox's messages/ folders into the new Stalwart mailbox. Do a final delta sync at cutover so nothing is lost. (This is the step that makes these migrations, not fresh setups.)

F2 — Cloudflare DNS (per domain): 14. For each domain: MX → 10 mail2.my (the shared front FQDN), where mail2.my is a Cloudflare A record → MAILGW_IP, proxy OFF (grey cloud) — mail must not go through Cloudflare's web proxy. (One front FQDN serves all 3 domains; only the MX target differs per zone.) 15. PTR: MAILGW_IP (147.93.120.158) → one FQDN mail2.my (§3a). Set in Hostinger hPanel → Network/PTR after the forward A record exists. Not per-domain. 16. SPF/DKIM/DMARC per domain, aligned with the sending path that actually signs (Stalwart→Plunk→SES). Publish the Stalwart DKIM selector TXT from F1; SPF must cover the sender (Plunk/SES), NOT MAILGW_IP (the front receives only, it never sends). 17. Outbound: unchanged — Stalwart→Plunk→SES; recipients see Plunk/SES, never hetzner1 or the micro.

Cutover order (per domain): Stalwart ready (F1) → DKIM/SPF records live → lower MX TTL a day ahead → flip MX to mail2.my → final imapsync delta → monitor → raise TTL. Keep the old provider's MX/records for 24–48 h as rollback.

Phase G — Verification (acceptance)

  1. dig MX <domain> → mail2.my → MAILGW_IP.
  2. Inbound: send from an external address (e.g. kriswork@pm.me) to user@<domain>; confirm arrival in Stalwart and that the Received: headers show only MAILGW_IP / mail2.my — no 100.64.0.1, no hetzner1 public host.
  3. Large attachment (≥5 MB) through the tunnel — proves MTU.
  4. dig -x MAILGW_IP → the front FQDN (PTR done).
  5. Migrated mail: confirm old folders/messages present post-cutover (imapsync delta ran).
  6. Cross-check the concealment goal: dig -x hetzner1_public_ip, nslookup mail2.my, and read a received message's Received: chain — none may reveal hetzner1 for these 3 domains.
  7. Non-regression: hetzner1's existing domains (digid.co etc.) still receive/send unchanged.

4. Guardrails (from prior mail-mesh incidents — do not skip)

5. Front host — DECIDED: Hostinger VPS srv715158 (2026-10-08)

6. Rollback