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
+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"}}