Files
selfpost/test/e2e/cert_reload_check.go
mixandClaude Opus 5.5 6023feb405
test / test (push) Canceled after 0s
audit 6: documents, comments, dead code and staticcheck; the test gaps closed
The last stage of the 2026-10-12 audit; no cut.

Doc drift: architecture.md (the sender check in § OpenDKIM, the limit:
row's 48 hours, the inbound log and filter-list routes and who may open
them, migrations to 0011 and every table, KitPage as production data,
Reload configuration as a restore writes, the panel's roles counted),
schema-migrations.md (tables and columns since v2), guide.md (a delivery's
lines go at the next prune; INBOUND_JOURNAL_MILTER_SOCKET moved to
architecture.md's internal list), development.md (the env guard, the
deferral the e2e proves, its fixed sleeps), roadmap.md.

Comments say what the code does now; TestBaselineIsTheOnlyMigration is
TestChainStartsAtTheBaseline; the milter package doc names its three
milters.

Dead code gone: view.Facts, dmarc HostedSuggestion and
ValidateHostedAddress, app UpdateMode (each with the test that alone kept
it), the method checks the method-bound routes made dead;
Settings.WithResult calls resultFlash. staticcheck 2026.1 in test.yml on
both modules, clean: the e2e package builds without its tests now (a test
and two constants moved out of non-test files), three findings fixed.
The test moved, TestImageBuildPreservesLogrotateMode, had sat in a
non-test file and never run; run, it failed on the entrypoint (docker run
without a hostname) and now asks with --entrypoint stat.

The test gaps no step named, each closed: follow() with mail.log gone past
blindMin and made again; CheckRestore with a manifest that is not JSON;
the origin check with a port; every /server/ pattern in web.go through
server() and in the routes table (it found GET /server/health/fragment
missing from the table - guarded, not tested); L9 as the owner decided it
on 2026-10-12, the comment on senderSession.Headers and the test passing
an h that holds the earlier messages' From:.

Plan stage 6 ticked; roadmap audit row done, 81/81.

Tested on the test server: go vet + go test of 27 packages, make e2e (all
pass, 513 s) on this tree; staticcheck locally on both modules.

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

97 lines
3.1 KiB
Go

package e2e
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"time"
)
// servedSerial is the serial of the certificate Postfix presents on the
// implicit-TLS port.
func servedSerial() (*big.Int, error) {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", smtpsAddr, &tls.Config{
InsecureSkipVerify: true,
ServerName: selfpostHostname,
})
if err != nil {
return nil, err
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
return nil, fmt.Errorf("no certificate presented")
}
return certs[0].SerialNumber, nil
}
func fileSerial(path string) (*big.Int, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(raw)
if block == nil {
return nil, fmt.Errorf("%s holds no PEM block", path)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
return cert.SerialNumber, nil
}
// checkCertRenewalPickedUp replaces the certificate in the stand's mount, as
// a renewer would, and waits for port 465 to present it without a restart.
// Postfix reads a copy made at start (postfix-config.sh); until 2.5.0 nothing
// renewed that copy, and a renewed certificate reached Postfix only when the
// container was restarted (audit 2026-10-12, H2). compose.override.yml sets
// TLS_RELOAD_INTERVAL_SECONDS to 2 so this does not wait a day.
func checkCertRenewalPickedUp(s *stack) error {
certPath := filepath.Join(s.stageDir, "certs", "fullchain.pem")
before, err := servedSerial()
if err != nil {
return fmt.Errorf("read the served certificate: %w", err)
}
if err := writeSelfSignedCert(certPath, filepath.Join(s.stageDir, "certs", "privkey.pem")); err != nil {
return fmt.Errorf("renew the certificate: %w", err)
}
want, err := fileSerial(certPath)
if err != nil {
return err
}
if want.Cmp(before) == 0 {
return fmt.Errorf("the renewed certificate has the old serial %s", before)
}
if err := waitFor("port 465 to present the renewed certificate", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
got, err := servedSerial()
if err != nil {
return false, err
}
if got.Cmp(want) != 0 {
return false, fmt.Errorf("serial %s served, want %s", got, want)
}
return true, nil
}); err != nil {
return err
}
// The path that was broken is the one taken: Postfix reads the copy, and
// the copy is the renewed file, the key private to root.
out, err := s.execIn("selfpost", "sh", "-c",
"postconf -h smtpd_tls_cert_file && cmp /etc/postfix/tls/fullchain.pem /etc/postfix/tls-internal/fullchain.pem && "+
"cmp /etc/postfix/tls/privkey.pem /etc/postfix/tls-internal/privkey.pem && stat -c '%a %U' /etc/postfix/tls-internal/privkey.pem")
if err != nil {
return fmt.Errorf("the certificate Postfix reads: %v\n%s", err, out)
}
if !strings.Contains(out, "/etc/postfix/tls-internal/fullchain.pem\n") || !strings.Contains(out, "600 root") {
return fmt.Errorf("the certificate Postfix reads: %q, want Postfix reading it and the key 600 root", out)
}
return nil
}