From 6b4106f60e6942b3c90d7886ffe829e9566f9be6 Mon Sep 17 00:00:00 2001 From: Mix Date: Sun, 18 Oct 2026 10:27:37 +0300 Subject: [PATCH] upstream-tls-verify: Verified TLS to an inbound domain's upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 7 ++ build/postfix-config.sh | 12 +++- docs/guide.md | 15 +++- docs/roadmap.md | 2 +- internal/inbound/service.go | 3 + internal/inbound/service_test.go | 26 +++++++ internal/inbound/validate.go | 15 +++- internal/postfix/inbound.go | 2 +- internal/postfix/inbound_test.go | 15 ++++ internal/store/inbound.go | 5 +- .../migrations/0014_upstream_tls_secure.sql | 17 +++++ internal/store/schema_test.go | 27 ++++++-- internal/web/view/help.go | 2 +- internal/web/view/inbound.go | 7 +- internal/web/view/templates/in_domain.html | 3 +- test/e2e/main_test.go | 3 + test/e2e/upstream_tls_test.go | 68 +++++++++++++++++++ 17 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 internal/store/migrations/0014_upstream_tls_secure.sql create mode 100644 test/e2e/upstream_tls_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c7dec15..d110acf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.` beside the DKIM and SPF diff --git a/build/postfix-config.sh b/build/postfix-config.sh index a48a4e3..e1dd3e9 100644 --- a/build/postfix-config.sh +++ b/build/postfix-config.sh @@ -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, diff --git a/docs/guide.md b/docs/guide.md index b6028ac..86972c0 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -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 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index c42f83f..f1ff24c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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` | — | — | diff --git a/internal/inbound/service.go b/internal/inbound/service.go index f6a0ec0..10b42b7 100644 --- a/internal/inbound/service.go +++ b/internal/inbound/service.go @@ -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 diff --git a/internal/inbound/service_test.go b/internal/inbound/service_test.go index 22ee6cb..9845a9e 100644 --- a/internal/inbound/service_test.go +++ b/internal/inbound/service_test.go @@ -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) + } +} diff --git a/internal/inbound/validate.go b/internal/inbound/validate.go index 9b96036..7db13a5 100644 --- a/internal/inbound/validate.go +++ b/internal/inbound/validate.go @@ -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": diff --git a/internal/postfix/inbound.go b/internal/postfix/inbound.go index a43941d..2c7f282 100644 --- a/internal/postfix/inbound.go +++ b/internal/postfix/inbound.go @@ -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) } diff --git a/internal/postfix/inbound_test.go b/internal/postfix/inbound_test.go index 03d9087..8088fc5 100644 --- a/internal/postfix/inbound_test.go +++ b/internal/postfix/inbound_test.go @@ -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) + } +} diff --git a/internal/store/inbound.go b/internal/store/inbound.go index 08b6036..7fb55e7 100644 --- a/internal/store/inbound.go +++ b/internal/store/inbound.go @@ -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" ) diff --git a/internal/store/migrations/0014_upstream_tls_secure.sql b/internal/store/migrations/0014_upstream_tls_secure.sql new file mode 100644 index 0000000..97b69bd --- /dev/null +++ b/internal/store/migrations/0014_upstream_tls_secure.sql @@ -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; diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index 89736f9..61330a7 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -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) + } +} diff --git a/internal/web/view/help.go b/internal/web/view/help.go index 6ae24ed..a1060e5 100644 --- a/internal/web/view/help.go +++ b/internal/web/view/help.go @@ -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."), diff --git a/internal/web/view/inbound.go b/internal/web/view/inbound.go index 3ee90fb..06b273d 100644 --- a/internal/web/view/inbound.go +++ b/internal/web/view/inbound.go @@ -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" } diff --git a/internal/web/view/templates/in_domain.html b/internal/web/view/templates/in_domain.html index 54acb05..e93950b 100644 --- a/internal/web/view/templates/in_domain.html +++ b/internal/web/view/templates/in_domain.html @@ -29,7 +29,8 @@
+ +

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.

{{template "body_close"}} diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 1e04cdc..d7b8cc6 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -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) diff --git a/test/e2e/upstream_tls_test.go b/test/e2e/upstream_tls_test.go new file mode 100644 index 0000000..310f03b --- /dev/null +++ b/test/e2e/upstream_tls_test.go @@ -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) + } +}