Files
selfpost/internal/health/cert.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

190 lines
7.0 KiB
Go

package health
import (
"bytes"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// certWarnDays is how close to expiry the certificate has to be before the
// status page complains. certbot renews a 90-day certificate at 30 days left
// - a fixed 30 days before 4.0, a third of the lifetime from 4.0 (certbot
// docs, certbot/docs/using.rst, "Renewing certificates") - so two weeks is
// comfortably past the point where automatic renewal should have happened.
// For a 45-day certificate a third is 15 days, one day ahead of this warning
// (assumptions A-64).
const certWarnDays = 14
// Certificate is the state of the TLS certificate Postfix serves on 465/587
// (guide § Environment variables: TLS_CERT_FILE). The panel only reads it —
// the file is supplied by the reverse proxy through a read-only mount.
// Postfix itself serves the copy build/postfix-config.sh makes in
// /etc/postfix/tls-internal, refreshed by build/postfix-cert-reload.sh up to
// TLS_RELOAD_INTERVAL_SECONDS later, and each smtpd loads it when it starts
// (Postfix 3.7.11, src/smtpd/smtpd.c, pre_jail_init(), TLS_SERVER_INIT) - so
// the mounted file can be newer than what is served (assumptions A-65).
// CheckServedCertificate reads the copy, and names the mounted file as a
// renewal waiting while the two differ.
type Certificate struct {
Path string
Subject string
NotAfter time.Time
DaysLeft int
Status Status
Detail string
// Renewal is the mounted certificate while Postfix serves an older one.
Renewal *Renewal
}
// Renewal is a renewed certificate in the mounted file that Postfix has not
// taken up yet.
type Renewal struct {
NotAfter time.Time
Mounted time.Time // the mounted file's modification time
// Late is true once it has waited longer than cert-reload's interval
// and an hour: the check that should have taken it up has passed.
Late bool
}
// ServedCertPath is the certificate file Postfix serves, smtpd_tls_cert_file
// as postconf prints it (Postfix 3.7.11, postconf(1): -h, the value without
// its name): build/postfix-config.sh's copy when the mounted files were there
// at the start, the mounted file itself otherwise. "" when postconf fails.
// It is read once, at the panel's start, as the retry policy is: main.cf is
// written before supervisord starts the panel and not after.
func ServedCertPath() string {
out, err := exec.Command("postconf", "-h", "smtpd_tls_cert_file").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// CheckServedCertificate checks the certificate Postfix serves (served) and,
// when the mounted file differs from it byte for byte - which is how
// build/postfix-cert-reload.sh decides to copy it (cmp -s) - reports the
// mounted one as a renewal waiting. interval is TLS_RELOAD_INTERVAL_SECONDS:
// cert-reload looks for a renewal that often. A renewal that waits is the
// normal state for up to that long; the served certificate's nearing expiry
// is then no warning, its expiry still an error (clients see what is served).
// One waiting longer is a warning: cert-reload refused it (the key in the
// mount does not belong to it) or Postfix's reload failed, and cert-reload's
// lines in the container log say which. With served empty the mounted file is
// checked, as before.
func CheckServedCertificate(served, mounted string, interval time.Duration, now time.Time) Certificate {
if served == "" {
return CheckCertificate(mounted)
}
c := CheckCertificate(served)
if mounted == "" || mounted == served {
return c
}
have, err1 := os.ReadFile(served)
want, err2 := os.ReadFile(mounted)
fi, err3 := os.Stat(mounted)
if err1 != nil || err2 != nil || err3 != nil || bytes.Equal(have, want) {
return c
}
leaf, err := parseLeaf(want)
if err != nil {
return c
}
r := &Renewal{NotAfter: leaf.NotAfter, Mounted: fi.ModTime(), Late: now.Sub(fi.ModTime()) > interval+time.Hour}
c.Renewal = r
switch {
case r.Late:
c.Status = Worst(c.Status, StatusWarn)
c.Detail = fmt.Sprintf("A renewed certificate has been in the mounted file since %s UTC, longer than Postfix's check for a renewal allows, and Postfix still serves the one above. cert-reload's lines in the container log say why.",
r.Mounted.UTC().Format("2006-01-02 15:04"))
case c.Status == StatusError:
c.Detail += " A renewed one is waiting in the mounted file; Postfix takes it up at its next check for a renewal, at most " + intervalText(interval) + " after it was mounted."
default:
c.Status = StatusOK
c.Detail = "Postfix still serves the certificate above. A renewed one is waiting in the mounted file; Postfix takes it up at its next check for a renewal, at most " + intervalText(interval) + " after it was mounted."
}
return c
}
// intervalText says an interval the way the help says the default: "a day".
func intervalText(d time.Duration) string {
unit, size := "second", time.Second
switch {
case d >= 24*time.Hour && d%(24*time.Hour) == 0:
unit, size = "day", 24*time.Hour
case d >= time.Hour && d%time.Hour == 0:
unit, size = "hour", time.Hour
case d >= time.Minute && d%time.Minute == 0:
unit, size = "minute", time.Minute
}
switch n := int64(d / size); {
case n == 1 && unit == "hour":
return "an hour"
case n == 1:
return "a " + unit
default:
return fmt.Sprintf("%d %ss", n, unit)
}
}
// CheckCertificate parses the leaf certificate at path and reports how much
// validity is left. A missing or unparsable file is an error status rather than
// an error return: the status page reports it in place, like every other check.
func CheckCertificate(path string) Certificate {
c := Certificate{Path: path}
if path == "" {
c.Status = StatusUnknown
c.Detail = "No certificate path is configured (TLS_CERT_FILE)."
return c
}
data, err := os.ReadFile(path)
if err != nil {
c.Status = StatusError
c.Detail = fmt.Sprintf("Could not read the certificate at %s.", path)
return c
}
leaf, err := parseLeaf(data)
if err != nil {
c.Status = StatusError
c.Detail = fmt.Sprintf("%s does not contain a readable certificate.", path)
return c
}
c.Subject = leaf.Subject.CommonName
c.NotAfter = leaf.NotAfter
c.DaysLeft = int(time.Until(leaf.NotAfter).Hours() / 24)
switch {
case !time.Now().Before(leaf.NotAfter):
c.Status = StatusError
c.Detail = "The certificate has expired. Senders will refuse the TLS connection."
case c.DaysLeft < certWarnDays:
c.Status = StatusWarn
c.Detail = fmt.Sprintf("Expires in %d day(s). Check that renewal on the host still works.", c.DaysLeft)
default:
c.Status = StatusOK
c.Detail = fmt.Sprintf("Valid for another %d day(s).", c.DaysLeft)
}
return c
}
// parseLeaf returns the first certificate in a PEM chain — the leaf, which is
// the one whose validity clients see.
func parseLeaf(data []byte) (*x509.Certificate, error) {
for rest := data; len(rest) > 0; {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
return x509.ParseCertificate(block.Bytes)
}
return nil, fmt.Errorf("no CERTIFICATE block found")
}