Files
selfpost/internal/postfix/inbound.go
T
mixandClaude Opus 5.5 668e299c69 postfix: no reload for a map that did not change; DNS re-checks limited (audit W6/M9)
Security audit 2026-10-17, findings W6/M9 (low), stage 4. Every
application, upstream, recipient or report-address save reloaded Postfix
through supervisord, and POST .../dns-recheck bypassed the five-minute
cache for up to ~14 resolver queries - both at whatever rate an
authenticated user pressed.

- writeChanged: each generated map is written only when its content
  differs from the file on disk, and Postfix is reloaded only when one was
  (sender map, inbound maps, DMARC maps). Health's Reload configuration
  ends with an explicit reload (app.Service.ReloadPostfix, SenderMaps
  gains Reload), so it still re-reads whatever is on disk. No debounce:
  with unchanged saves gone, a burst is real changes Postfix must see.
- auth.AllowDNSRecheck: twenty forced re-checks in 15 minutes per user;
  past that the outbound, inbound and settings re-check show the cached
  result without the "re-checked" flash, and the log says why.

Tests: TestARebuildThatChangesNothingDoesNotReload,
TestDNSRechecksArePerUser; the fakes gain Reload. Docs: guide § Overview
and Health; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:25:20 +03:00

145 lines
5.0 KiB
Go

package postfix
import (
"fmt"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/mixeme/selfpost/internal/configsafe"
)
// InboundRoute is one inbound domain's Postfix map material: the relay
// domain, the next-hop transport, the TLS policy for that hop, and the
// recipient list (or a domain catch-all).
type InboundRoute struct {
Domain string
Host string
Port int
TLSMode string
RecipientMode string
Recipients []string
}
func (p *Postfix) inboundMapPaths() (relayDomains, transport, recipients, tlsPolicy string) {
dir := filepath.Dir(p.senderLoginMapsPath)
return filepath.Join(dir, "relay_domains"),
filepath.Join(dir, "transport"),
filepath.Join(dir, "relay_recipients"),
filepath.Join(dir, "tls_policy")
}
// RebuildInboundMaps regenerates the inbound relay lookup tables from the full
// set of configured routes and reloads Postfix. Domains with an empty host are
// omitted so mail is never accepted with nowhere to send it. Full regeneration
// keeps the files a pure function of the registry (security.md).
func (p *Postfix) RebuildInboundMaps(routes []InboundRoute) error {
relay, transport, recipients, tlsPolicy, err := renderInboundMaps(routes)
if err != nil {
return err
}
rd, tr, rc, tl := p.inboundMapPaths()
changed, err := writeChanged(mapFile{rd, relay}, mapFile{tr, transport}, mapFile{rc, recipients}, mapFile{tl, tlsPolicy})
if err != nil || !changed {
return err
}
return p.reload()
}
// Postfix 3.10.13, man/man5/postconf.5: a relay_domains table "is matched when
// a (parent) domain appears as lookup key", the value unused; relay_recipient_maps
// is used as a list, "@domain" its wild-card (src/global/mail_addr_find.c,
// MA_FIND_AT_DOMAIN, the exact domain only). man/man5/transport.5: "domain
// transport:nexthop" for that domain. README_FILES/TLS_README: the
// smtp_tls_policy_maps key is the verbatim next-hop; src/tls/tls_level.c names
// the levels none, may and encrypt.
func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tlsPolicy []byte, err error) {
sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
var relayB, transportB, recipB, tlsB strings.Builder
// One TLS policy line per next hop: the map is keyed by it, and a key
// written twice was a warning from Postfix and the first line's mode
// (audit 2026-10-12, L5). The panel refuses two modes for one next hop
// (inbound.Service.SetTransport); rows saved before that keep the
// first by domain name, which is what Postfix did with them.
tlsFor := map[string]bool{}
for _, r := range routes {
if strings.TrimSpace(r.Host) == "" {
continue
}
if err := assertInboundRouteSafe(r); err != nil {
return nil, nil, nil, nil, err
}
nexthop := inboundNexthop(r.Host, r.Port)
fmt.Fprintf(&relayB, "%s OK\n", r.Domain)
fmt.Fprintf(&transportB, "%s smtp:%s\n", r.Domain, nexthop)
if !tlsFor[nexthop] {
tlsFor[nexthop] = true
fmt.Fprintf(&tlsB, "%s %s\n", nexthop, r.TLSMode)
}
switch r.RecipientMode {
case "any":
fmt.Fprintf(&recipB, "@%s OK\n", r.Domain)
default:
addrs := append([]string(nil), r.Recipients...)
sort.Strings(addrs)
for _, addr := range addrs {
fmt.Fprintf(&recipB, "%s OK\n", addr)
}
}
}
return []byte(relayB.String()), []byte(transportB.String()), []byte(recipB.String()), []byte(tlsB.String()), nil
}
// inboundNexthop is the Postfix next-hop [host]:port form. The brackets are
// what disable the MX lookup, so the operator's explicit upstream is used as
// given; they also delimit a bare IPv6 address.
//
// Postfix 3.10.13, src/smtp/smtp_connect.c: no MX lookup for a destination that
// starts with "["; src/util/host_port.c, host_port(): "[host]:port", the host
// a name or an address ("ipv6:" in front is optional), checked by
// valid_hostaddr().
func inboundNexthop(host string, port int) string {
return "[" + host + "]:" + strconv.Itoa(port)
}
func assertInboundRouteSafe(r InboundRoute) error {
if err := assertMapToken(r.Domain, "domain"); err != nil {
return err
}
if err := assertMapToken(r.Host, "host"); err != nil {
return err
}
if r.Port < 1 || r.Port > 65535 {
return fmt.Errorf("postfix: invalid inbound port %d", r.Port)
}
switch r.TLSMode {
case "may", "encrypt", "none":
default:
return fmt.Errorf("postfix: invalid tls mode %q", r.TLSMode)
}
if r.RecipientMode != "list" && r.RecipientMode != "any" {
return fmt.Errorf("postfix: invalid recipient mode %q", r.RecipientMode)
}
for _, addr := range r.Recipients {
if err := assertMapToken(addr, "recipient"); err != nil {
return err
}
}
return nil
}
// texthashForbidden is what may never appear in a texthash line: whitespace and
// the newline that would end it, the comma that separates values, and the
// backslash.
const texthashForbidden = " \t\r\n,\\"
// assertMapToken rejects values that could break out of a texthash line.
func assertMapToken(v, what string) error {
if err := configsafe.Token(what, v, texthashForbidden); err != nil {
return fmt.Errorf("postfix: %w", err)
}
return nil
}