Files
selfpost/test/e2e/health_served_test.go
T
mixandClaude Opus 5.5 98e09fef4a
test / test (push) Canceled after 0s
health: sockets dialled, the certificate Postfix serves, "deferred" only for the queue
Audit 2026-10-12, stage 2c, "Health says what is served"; the pages of
ea8b6bf's mockup.

Sockets (A-38). A unix socket's file outlives a killed OpenDKIM or panel
and refuses every connect() (unix(7)); Health said "Listening" from a
stat. CheckSocket and CheckMilter's unix case now dial: refused is
"Nothing listens on the ... socket: the program that made it has
stopped", EACCES is unknown (the panel may not connect), anything else
"does not answer". The probe closes before a byte, which no milter logs:
libmilter takes the EOF as SMFIC_EOF without a line (sendmail
8.17.1.9-2+deb12u2, the image's libmilter1.0.1, libmilter/comm.c
mi_rd_cmd(), engine.c mi_engine() - its source is now in dev/upstream),
OpenDKIM's mlfi_close() has no connection to free, go-milter v0.4.1
returns quietly on io.EOF (session.go, HandleMilterCommands()). The page
polls every 5 s while in view, so this mattered.

Certificate (A-65). Postfix serves build/postfix-config.sh's copy,
refreshed by cert-reload every TLS_RELOAD_INTERVAL_SECONDS. The panel now
reads smtpd_tls_cert_file from postconf -h at its start (as the retry
policy) and TLS_RELOAD_INTERVAL_SECONDS; CheckServedCertificate checks the
served copy and, when the mounted file differs byte for byte (cert-reload's
own cmp -s), names it as Renewal: its expiry and mtime, two facts on the
card. Waiting is fine for the interval: a nearing expiry of the served one
is then no warning (an expiry still an error); past interval + 1 h it is a
warning pointing at cert-reload's lines. The notifier sends no expiry
notice while a renewal waits on time.

Wording (D-P1, D-P3, and the same for the spam filter): Sender check
note, help topics sender-check, upstream, filter, certificate - word for
word as in the mockup; the filter row's detail too.

Tests: stale socket file (Linux) refused for CheckSocket and CheckMilter;
CheckServedCertificate (same, waiting, late, expired + waiting, no served
path); intervalText; the card's facts; the notifier quiet while a renewal
waits, speaking once it is late. Stand (2026-10-15, make e2e, exit 0):
new health_shows_what_is_served - the three sockets dialled ok by the
panel's user; a certificate mounted without its key is refused by
cert-reload ("do not belong together") and the card shows the served
expiry, "Renewal expires" with the mounted one's, "Renewal mounted", and
"Postfix still serves the certificate above."; a matching pair is then
served again. go vet and go test ./... pass on the server (24 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:44:21 +03:00

140 lines
4.4 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"
"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 three sockets through the selfpost group,
// as Postfix does: three ok rows and no detail under any.
sockets := box(health(), "sockets")
if n := strings.Count(sockets, `<tr><td>`); n != 3 {
t.Fatalf("Milter sockets has %d rows, want 3:\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)
}