health-setup-checks: the certificate's name, Postfix's greeting, the proxy

Decided by the owner on 2026-10-13 (in place of preflight), taken up on
2026-10-16; mockups b2dcefc, cc33890, 0feaa55 (his: the greeting on a row
of its own, the name check a tag beside Names).

- The certificate: health.Certificate keeps its names (DNS names, or the
  CN without any) and ForHost holds it to SELFPOST_HOSTNAME as a TLS
  client does - VerifyHostname over the DNS names, the CN only without
  any, as OpenSSL's X509_check_host and so a sending Postfix still take it
  (src/tls/tls_client.c:439, 513). A certificate for another name fails;
  the tag beside Names says matches / not for the hostname.
- The greeting: postconf -x -h smtpd_banner, read once at the panel's
  start like the served certificate's path (main.cf is not rewritten
  after), is what smtpd writes after "220 " (src/smtpd/smtpd.c:5979). A
  first word other than the hostname warns, with what to set. A fact may
  now take the whole row (Fact.Wide, sp-wide) and carry a tag beside its
  label (Fact.LabelTag); both on the kit page.
- The proxy: auth.Module.Seen says the address the panel counts sign-ins
  against, the peer, and whether X-Forwarded-For was read or left unread
  for an untrusted peer; the Configuration card shows "Your address" and,
  in the second case, warns that every visitor shares one sign-in limit
  and names the /32 (or /128) to put in TRUSTED_PROXY_CIDR.

Tests: TestCertificateForHost (CN-only, DNS names, a wildcard, the CN
ignored beside DNS names), TestSeenAddress, TestHealthSaysWhereItSeesTheViewer,
TestHealthGreetingAndCertificateName; e2e reads the stand's right state on
Health (matches, "220 mail.e2e.test ESMTP", a direct client). go vet + go
test (26 packages) and make e2e on the test server for this tree.

Docs: help (word for word with the mockup), guide § Overview and Health,
CHANGELOG [Unreleased] § Added, roadmap row done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-16 23:51:42 +03:00
co-authored by Claude Opus 5.5
parent 0feaa552d1
commit a8fd96c752
22 changed files with 364 additions and 14 deletions
+57
View File
@@ -37,8 +37,60 @@ type Certificate struct {
DaysLeft int
Status Status
Detail string
// Names are the names the certificate is for: its DNS names, or its
// common name when it has none. MatchesHost says whether one of them is
// the server's (ForHost); nil until that is asked.
Names []string
MatchesHost *bool
// Renewal is the mounted certificate while Postfix serves an older one.
Renewal *Renewal
leaf *x509.Certificate
}
// ForHost holds the certificate to the server's own name, SELFPOST_HOSTNAME:
// a client that verifies the name - Go's crypto/tls does by default, so does
// any MTA under DANE or MTA-STS - refuses a certificate for another however
// valid it is, so one that is not for host is a failure. The name is matched
// against the DNS names as a TLS client matches it (crypto/x509,
// Certificate.VerifyHostname: a wildcard for one label), and against the
// common name only when there are none - which Go refuses but a sending
// Postfix still takes: its TLS client names the host with SSL_add1_host()
// and only X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS (Postfix 3.10.13,
// src/tls/tls_client.c:439, 513), and OpenSSL's X509_check_host() checks the
// subject CN when there is no DNS subjectAltName unless told
// X509_CHECK_FLAG_NEVER_CHECK_SUBJECT (OpenSSL 3.5, X509_check_host(3)).
// Nothing is said with no certificate read or no host.
func (c Certificate) ForHost(host string) Certificate {
if c.leaf == nil || host == "" {
return c
}
var ok bool
if len(c.leaf.DNSNames) > 0 {
ok = c.leaf.VerifyHostname(host) == nil
} else {
ok = strings.EqualFold(strings.TrimSuffix(c.leaf.Subject.CommonName, "."), strings.TrimSuffix(host, "."))
}
c.MatchesHost = &ok
if !ok {
c.Status = StatusError
c.Detail = fmt.Sprintf("None of the certificate's names is %s (SELFPOST_HOSTNAME): a client that verifies the name refuses the connection, however valid the certificate is. Mount a certificate for %s.", host, host)
}
return c
}
// SMTPBanner is Postfix's smtpd_banner with its parameters expanded, as
// postconf -x prints it: what smtpd puts after "220 " when a client connects
// (Postfix 3.10.13, src/smtpd/smtpd.c:5979, smtpd_chat_reply(state, "220
// %s", var_smtpd_banner); postconf(1) -x expands $name). build/postfix-
// config.sh sets it to "$myhostname ESMTP". It is read once, at the start,
// as ServedCertPath is; "" when postconf fails.
func SMTPBanner() string {
out, err := exec.Command("postconf", "-x", "-h", "smtpd_banner").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// Renewal is a renewed certificate in the mounted file that Postfix has not
@@ -155,6 +207,11 @@ func CheckCertificate(path string) Certificate {
}
c.Subject = leaf.Subject.CommonName
c.Names = leaf.DNSNames
if len(c.Names) == 0 && c.Subject != "" {
c.Names = []string{c.Subject}
}
c.leaf = leaf
c.NotAfter = leaf.NotAfter
c.DaysLeft = int(time.Until(leaf.NotAfter).Hours() / 24)
switch {
+45
View File
@@ -286,3 +286,48 @@ func TestIntervalText(t *testing.T) {
}
}
}
// A certificate for another name is no certificate for this server: a client
// that checks the name refuses it however valid it is. Its DNS names are
// matched as a TLS client matches them, a wildcard for one label; with none,
// its common name, as OpenSSL - and so a sending Postfix - still does.
func TestCertificateForHost(t *testing.T) {
dir := t.TempDir()
cnOnly := filepath.Join(dir, "cn.pem")
writeCert(t, cnOnly, "mail.example.org", 90*24*time.Hour)
if c := CheckCertificate(cnOnly).ForHost("MAIL.example.org"); c.Status != StatusOK || c.MatchesHost == nil || !*c.MatchesHost {
t.Errorf("a CN-only certificate for the hostname: %+v", c)
}
if c := CheckCertificate(cnOnly).ForHost("other.example.org"); c.Status != StatusError || *c.MatchesHost ||
!strings.Contains(c.Detail, "None of the certificate's names is other.example.org") {
t.Errorf("a certificate for another name: %+v", c)
}
san := filepath.Join(dir, "san.pem")
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "mail.example.org"},
DNSNames: []string{"*.example.net", "relay.example.org"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour)}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(san, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
t.Fatal(err)
}
c := CheckCertificate(san)
if strings.Join(c.Names, " ") != "*.example.net relay.example.org" {
t.Errorf("names = %q, want the DNS names", c.Names)
}
for host, want := range map[string]bool{"mail.example.net": true, "relay.example.org": true, "a.b.example.net": false,
"mail.example.org": false /* the CN does not count beside DNS names */} {
if got := c.ForHost(host); *got.MatchesHost != want {
t.Errorf("ForHost(%s) matches = %v, want %v", host, *got.MatchesHost, want)
}
}
if c := (Certificate{Status: StatusError}).ForHost("mail.example.org"); c.MatchesHost != nil {
t.Error("a certificate that was not read was matched")
}
}