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; mockupsb2dcefc,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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."),
|
||||
}},
|
||||
}
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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'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>`)
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
Reference in New Issue
Block a user