Files
selfpost/test/e2e/health_served_test.go
T
mixandClaude Opus 5.5 a8fd96c752 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>
2026-10-16 23:51:42 +03:00

200 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package e2e
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// testHealthShowsWhatIsServed: Server › Health dials the milter sockets as
// Postfix does, rather than finding their files (assumptions A-38), and shows
// the certificate Postfix serves, naming a different mounted one as a renewal
// waiting (A-65).
func testHealthShowsWhatIsServed(t *testing.T, sc *scenario) {
health := func() string {
t.Helper()
resp, body, err := sc.panel.get("/server/health")
if err != nil || resp.StatusCode != 200 {
t.Fatalf("GET /server/health: %v, %v", resp, err)
}
return body
}
box := func(body, id string) string {
t.Helper()
i := strings.Index(body, `id="`+id+`"`)
if i < 0 {
t.Fatalf("no box %s on Health:\n%.2000s", id, body)
}
rest := body[i:]
if j := strings.Index(rest[1:], `<div class="box`); j > 0 {
rest = rest[:j+1]
}
return rest
}
// The panel's user reaches all four sockets - the signer's two, the
// sender check's, the journal's - through the selfpost group, as Postfix
// does: four ok rows and no detail under any.
sockets := box(health(), "sockets")
if n := strings.Count(sockets, `<tr><td>`); n != 4 {
t.Fatalf("Milter sockets has %d rows, want 4:\n%s", n, sockets)
}
if strings.Contains(sockets, `sp-muted sp-small">`) {
t.Fatalf("a milter socket is not answering the panel's dial:\n%s", sockets)
}
// A certificate mounted with a key it does not belong to: cert-reload
// refuses to copy it (build/postfix-cert-reload.sh, same_pair), so
// Postfix keeps serving the old one and the mounted one waits.
certPath := filepath.Join(h.stageDir, "certs", "fullchain.pem")
keyPath := filepath.Join(h.stageDir, "certs", "privkey.pem")
served, err := servedNotAfter()
if err != nil {
t.Fatal(err)
}
renewalNotAfter := time.Now().Add(72 * time.Hour).UTC().Truncate(time.Minute)
if err := writeCertWithoutItsKey(certPath, renewalNotAfter); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
// A pair that belongs together, picked up as any renewal is.
_ = writeSelfSignedCert(certPath, keyPath)
_ = waitFor("the mended certificate served", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
want, err := fileSerial(certPath)
if err != nil {
return false, err
}
got, err := servedSerial()
return err == nil && got.Cmp(want) == 0, err
})
})
if err := waitFor("cert-reload to refuse the mismatched pair", 20*time.Second, 500*time.Millisecond, func() (bool, error) {
return strings.Contains(h.logs("selfpost"), "do not belong together"), nil
}); err != nil {
t.Fatalf("%v\n%s", err, h.logs("selfpost"))
}
cert := box(health(), "certificate")
for _, want := range []string{
"<dt>Expires</dt><dd class=\"sp-mono\">" + served.UTC().Format("2006-01-02 15:04 UTC"),
"<dt>Renewal expires</dt><dd class=\"sp-mono\">" + renewalNotAfter.Format("2006-01-02 15:04 UTC"),
"<dt>Renewal mounted</dt>",
"Postfix still serves the certificate above.",
} {
if !strings.Contains(cert, want) {
t.Fatalf("the TLS certificate card lacks %q:\n%s", want, cert)
}
}
}
// servedNotAfter is the expiry of the certificate port 465 presents.
func servedNotAfter() (time.Time, error) {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", smtpsAddr, &tls.Config{
InsecureSkipVerify: true,
ServerName: selfpostHostname,
})
if err != nil {
return time.Time{}, err
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
return time.Time{}, fmt.Errorf("no certificate presented")
}
return certs[0].NotAfter, nil
}
// writeCertWithoutItsKey writes a certificate for a key that is thrown away,
// leaving the mounted key as it was.
func writeCertWithoutItsKey(certPath string, notAfter time.Time) error {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return err
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: selfpostHostname},
DNSNames: []string{selfpostHostname},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: notAfter,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return err
}
return os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644)
}
// testFormPostWithTheBrowsersOrigin: a browser on a plain-HTTP panel at a
// name sends no Sec-Fetch-Site (Fetch Metadata § 3: only for a potentially
// trustworthy URL), and its form POST - a navigation - carries Origin by the
// page's Referrer-Policy (Fetch Standard § 3.2): its own origin under the
// panel's same-origin, which passes; "null" under no-referrer, which the
// panel sent until 2.5.2 and which the origin check refuses (assumptions
// D-B3). The client here is not a browser: it sends what one would.
func testFormPostWithTheBrowsersOrigin(t *testing.T, sc *scenario) {
resp, page, err := sc.panel.get("/server/health")
if err != nil {
t.Fatal(err)
}
if got := resp.Header.Get("Referrer-Policy"); got != "same-origin" {
t.Fatalf("Referrer-Policy %q, want same-origin", got)
}
// The setup checks of health-setup-checks, on a stand that passes them:
// the stand's certificate is for its hostname, Postfix greets with it
// (postconf -x smtpd_banner, read at the panel's start), and the client
// reaches the panel directly, with no proxy in between.
for _, want := range []string{
`<dt>Names <span class="tag is-success is-light">matches</span></dt>`,
`<div class="sp-wide"><dt>Greeting</dt><dd class="sp-mono">220 ` + selfpostHostname + ` ESMTP</dd>`,
`<dt>Your address</dt>`, `The connection&#39;s own; no proxy in between said otherwise`,
} {
if !strings.Contains(page, want) {
t.Fatalf("Server › Health lacks %q:\n%.3000s", want, page)
}
}
post := func(origin string) int {
t.Helper()
req, err := http.NewRequest(http.MethodPost, panelBaseURL+"/server/health/recheck", strings.NewReader(""))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if origin != "" {
req.Header.Set("Origin", origin)
}
resp, err := sc.panel.http.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
if code := post(panelBaseURL); code != http.StatusOK {
t.Fatalf("POST with the page's own Origin: status %d, want 200 after the redirect", code)
}
if code := post("null"); code != http.StatusForbidden {
t.Fatalf("POST with Origin: null: status %d, want 403", code)
}
// Neither Sec-Fetch-Site nor Origin: nothing says where it came from, and
// it is refused (origin-strict, 2.10.4; allowed before as an accepted risk).
if code := post(""); code != http.StatusForbidden {
t.Fatalf("POST with neither header: status %d, want 403", code)
}
}