wip: mail hardening: stalwart PROXY /32 + MTA-STS/TLS-RPT #679

Draft
lytedev wants to merge 1 commit from sec-mail into main
Owner

What

Two inbound-mail hardening changes on beefcake's stalwart / the lyte.dev zone. Not deployed — build-checked only, for review + deploy by you.

  1. Tighten stalwart's PROXY-protocol trust to pebble's /32 (packages/hosts/beefcake/stalwart.nix)
  2. Add MTA-STS + TLS-RPT for inbound mail (lib/modules/nixos/dns-zones.nix, packages/hosts/beefcake/caddy.nix, packages/hosts/beefcake.nix)

Build-checked: nix build .#nixosConfigurations.beefcake.config.system.build.toplevel succeeds. The caddy adapt/fmt validation and the zone render both run as part of that build and pass. The rendered policy body and TXT records were inspected in the store (below).


1. Stalwart PROXY trust: 100.64.0.0/10100.64.0.15/32

overrideProxyTrustedNetworks previously trusted the entire tailnet CGNAT range (100.64.0.0/10) to send PROXY-protocol headers on inbound :25. A PROXY header claims an arbitrary client IP, so anything in that range that reached :25 could forge the sender IP and defeat SPF/DNSBL/reputation. The only host that legitimately sends PROXY headers is pebble, the MX front:

MX → pebble → HAProxy → beefcake:25 (PROXY v2)

Narrowed to pebble's tailnet /32. Your three questions:

(a) Is pebble's tailnet IP stable? Yes. headscale assigns node IPs persistently — a node keeps its address across reboots/reconnects; it only changes if the node is deleted and re-registered. Confirmed current value 100.64.0.15 two ways:

  • ssh root@pebble 'tailscale ip -4'100.64.0.15
  • headscale on beefcake: headscale nodes listpebble … 100.64.0.15, fd7a:115c:a1e0::f

The code comment calls out that if headscale ever does reassign it, this /32 must be updated in lockstep or PROXY-passed mail gets rejected as untrusted.

(b) Is a secret required instead? No — there's nothing to configure. PROXY protocol has no authentication or shared secret (HAProxy PROXY protocol spec — "The receiver … may choose to accept the connection … based on the sender's address"; there is no signature/token field). Trust is purely by source IP. So the correct control is a tight source-IP allowlist: the /32 here + the headscale ACL restricting who can even open :25. There is no stronger knob available at this layer.

(c) Why was this rated LOW? Because the headscale ACL already restricts :25 to pebble (src: [pebble], dst: [beefcake:25,2526] in headscale-acl.json), the broad /10 trust was only reachable by a host that is both (i) an already-compromised tailnet node and (ii) permitted by the ACL to reach :25 — i.e. it required a prior compromise to exploit at all. The /32 removes that residual defense-in-depth gap so the two controls agree, rather than fixing an open hole. Hence LOW, not higher.

Note: overrideProxyTrustedNetworks also means stalwart requires a PROXY header from the trusted source, so plain SMTP from the tailnet to :25 would hang. That's unchanged — pebble's outage-fallback queue uses the separate plain :2526 listener, which is unaffected by this narrowing.


2. MTA-STS + TLS-RPT (M-low)

Today inbound mail TLS is opportunistic STARTTLS with no published policy and no reporting: an on-path attacker can strip the STARTTLS advertisement (downgrade to cleartext) and we'd neither prevent nor detect it. dig TXT _mta-sts.lyte.dev / dig TXT _smtp._tls.lyte.dev both currently return empty.

What these are:

  • MTA-STS (RFC 8461) — a policy you publish that tells sending mail servers: "always use TLS to my MX, and the MX must present a valid, matching certificate." A sender that supports MTA-STS will then refuse to deliver over a stripped/cleartext connection or to a wrong/invalid cert, closing the downgrade/MITM window on inbound mail. The policy is discovered via a DNS TXT record (_mta-sts) and fetched over HTTPS (so the policy itself can't be forged by a DNS attacker — it's cert-validated).
  • TLS-RPT (RFC 8460) — a TXT record naming an address where senders email you daily aggregate reports of TLS handshake failures to your MX. This is how you detect downgrade attempts or cert problems (and how you gain confidence before enforcing).

What this PR adds:

  • _mta-sts.lyte.dev TXT → v=STSv1; id=20260701000000 (the id is an opaque version tag; bump it whenever the policy file body changes so senders re-fetch).
  • A caddy vhost mta-sts.lyte.dev serving the policy at https://mta-sts.lyte.dev/.well-known/mta-sts.txt (auto-provisioned cert, same as files.lyte.dev). Rendered body (verified in the built, caddy fmt'd Caddyfile — no leading whitespace):
    version: STSv1
    mode: testing
    mx: pebble.lyte.dev
    max_age: 604800
    
  • _smtp._tls.lyte.dev TXT → v=TLSRPTv1; rua=mailto:postmaster@lyte.dev.
  • mta-sts added to beefcake's lyte.dns-updater.records so the policy host resolves to this box.

Recommendation — start in testing, then enforce. I set mode: testing deliberately: in testing mode senders evaluate the policy and report failures via TLS-RPT but do not reject mail on a TLS failure. So we get visibility with zero delivery risk. Once TLS-RPT reports come in clean for a while (pebble's cert for pebble.lyte.dev is valid and matches the MX), flip the Caddyfile to mode: enforce and bump the id in dns-zones.nix in the same change so senders pick up the new policy.

Deploy notes (for when you deploy)

  • Config-only + DNS; no cross-release toolchain change, so a normal live switch is fine (not a reboot). Deploy beefcake over the LAN per repo rules (--hostname 192.168.0.9), not the VPN.
  • After deploy, the dynamic mta-sts.lyte.dev A record is published by the dns-updater run, and caddy provisions the cert on first hit — the well-known URL won't serve valid TLS until both happen.
  • Quick post-deploy checks:
    dig +short TXT _mta-sts.lyte.dev
    dig +short TXT _smtp._tls.lyte.dev
    curl -s https://mta-sts.lyte.dev/.well-known/mta-sts.txt
    

🤖 Generated with Claude Code

## What Two inbound-mail hardening changes on beefcake's stalwart / the `lyte.dev` zone. **Not deployed** — build-checked only, for review + deploy by you. 1. **Tighten stalwart's PROXY-protocol trust to pebble's /32** (`packages/hosts/beefcake/stalwart.nix`) 2. **Add MTA-STS + TLS-RPT** for inbound mail (`lib/modules/nixos/dns-zones.nix`, `packages/hosts/beefcake/caddy.nix`, `packages/hosts/beefcake.nix`) Build-checked: `nix build .#nixosConfigurations.beefcake.config.system.build.toplevel` succeeds. The caddy `adapt`/`fmt` validation and the zone render both run as part of that build and pass. The rendered policy body and TXT records were inspected in the store (below). --- ## 1. Stalwart PROXY trust: `100.64.0.0/10` → `100.64.0.15/32` `overrideProxyTrustedNetworks` previously trusted the **entire tailnet CGNAT range** (`100.64.0.0/10`) to send PROXY-protocol headers on inbound `:25`. A PROXY header *claims* an arbitrary client IP, so anything in that range that reached `:25` could forge the sender IP and defeat SPF/DNSBL/reputation. The only host that legitimately sends PROXY headers is pebble, the MX front: ``` MX → pebble → HAProxy → beefcake:25 (PROXY v2) ``` Narrowed to pebble's tailnet **/32**. Your three questions: **(a) Is pebble's tailnet IP stable?** Yes. headscale assigns node IPs persistently — a node keeps its address across reboots/reconnects; it only changes if the node is deleted and re-registered. Confirmed current value **`100.64.0.15`** two ways: - `ssh root@pebble 'tailscale ip -4'` → `100.64.0.15` - headscale on beefcake: `headscale nodes list` → `pebble … 100.64.0.15, fd7a:115c:a1e0::f` The code comment calls out that if headscale ever *does* reassign it, this /32 must be updated in lockstep or PROXY-passed mail gets rejected as untrusted. **(b) Is a secret required instead?** No — there's nothing to configure. **PROXY protocol has no authentication or shared secret** ([HAProxy PROXY protocol spec](https://github.com/haproxy/haproxy/blob/v3.0.0/doc/proxy-protocol.txt) — "The receiver … *may* choose to accept the connection … based on the sender's address"; there is no signature/token field). Trust is purely by source IP. So the correct control *is* a tight source-IP allowlist: the `/32` here + the headscale ACL restricting who can even open `:25`. There is no stronger knob available at this layer. **(c) Why was this rated LOW?** Because the headscale ACL already restricts `:25` to pebble (`src: [pebble], dst: [beefcake:25,2526]` in `headscale-acl.json`), the broad `/10` trust was only reachable by a host that is **both** (i) an already-compromised tailnet node **and** (ii) permitted by the ACL to reach `:25` — i.e. it required a prior compromise to exploit at all. The `/32` removes that residual defense-in-depth gap so the two controls agree, rather than fixing an open hole. Hence LOW, not higher. > Note: `overrideProxyTrustedNetworks` also means stalwart *requires* a PROXY header from the trusted source, so plain SMTP from the tailnet to `:25` would hang. That's unchanged — pebble's outage-fallback queue uses the separate plain `:2526` listener, which is unaffected by this narrowing. --- ## 2. MTA-STS + TLS-RPT (M-low) Today inbound mail TLS is **opportunistic STARTTLS** with no published policy and no reporting: an on-path attacker can strip the STARTTLS advertisement (downgrade to cleartext) and we'd neither prevent nor detect it. `dig TXT _mta-sts.lyte.dev` / `dig TXT _smtp._tls.lyte.dev` both currently return empty. **What these are:** - **MTA-STS** (RFC 8461) — a policy you publish that tells sending mail servers: *"always use TLS to my MX, and the MX must present a valid, matching certificate."* A sender that supports MTA-STS will then refuse to deliver over a stripped/cleartext connection or to a wrong/invalid cert, closing the downgrade/MITM window on inbound mail. The policy is discovered via a DNS TXT record (`_mta-sts`) and fetched over **HTTPS** (so the policy itself can't be forged by a DNS attacker — it's cert-validated). - **TLS-RPT** (RFC 8460) — a TXT record naming an address where senders email you **daily aggregate reports** of TLS handshake failures to your MX. This is how you *detect* downgrade attempts or cert problems (and how you gain confidence before enforcing). **What this PR adds:** - `_mta-sts.lyte.dev` TXT → `v=STSv1; id=20260701000000` (the `id` is an opaque version tag; bump it whenever the policy file body changes so senders re-fetch). - A caddy vhost **`mta-sts.lyte.dev`** serving the policy at `https://mta-sts.lyte.dev/.well-known/mta-sts.txt` (auto-provisioned cert, same as `files.lyte.dev`). Rendered body (verified in the built, `caddy fmt`'d Caddyfile — no leading whitespace): ``` version: STSv1 mode: testing mx: pebble.lyte.dev max_age: 604800 ``` - `_smtp._tls.lyte.dev` TXT → `v=TLSRPTv1; rua=mailto:postmaster@lyte.dev`. - `mta-sts` added to beefcake's `lyte.dns-updater.records` so the policy host resolves to this box. **Recommendation — start in `testing`, then `enforce`.** I set `mode: testing` deliberately: in testing mode senders evaluate the policy and **report failures via TLS-RPT but do not reject mail** on a TLS failure. So we get visibility with zero delivery risk. Once TLS-RPT reports come in clean for a while (pebble's cert for `pebble.lyte.dev` is valid and matches the MX), flip the Caddyfile to `mode: enforce` **and bump the `id`** in `dns-zones.nix` in the same change so senders pick up the new policy. ### Deploy notes (for when you deploy) - Config-only + DNS; no cross-release toolchain change, so a normal live switch is fine (not a reboot). Deploy beefcake **over the LAN** per repo rules (`--hostname 192.168.0.9`), not the VPN. - After deploy, the dynamic `mta-sts.lyte.dev` A record is published by the dns-updater run, and caddy provisions the cert on first hit — the well-known URL won't serve valid TLS until both happen. - Quick post-deploy checks: ``` dig +short TXT _mta-sts.lyte.dev dig +short TXT _smtp._tls.lyte.dev curl -s https://mta-sts.lyte.dev/.well-known/mta-sts.txt ``` --- 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(mail): tighten stalwart PROXY trust to pebble /32; add MTA-STS + TLS-RPT
All checks were successful
/ check-format (push) Successful in 8s
/ build (push) Successful in 8m24s
6bfb1a528c
Two inbound-mail hardening changes on beefcake's stalwart / lyte.dev:

- Narrow overrideProxyTrustedNetworks from 100.64.0.0/10 (the whole tailnet
  CGNAT range) to pebble's tailnet /32 (100.64.0.15). Only pebble, the MX
  front, ever sends PROXY v2 headers (MX -> pebble HAProxy -> beefcake:25).
  PROXY protocol has no auth; source-IP trust is the only control, so a /32
  plus the existing headscale ACL (src: [pebble] -> beefcake:25) are the
  layered controls. Removes the residual where a compromised tailnet node
  that could also reach :25 could forge the client IP and defeat
  SPF/DNSBL/reputation.

- Add MTA-STS + TLS-RPT for lyte.dev inbound mail (previously opportunistic
  STARTTLS only, strippable by an on-path attacker with no policy or
  reporting):
  - _mta-sts.lyte.dev TXT (v=STSv1) + a caddy vhost mta-sts.lyte.dev serving
    the policy at /.well-known/mta-sts.txt (mode: testing, mx: pebble.lyte.dev,
    max_age 7d). testing is report-only; flip to enforce once TLS-RPT is clean.
  - _smtp._tls.lyte.dev TXT (v=TLSRPTv1; rua=mailto:postmaster@lyte.dev) for
    aggregate TLS-failure reports.
  - mta-sts added to the beefcake dns-updater dynamic records so the policy
    host resolves here.

Build-checked (nixosConfigurations.beefcake toplevel); caddy adapt + zone
render validated. Not deployed.
lytedev changed title from mail hardening: stalwart PROXY /32 + MTA-STS/TLS-RPT to wip: mail hardening: stalwart PROXY /32 + MTA-STS/TLS-RPT 2026-07-01 11:25:42 -05:00
All checks were successful
/ check-format (push) Successful in 8s
Required
Details
/ build (push) Successful in 8m24s
Required
Details
This pull request is marked as a work in progress.
This branch is out-of-date with the base branch
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin sec-mail:sec-mail
git switch sec-mail
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lytedev/nix!679
No description provided.