health: sockets dialled, the certificate Postfix serves, "deferred" only for the queue
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
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>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user