Files
selfpost/internal/health/cert.go
T
mixandClaude Opus 5.5 bdeb01681d local-time: the panel in the container's zone, named on Settings
Agreed by the owner on 2026-10-13 (the local day, no "UTC" beside a
time); the zone is the container's TZ in .env and Settings only shows it,
his "Да" of 2026-10-17. Mockups c205106.

Every time the panel shows is formatted in time.Local and says no zone:
the logs' Time and the DMARC Received columns, a message's timeline,
Overview's "Updated", Health's certificate and renewal, the filter lists,
DMARC reports and windows, the auto limit's last recalculation. Stored
instants stay UTC. The days counted are the zone's: delivery_log_days and
sender_check_days key their rows by the local date, the sender check cuts
downtime at local midnights (byDay), the log filters' days run from local
midnight to local midnight. Notification mail and the test message write
the zone's abbreviation after a time - they are read away from the panel.
DMARC's one-day window label stays the reporter's (UTC) day.

Settings box 05, Time zone, read-only: the name (UTC without TZ), its
offset, and a TZ the image cannot load said to leave the times in UTC
(time/zoneinfo_unix.go, initLocal) rather than named. TZ is passed by the
compose file ("${TZ:-}"), in .env.example and the guide's table; tzdata is
named in the image (supervisor's python3.13 brings it today). Postfix
takes TZ through import_environment, so mail.log's stamps are the same
zone, which closes the trap the roadmap wrote down: the tailer has always
read them in time.Local.

Tests: a TestMain runs view, handlers, store and cmd/panel in UTC whatever
the machine's zone (as logtail's); TestSettingsShowsTheTimeZone,
TestTimesAreTheContainersZone, TestByDayInTheContainersZone, a Moscow day
in TestQueryInboundSpamFilters; envdoc's pattern takes two-letter names.
e2e time_zone_is_the_containers recreates the stand with
TZ=Europe/Moscow: date says MSK, Settings names the zone and UTC+03:00, a
test message's page gives its time and its mail.log lines in Moscow's.
go vet + go test (26 packages) and make e2e on the test server for this
tree.

Docs: guide (§ Settings, the TZ row, the days of Health and the logs),
architecture, schema-migrations, help (word for word with the mockup),
CHANGELOG [Unreleased] § Added, roadmap row done with the decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:34:28 +03:00

247 lines
9.4 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.10.13, 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
// Names are the names the certificate is for: its DNS names, or its
// common name when it has none. MatchesHost says whether one of them is
// the server's (ForHost); nil until that is asked.
Names []string
MatchesHost *bool
// Renewal is the mounted certificate while Postfix serves an older one.
Renewal *Renewal
leaf *x509.Certificate
}
// ForHost holds the certificate to the server's own name, SELFPOST_HOSTNAME:
// a client that verifies the name - Go's crypto/tls does by default, so does
// any MTA under DANE or MTA-STS - refuses a certificate for another however
// valid it is, so one that is not for host is a failure. The name is matched
// against the DNS names as a TLS client matches it (crypto/x509,
// Certificate.VerifyHostname: a wildcard for one label), and against the
// common name only when there are none - which Go refuses but a sending
// Postfix still takes: its TLS client names the host with SSL_add1_host()
// and only X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS (Postfix 3.10.13,
// src/tls/tls_client.c:439, 513), and OpenSSL's X509_check_host() checks the
// subject CN when there is no DNS subjectAltName unless told
// X509_CHECK_FLAG_NEVER_CHECK_SUBJECT (OpenSSL 3.5, X509_check_host(3)).
// Nothing is said with no certificate read or no host.
func (c Certificate) ForHost(host string) Certificate {
if c.leaf == nil || host == "" {
return c
}
var ok bool
if len(c.leaf.DNSNames) > 0 {
ok = c.leaf.VerifyHostname(host) == nil
} else {
ok = strings.EqualFold(strings.TrimSuffix(c.leaf.Subject.CommonName, "."), strings.TrimSuffix(host, "."))
}
c.MatchesHost = &ok
if !ok {
c.Status = StatusError
c.Detail = fmt.Sprintf("None of the certificate's names is %s (SELFPOST_HOSTNAME): a client that verifies the name refuses the connection, however valid the certificate is. Mount a certificate for %s.", host, host)
}
return c
}
// SMTPBanner is Postfix's smtpd_banner with its parameters expanded, as
// postconf -x prints it: what smtpd puts after "220 " when a client connects
// (Postfix 3.10.13, src/smtpd/smtpd.c:5979, smtpd_chat_reply(state, "220
// %s", var_smtpd_banner); postconf(1) -x expands $name). build/postfix-
// config.sh sets it to "$myhostname ESMTP". It is read once, at the start,
// as ServedCertPath is; "" when postconf fails.
func SMTPBanner() string {
out, err := exec.Command("postconf", "-x", "-h", "smtpd_banner").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// 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.10.13, 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, 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.Local().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.Names = leaf.DNSNames
if len(c.Names) == 0 && c.Subject != "" {
c.Names = []string{c.Subject}
}
c.leaf = leaf
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")
}