upstream-tls-verify: Verified TLS to an inbound domain's upstream

Owner, 2026-10-18: "Оба предложения делаем"; the mockup is c10cd78. The
security audit's M7: Required is Postfix's encrypt level, which checks no
certificate - it keeps the mail from a listener, not from one who answers
in the upstream's place.

- A fourth TLS mode, Verified, stored as Postfix's level secure (migration
  0014 rebuilds inbound_transports with the wider CHECK; schema 14) and
  written to the TLS policy map like the others. Postfix matches the
  certificate against the next hop's name (smtp_tls_secure_cert_match =
  nexthop, dot-nexthop, the default; postconf(5)), so Verified is refused
  for an upstream given by address (checkSecureHost).
- Postfix's SMTP client trusts Debian's ca-certificates bundle
  (smtp_tls_CAfile): Postfix trusts none by default (DEF_SMTP_TLS_CA_FILE
  "", DEF_TLS_APPEND_DEF_CA 0). The levels may and encrypt only log the
  verdict, so mail to recipients' MX is unchanged.
- The page: the select's fourth option, a help line under it, the lead
  "over verified TLS", the list's tag "verified"; Help word for word with
  the mockup; guide § Inbound relay, with a posttls-finger command to try
  an upstream first.

Tests: TestVerifiedTLSNeedsTheUpstreamsName, TestRenderInboundMapsSecure,
TestUpstreamTLSModeSecure; schema tests at 14. e2e
inbound_upstream_verified_tls: an address refused, a name saved as
"[sink]:25 secure" in tls_policy, the page says verified, the CA file set
and present, and posttls-finger -l secure with it calls the stand's
self-signed port 25 "Untrusted" (the stand has no TLS upstream). make e2e
(65 passed) and go vet + go test (26 packages) on the test server for this
tree. Tried against the live upstream from the 2.14.5 container:
mc-ent.mixfed.ru gives "Verified TLS connection established".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-18 10:27:37 +03:00
co-authored by Claude Opus 5.5
parent c10cd78eff
commit 6b4106f60e
17 changed files with 215 additions and 14 deletions
+7
View File
@@ -7,6 +7,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Added
- **Verified TLS to an inbound domain's upstream** (upstream-tls-verify; the
security audit's M7): a fourth choice beside Opportunistic, Required and
Off - Postfix's `secure` level, which also checks that the upstream's
certificate was issued by a trusted authority for its name. The upstream
is then given by name. Postfix's SMTP client now trusts Debian's
`ca-certificates`; mail to the recipients' MX is unchanged. Migration
0014 widens the stored TLS mode; schema 14.
- **A DMARC record for the server's own name** (server-dmarc-hint; the
security audit's S3): Server › Settings › Notification mail gives
`v=DMARC1; p=reject` at `_dmarc.<hostname>` beside the DKIM and SPF
+11 -1
View File
@@ -297,9 +297,19 @@ postconf -e \
"local_transport=error:local mail delivery is disabled"
# Outbound delivery: straight to the recipient MX, opportunistic TLS.
# The client trusts the authorities Debian's ca-certificates ships, for an
# inbound domain's upstream with Verified TLS (the "secure" level in the TLS
# policy map, upstream-tls-verify): Postfix trusts none by default - the
# *_tls_CAfile defaults are empty and tls_append_default_CA is "no" (Postfix
# 3.10.13, src/global/mail_params.h, DEF_SMTP_TLS_CA_FILE,
# DEF_TLS_APPEND_DEF_CA). The levels may and encrypt check nothing and only
# log the verdict; mail to the recipients' MX is unchanged. The file is the
# bundle update-ca-certificates writes (ca-certificates, installed in the
# image), read by smtp(8) unchrooted (chroot is off below).
postconf -e \
"smtp_tls_security_level=may" \
"smtp_tls_note_starttls_offer=yes"
"smtp_tls_note_starttls_offer=yes" \
"smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt"
# TLS server material shared by every inbound service. auth_only guarantees
# credentials are never accepted before TLS is up on any port. Postfix 3.10.13,
+13 -2
View File
@@ -1237,10 +1237,21 @@ for such a sender.
**Panel** (`/inbound/domains`): the global role adds and deletes domains. On a
domain's page, set the upstream host/port and TLS to that hop (opportunistic /
required / off), and choose recipients. *Required* is Postfix's `encrypt`
required / verified / off), and choose recipients. *Required* is Postfix's `encrypt`
level: the hop is encrypted, but the upstream's certificate is not checked,
so it guards against someone who listens on the way, not against one who
answers in the upstream's place. An upstream has one TLS mode: Postfix
answers in the upstream's place. *Verified* is Postfix's `secure` level: the
certificate must be issued by an authority Debian's `ca-certificates`
trusts, for the upstream's host name - so the upstream is given by name,
not by address (the panel refuses an address with it), and mail waits in
the queue while the check fails, a self-signed certificate or one for
another name included. To try an upstream before choosing it:
```sh
docker exec <container> posttls-finger -c -l secure -F /etc/ssl/certs/ca-certificates.crt "[mx.example.com]:25"
```
"Verified TLS connection established" means it will pass. An upstream has one TLS mode: Postfix
keeps the TLS policy per next hop, not per domain, so the mode saved for one
domain is the mode of every domain forwarded to the same host and port, and
they are changed with it; a *domain* user who does not manage all of them is
+1 -1
View File
@@ -29,7 +29,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| ID | Topic | Status | Progress | Plan |
|---|---|---|---|---|
| security-audit-2026-10-17 | Follow-ups of the 2026-10-17 security audit: A2 (high), seven mediums, the lows | stages 0-6 shipped `2.14.4` (2026-10-17); stage H (the host, S1-S3, S5) the owner's | 27/29 | [security-audit-2026-10-17.md](security-audit-2026-10-17.md) |
| upstream-tls-verify | Inbound: offer "Verified" TLS to the upstream (Postfix `secure`, the upstream's name checked), beside Required, which checks no certificate (audit M7) | proposed | — | — |
| upstream-tls-verify | Inbound: offer "Verified" TLS to the upstream (Postfix `secure`, the upstream's name checked), beside Required, which checks no certificate (audit M7) | built 2026-10-18 (owner: "Оба предложения делаем") | — | — |
| server-dmarc-hint | Server › Settings lists a DMARC record for the server's own name beside its DKIM and SPF (audit S3, product side) | built 2026-10-18 (owner: "Оба предложения делаем") | — | — |
| inbound-upstream-result | Inbound › Log shows what the upstream answered: sent, deferred, bounced, with its last reply | shipped `2.10.0` | 7/7 | [plans/inbound-upstream-result.md](plans/inbound-upstream-result.md) |
| health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | shipped `2.11.0` | — | — |
+3
View File
@@ -108,6 +108,9 @@ func (s *Service) SetTransportAs(id int64, host, portRaw, tlsMode string, mayCha
if err := checkTLSMode(tlsMode); err != nil {
return 0, err
}
if err := checkSecureHost(host, tlsMode); err != nil {
return 0, err
}
domains, err := s.store.ListInboundDomains()
if err != nil {
return 0, err
+26
View File
@@ -2,6 +2,7 @@ package inbound
import (
"errors"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/postfix"
@@ -66,3 +67,28 @@ func TestOneTLSModePerUpstream(t *testing.T) {
t.Errorf("the refused save changed something: c on %d, a %s", d.Port, mode(a.ID))
}
}
// Verified TLS (secure) is saved for an upstream given by name and refused for
// one given by address: Postfix checks the certificate against the next hop's
// name (upstream-tls-verify).
func TestVerifiedTLSNeedsTheUpstreamsName(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
s := NewService(st, nullMaps{})
d, _ := s.Add("in.example")
for _, host := range []string{"10.0.0.8", "[2001:db8::25]"} {
var v *ValidationError
if err := s.SetTransport(d.ID, host, "25", store.TLSModeSecure); !errors.As(err, &v) || !strings.Contains(err.Error(), "give the upstream by name") {
t.Errorf("Verified TLS to %s: %v, want refused", host, err)
}
}
if err := s.SetTransport(d.ID, "MX.Example.NET", "25", store.TLSModeSecure); err != nil {
t.Fatalf("Verified TLS to a name: %v", err)
}
if got, _ := st.GetInboundDomain(d.ID); got.TLSMode != store.TLSModeSecure || got.Host != "mx.example.net" {
t.Errorf("saved %s %s", got.Host, got.TLSMode)
}
}
+14 -1
View File
@@ -103,13 +103,26 @@ func parsePort(raw string) (int, error) {
func checkTLSMode(mode string) error {
switch mode {
case "may", "encrypt", "none":
case "may", "encrypt", "secure", "none":
return nil
default:
return invalid("invalid TLS mode")
}
}
// checkSecureHost refuses Verified TLS for an upstream given by address:
// Postfix's secure level matches the certificate against the next hop's name
// (smtp_tls_secure_cert_match = nexthop, dot-nexthop; Postfix 3.10.13,
// postconf(5): "the transport next-hop configured for the domain stripped of
// ... enclosing square brackets and trailing port"), and a certificate names
// hosts, not addresses.
func checkSecureHost(host, mode string) error {
if mode == "secure" && net.ParseIP(host) != nil {
return invalid("Verified TLS checks the upstream's certificate against its name: give the upstream by name, not by address")
}
return nil
}
func checkRecipientMode(mode string) error {
switch mode {
case "list", "any":
+1 -1
View File
@@ -115,7 +115,7 @@ func assertInboundRouteSafe(r InboundRoute) error {
return fmt.Errorf("postfix: invalid inbound port %d", r.Port)
}
switch r.TLSMode {
case "may", "encrypt", "none":
case "may", "encrypt", "secure", "none":
default:
return fmt.Errorf("postfix: invalid tls mode %q", r.TLSMode)
}
+15
View File
@@ -136,3 +136,18 @@ func TestRenderInboundMapsOneTLSLinePerNextHop(t *testing.T) {
t.Errorf("tls_policy = %q, want the next hop once", tlsPolicy)
}
}
// Verified TLS is Postfix's secure level, written as the others are; the
// certificate is matched against the next hop's name by the main.cf default
// (smtp_tls_secure_cert_match = nexthop, dot-nexthop).
func TestRenderInboundMapsSecure(t *testing.T) {
_, _, _, tlsPolicy, err := renderInboundMaps([]InboundRoute{
{Domain: "in.example.com", Host: "mx.example.net", Port: 25, TLSMode: "secure", RecipientMode: "any"},
})
if err != nil {
t.Fatal(err)
}
if string(tlsPolicy) != "[mx.example.net]:25 secure\n" {
t.Errorf("tls_policy = %q", tlsPolicy)
}
}
+4 -1
View File
@@ -14,10 +14,13 @@ const (
)
// TLS modes for the hand-off to the upstream. Values are Postfix
// smtp_tls_policy_maps levels: may (opportunistic), encrypt (required), none.
// smtp_tls_policy_maps levels: may (opportunistic), encrypt (required),
// secure (verified: the certificate checked against the upstream's name;
// migration 0014), none.
const (
TLSModeMay = "may"
TLSModeEncrypt = "encrypt"
TLSModeSecure = "secure"
TLSModeNone = "none"
)
@@ -0,0 +1,17 @@
-- The upstream's TLS mode may be 'secure' (upstream-tls-verify, 2.15.0; owner,
-- 2026-10-18; the security audit of 2026-10-17, M7): Postfix's level that
-- encrypts and checks the upstream's certificate against its name. The CHECK
-- of a column cannot be altered in SQLite, so the table is made anew with the
-- wider one and its rows copied (https://sqlite.org/lang_altertable.html § 7).
-- Nothing references inbound_transports, so it can be dropped with foreign
-- keys on; its own reference to inbound_domains goes over with the new table.
CREATE TABLE inbound_transports_new (
inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE,
host TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'secure', 'none'))
);
INSERT INTO inbound_transports_new (inbound_domain_id, host, port, tls_mode)
SELECT inbound_domain_id, host, port, tls_mode FROM inbound_transports;
DROP TABLE inbound_transports;
ALTER TABLE inbound_transports_new RENAME TO inbound_transports;
+23 -4
View File
@@ -120,8 +120,8 @@ func TestBaselineSchema(t *testing.T) {
}
var version, appID int64
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 13 {
t.Errorf("user_version = %d, %v; want 13", version, err)
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 14 {
t.Errorf("user_version = %d, %v; want 14", version, err)
}
if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID {
t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID)
@@ -197,8 +197,8 @@ func TestOpenMigratesA20Database(t *testing.T) {
}
defer st.Close()
var version int
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 13 {
t.Errorf("user_version = %d, %v; want 13", version, err)
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 14 {
t.Errorf("user_version = %d, %v; want 14", version, err)
}
if ok, err := st.UserExists(); err != nil || !ok {
t.Errorf("the user of the 2.0 database: exists = %v, %v", ok, err)
@@ -442,3 +442,22 @@ func TestTwoOpensMigrateOnce(t *testing.T) {
t.Fatalf("user_version %d, want %d", version, len(entries))
}
}
// Migration 0014 widens the upstream's TLS mode to secure and keeps every
// upstream as it was (upstream-tls-verify).
func TestUpstreamTLSModeSecure(t *testing.T) {
st := openTestStore(t)
d, err := st.AddInboundDomain("in.example.com")
if err != nil {
t.Fatal(err)
}
if err := st.UpdateInboundTransport(d.ID, "mx.example.net", 25, TLSModeSecure); err != nil {
t.Fatalf("secure refused: %v", err)
}
if err := st.UpdateInboundTransport(d.ID, "mx.example.net", 25, "verify"); err == nil {
t.Error("a mode the panel does not know was stored")
}
if got, err := st.GetInboundDomain(d.ID); err != nil || got.TLSMode != TLSModeSecure {
t.Errorf("mode %q, %v", got.TLSMode, err)
}
}
+1 -1
View File
@@ -231,7 +231,7 @@ var inboundTopics = []HelpTopic{
Rich("An optional backup-MX or forwarder, switched on in the server's ", Code(".env"), "; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role."),
}},
{ID: HelpUpstream, Title: "Upstream", Body: []Text{
Plain("The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear; Required sends only over TLS, and the mail waits in the queue while the upstream does not offer it or the TLS handshake fails - an upstream that answers STARTTLS with a permanent error bounces it. Neither checks the upstream's certificate: TLS here keeps the mail from whoever only listens on the way, not from one who answers in the upstream's place. Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail."),
Plain("The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear; Required sends only over TLS, and the mail waits in the queue while the upstream does not offer it or the TLS handshake fails - an upstream that answers STARTTLS with a permanent error bounces it. Neither checks the upstream's certificate: TLS here keeps the mail from whoever only listens on the way, not from one who answers in the upstream's place. Verified is Required with that check too: the upstream's certificate has to be issued by a trusted authority for the host given here, so the host is given by name, not by address, and the mail waits in the queue while the check fails - a certificate for another name, or one the upstream signed itself, included. Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail."),
}},
{ID: HelpRecipients, Title: "Valid recipients", Body: []Text{
Plain("Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream."),
+6 -1
View File
@@ -24,6 +24,7 @@ func InboundDomainHref(id int64) string { return inboundDomains + "/" + strconv.
const (
recipientModeAny = "any"
tlsModeEncrypt = "encrypt"
tlsModeSecure = "secure"
tlsModeNone = "none"
)
@@ -120,6 +121,8 @@ func tlsTag(mode string) Tag {
switch mode {
case tlsModeEncrypt:
return Tag{Status: "ok", Label: "required"}
case tlsModeSecure:
return Tag{Status: "ok", Label: "verified"}
case tlsModeNone:
return Tag{Status: "off", Label: "off"}
}
@@ -303,7 +306,7 @@ func NewInDomain(m Meta, in InDomainInput) *InDomain {
Host: in.Host,
Port: strconv.Itoa(in.Port),
TLSOptions: []Option{{Value: "may", Label: "Opportunistic"}, {Value: tlsModeEncrypt, Label: "Required"},
{Value: tlsModeNone, Label: "Off"}},
{Value: tlsModeSecure, Label: "Verified"}, {Value: tlsModeNone, Label: "Off"}},
TLSSelected: in.TLSMode,
Recipients: Box{No: "03", Title: "Valid recipients", ID: "recipients", Help: topicLink(HelpRecipients, "Listed addresses or any address")},
@@ -335,6 +338,8 @@ func inDomainLead(in InDomainInput) Text {
switch in.TLSMode {
case tlsModeEncrypt:
tls = "over required TLS"
case tlsModeSecure:
tls = "over verified TLS"
case tlsModeNone:
tls = "without TLS"
}
+2 -1
View File
@@ -29,7 +29,8 @@
<div class="field"><label class="label" for="tls">TLS to upstream</label>
<div class="control"><div class="select is-fullwidth"><select id="tls" name="tls_mode">{{$sel := .TLSSelected}}{{range .TLSOptions}}
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div></div>
</select></div></div>
<p class="help">Required encrypts the hop without checking who answers. Verified also checks the upstream's certificate: issued by a trusted authority for the host above, which is then given by name.</p></div>
<button type="submit" class="button is-primary">Save upstream</button>
</form>
{{template "body_close"}}
+3
View File
@@ -407,6 +407,9 @@ func TestE2E(t *testing.T) {
run("inbound_bare_newline_is_one_message", func(t *testing.T) {
testInboundBareNewline(t, sc)
})
run("inbound_upstream_verified_tls", func(t *testing.T) {
testVerifiedUpstreamTLS(t, sc)
})
// After every step that uses adminPassword: the reset sets another.
run("password_reset_by_e_mail", func(t *testing.T) {
testPasswordReset(t, sc)
+68
View File
@@ -0,0 +1,68 @@
package e2e
import (
"net/url"
"regexp"
"strings"
"testing"
)
// verifiedDomain is an inbound domain of its own for Verified TLS, so that its
// upstream - a next hop with a TLS mode of its own - touches no other step.
const verifiedDomain = "verified.e2e.test"
// testVerifiedUpstreamTLS is upstream-tls-verify (owner, 2026-10-18): the
// panel saves Verified TLS for an upstream given by name and writes Postfix's
// secure level for it, refuses it for one given by address, and Postfix's
// client trusts Debian's authorities and so refuses a certificate none of
// them issued. The stand has no upstream that speaks TLS (smtp-sink does
// not), so the last is asked of posttls-finger - Postfix's own TLS client,
// with the client's CA file - against the stand's own port 25, whose
// certificate is self-signed: "Untrusted", never "Verified" (Postfix 3.10.13,
// src/tls/tls_misc.c:1252, the summary line's first word).
func testVerifiedUpstreamTLS(t *testing.T, sc *scenario) {
resp, body, err := sc.panel.postForm("/inbound/domains", url.Values{"name": {verifiedDomain}})
if err != nil {
t.Fatal(err)
}
m := regexp.MustCompile(`^/inbound/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path)
if m == nil {
t.Fatalf("add %s: landed on %s:\n%.800s", verifiedDomain, resp.Request.URL.Path, body)
}
base := "/inbound/domains/" + m[1]
defer sc.panel.postForm(base+"/delete", url.Values{})
// By address: refused, nothing saved.
resp, body, err = sc.panel.postForm(base+"/upstream", url.Values{"host": {"10.77.0.11"}, "port": {"25"}, "tls_mode": {"secure"}})
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 400 || !strings.Contains(body, "give the upstream by name") {
t.Fatalf("Verified TLS to an address: status %d:\n%.800s", resp.StatusCode, body)
}
// By name: saved, and the policy map says secure for that next hop.
resp, body, err = sc.panel.postForm(base+"/upstream", url.Values{"host": {"sink"}, "port": {"25"}, "tls_mode": {"secure"}})
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 || resp.Request.URL.Path != base {
t.Fatalf("Verified TLS to a name: status %d, landed on %s:\n%.800s", resp.StatusCode, resp.Request.URL.Path, body)
}
if out, err := h.execIn("selfpost", "cat", "/data/postfix/tls_policy"); err != nil || !strings.Contains(out, "[sink]:25 secure\n") {
t.Errorf("tls_policy has no secure line for [sink]:25 (%v):\n%s", err, out)
}
if _, page, err := sc.panel.get(base); err != nil || !strings.Contains(page, "over verified TLS") {
t.Errorf("the domain's page does not say verified TLS (%v)", err)
}
// The client's trust: the bundle is set and there, and a self-signed
// certificate is not trusted with it.
out, err := h.execIn("selfpost", "sh", "-c", `f=$(postconf -h smtp_tls_CAfile) && echo "$f" && test -s "$f" && echo present`)
if err != nil || !strings.Contains(out, "/etc/ssl/certs/ca-certificates.crt\npresent") {
t.Fatalf("smtp_tls_CAfile (%v):\n%s", err, out)
}
out, _ = h.execIn("selfpost", "posttls-finger", "-c", "-l", "secure", "-F", "/etc/ssl/certs/ca-certificates.crt", "[127.0.0.1]:25")
if !strings.Contains(out, "Untrusted TLS connection established") || strings.Contains(out, "Verified TLS connection") {
t.Errorf("posttls-finger -l secure against the stand's self-signed port 25:\n%s", out)
}
}