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
+6
View File
@@ -452,6 +452,12 @@ type Fact struct {
Small bool
Big bool
Note Text
// Wide takes the whole row of the grid (sp-wide): a line a server wrote,
// read as one - Postfix's greeting.
Wide bool
// LabelTag is a verdict on the value, beside the label: the
// certificate's names against the hostname.
LabelTag *Tag
}
// SideMenu is the input of side_menu, the left column of a detail page: groups
+33
View File
@@ -2,6 +2,7 @@ package view
import (
"strconv"
"strings"
"time"
)
@@ -53,6 +54,38 @@ type Health struct {
Configuration Box
Reload Action
// ConfigFacts is the address the panel sees the viewer at
// (WithClientAddress); ConfigProblem says what to do when it is the
// reverse proxy's own.
ConfigFacts []Fact
ConfigProblem Text
}
// WithClientAddress says where the panel sees the viewer's request from:
// client is the address it counts sign-ins against, peer the connection's,
// fromHeader that client came from X-Forwarded-For of a trusted proxy, and
// ignored that the header was there but peer is no trusted proxy - then
// every visitor has the proxy's address and shares one sign-in limit, which
// nothing else shows (auth.Module.Seen; guide § TRUSTED_PROXY_CIDR).
func (h *Health) WithClientAddress(client, peer string, fromHeader, ignored bool) *Health {
f := Fact{Label: "Your address", Value: Plain(client), Mono: true}
switch {
case fromHeader:
f.Note = Plain("From X-Forwarded-For, which the trusted proxy " + peer + " set")
case ignored:
f.Note = Plain("The proxy's own: its X-Forwarded-For is not read")
mask := "/32"
if strings.Contains(peer, ":") {
mask = "/128"
}
h.ConfigProblem = Rich("Every visitor reaches the panel as ", Mono(peer),
", the reverse proxy, and they all share one sign-in limit. Set ", Code("TRUSTED_PROXY_CIDR"),
" to ", Code(peer+mask), " - or the proxy's network - and restart the container.")
default:
f.Note = Plain("The connection's own; no proxy in between said otherwise")
}
h.ConfigFacts = []Fact{f}
return h
}
// SenderCheckRow is one period of the Sender check table.
+8 -3
View File
@@ -145,13 +145,18 @@ var overviewTopics = []HelpTopic{
Rich("Six checks, and the worst of them is the postmark. ", TagOf("ok", ""), " needs nothing; ",
TagOf("warn", ""), " still delivers mail but will stop doing so if ignored; ",
TagOf("fail", ""), " means mail is being lost or refused now. ", Link("/server/health", "Health"),
" shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total."),
" shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total. ",
Em("Your address"), ", in the Configuration card, is the address the panel sees your browser at: behind a reverse proxy it must be your own, taken from ", Code("X-Forwarded-For"),
"; when it is the proxy's, every visitor shares one sign-in limit, and Health warns and names the address to put in ", Code("TRUSTED_PROXY_CIDR"), "."),
}},
{ID: HelpCertificate, Title: "TLS certificate", Body: []Text{
Plain("The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect."),
Rich("The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect. It also fails when none of its names is the hostname (",
Code("SELFPOST_HOSTNAME"), ") - the tag beside ", Em("Names"), " says which: a client that verifies the name refuses it however valid it is."),
}},
{ID: HelpRDNS, Title: "Hostname and reverse DNS", Body: []Text{
Rich("The hostname (", Code("SELFPOST_HOSTNAME"), ") must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone."),
Rich("The hostname (", Code("SELFPOST_HOSTNAME"), ") must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone. ",
Em("Greeting"), " is the line Postfix opens every connection with, ", Code("220"), " and its banner; it must name the hostname too - a receiver compares it with the name it looked up - and a container id or ",
Code("localhost"), " there is a warning."),
}},
}
+2
View File
@@ -145,6 +145,8 @@ func KitPage() *Kit {
{Label: "Label", Value: Plain("Value")},
{Label: "Mono value", Value: Plain("4XcB7k2Jm9z1"), Mono: true},
{Label: "sp-big", Value: Plain("5 days"), Big: true, Note: Plain("a supporting line")},
{Label: "Label with a tag", Value: Plain("a verdict on the value"), LabelTag: &Tag{Status: "ok", Label: "matches"}},
{Label: "sp-wide", Value: Plain("a value that takes the whole row, a line of a server's own"), Mono: true, Wide: true},
},
Timeline: Box{No: "08", Title: "timeline"},
+15
View File
@@ -331,3 +331,18 @@ func TestUserDeleteSaysNothingOfReportAddresses(t *testing.T) {
pageLacks(t, "Delete user", out, `report address`, `will have no`, `The domains`)
}
}
// The Configuration card says where the panel sees the viewer from, and what
// to set when it is the reverse proxy's own address.
func TestHealthSaysWhereItSeesTheViewer(t *testing.T) {
out := renderSignedIn(t, "health", NewHealth(admin(), "").WithClientAddress("198.51.100.7", "172.18.0.1", true, false))
pageHas(t, "Health", out, `<div><dt>Your address</dt><dd class="sp-mono">198.51.100.7</dd><dd class="sp-muted">From X-Forwarded-For, which the trusted proxy 172.18.0.1 set</dd></div>`)
pageLacks(t, "Health", out, `TRUSTED_PROXY_CIDR`)
out = renderSignedIn(t, "health", NewHealth(admin(), "").WithClientAddress("172.18.0.1", "172.18.0.1", false, true))
pageHas(t, "Health", out, `The proxy&#39;s own: its X-Forwarded-For is not read`,
`<p class="mb-3 has-text-danger">Every visitor reaches the panel as <span class="sp-mono">172.18.0.1</span>, the reverse proxy, and they all share one sign-in limit. Set <code>TRUSTED_PROXY_CIDR</code> to <code>172.18.0.1/32</code>`)
out = renderSignedIn(t, "health", NewHealth(admin(), "").WithClientAddress("fd00::1", "fd00::1", false, true))
pageHas(t, "Health", out, `<code>fd00::1/128</code>`)
}
+5
View File
@@ -278,6 +278,11 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.sp-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 1rem 1.5rem; margin: 0; }
.sp-facts dt { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
.sp-facts dd { margin: 0; overflow-wrap: anywhere; }
/* A label may carry a tag, the verdict on its value (the certificate's
names against the hostname); it keeps the tag's own type. */
.sp-facts dt .tag { margin-left: .4rem; text-transform: none; letter-spacing: 0; vertical-align: 1px; }
/* A fact that takes the whole row: a line a server wrote, read as one. */
.sp-facts .sp-wide { grid-column: 1 / -1; }
.sp-facts dd.sp-big { font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
.sp-side { position: sticky; top: 1.5rem; }
.menu-label { font-family: var(--sp-mono); letter-spacing: .1em; }
+1 -1
View File
@@ -169,7 +169,7 @@
{{/* facts is a label / value grid (input: []Fact); Big is for a headline
number, with its supporting line under it. */}}
{{define "facts"}}<dl class="sp-facts">{{range .}}
<div><dt>{{.Label}}</dt><dd{{if and .Mono .Small}} class="sp-mono sp-small"{{else if .Mono}} class="sp-mono"{{else if .Big}} class="sp-big"{{end}}>{{template "inline" .Value}}</dd>{{if .Note}}<dd class="sp-muted">{{template "inline" .Note}}</dd>{{end}}</div>{{end}}
<div{{if .Wide}} class="sp-wide"{{end}}><dt>{{.Label}}{{with .LabelTag}} {{template "tag" .}}{{end}}</dt><dd{{if and .Mono .Small}} class="sp-mono sp-small"{{else if .Mono}} class="sp-mono"{{else if .Big}} class="sp-big"{{end}}>{{template "inline" .Value}}</dd>{{if .Note}}<dd class="sp-muted">{{template "inline" .Note}}</dd>{{end}}</div>{{end}}
</dl>{{end}}
{{/* side_menu is the Bulma menu of a detail page (input: SideMenu): icons,
+3 -1
View File
@@ -15,7 +15,9 @@
{{template "health_mail" .}}
{{template "box_open" .Configuration}}
{{template "body_open"}}
<p class="mb-3">Regenerates the Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads Postfix. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
{{if .ConfigFacts}}<div class="mb-3">{{template "facts" .ConfigFacts}}</div>
{{end}}{{if .ConfigProblem}}<p class="mb-3 has-text-danger">{{template "inline" .ConfigProblem}}</p>
{{end}}<p class="mb-3">Regenerates the Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads Postfix. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
{{template "action" .Reload}}
{{template "body_close"}}
{{template "box_close"}}