health: sockets dialled, the certificate Postfix serves, "deferred" only for the queue
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:
mix
2026-10-15 00:44:21 +03:00
co-authored by Claude Opus 5.5
parent ea8b6bfa6d
commit 98e09fef4a
22 changed files with 523 additions and 27 deletions
+52 -10
View File
@@ -1,8 +1,12 @@
package health
import (
"errors"
"fmt"
"net"
"os"
"syscall"
"time"
)
// Socket is the state of one milter socket Postfix connects to.
@@ -17,7 +21,7 @@ type Socket struct {
Detail string
}
// CheckSocket stats a milter socket. required distinguishes the two milters:
// CheckSocket checks a milter socket. required distinguishes the two milters:
// Postfix attaches OpenDKIM with default_action=tempfail, so a missing socket
// stops mail leaving the server, while the journal-milter is attached with
// default_action=accept and fails open — mail still goes out, only the send
@@ -25,10 +29,17 @@ type Socket struct {
// 3.7.11, src/milter/milter8.c, milter8_comm_error(): tempfail answers "451
// 4.7.1 Service unavailable - try again later", accept answers nothing).
//
// A socket file that is there is reported as listening. It is not proof of
// it: a unix socket's file outlives the process that bound it unless that
// process unlinks it (unix(7)), so a killed OpenDKIM or panel leaves one that
// refuses every connection - assumptions A-38.
// The socket is dialled, as Postfix will dial it; a file is not a listener.
// A unix socket's file outlives the process that bound it unless that process
// unlinks it, and a connect() to it then fails with ECONNREFUSED (unix(7)), so
// a killed OpenDKIM or panel leaves a file that refuses every connection -
// assumptions A-38. The connection is closed before a byte is sent, which
// none of the three milters logs: OpenDKIM's libmilter reads the EOF as
// SMFIC_EOF and ends the session without a line (sendmail 8.17.1.9,
// libmilter/comm.c, mi_rd_cmd(); engine.c, mi_engine()), and OpenDKIM's
// mlfi_close() has no connection to clean up (opendkim 2.11.0~beta2,
// opendkim/opendkim.c); go-milter v0.4.1 returns quietly on io.EOF
// (session.go, HandleMilterCommands()).
func CheckSocket(name, path string, required bool) Socket {
s := Socket{Name: name, Path: path}
if path == "" {
@@ -45,13 +56,38 @@ func CheckSocket(name, path string, required bool) Socket {
s.Status = missingStatus(required)
s.Detail = fmt.Sprintf("%s exists but is not a socket.", path)
default:
s.Present = true
s.Status = StatusOK
s.Detail = "Listening"
switch err := dialUnix(path); {
case err == nil:
s.Present = true
s.Status = StatusOK
s.Detail = "Listening"
case errors.Is(err, syscall.EACCES):
s.Status = StatusUnknown
s.Detail = fmt.Sprintf("The panel may not connect to %s, so whether anything listens is not known.", path)
case errors.Is(err, syscall.ECONNREFUSED):
s.Status = missingStatus(required)
s.Detail = fmt.Sprintf("Nothing listens on the %s socket: the program that made it has stopped. %s", name, missingCost(required))
default:
s.Status = missingStatus(required)
s.Detail = fmt.Sprintf("The %s socket does not answer. %s", name, missingCost(required))
}
}
return s
}
// unixDialTimeout bounds a dial of a local socket; a listener that is there
// answers at once, from the kernel's backlog.
const unixDialTimeout = 2 * time.Second
// dialUnix connects to the unix socket at path and closes the connection.
func dialUnix(path string) error {
c, err := net.DialTimeout("unix", path, unixDialTimeout)
if err != nil {
return err
}
return c.Close()
}
func missingStatus(required bool) Status {
if required {
return StatusError
@@ -60,8 +96,14 @@ func missingStatus(required bool) Status {
}
func missingDetail(name string, required bool) string {
return fmt.Sprintf("The %s socket is missing. %s", name, missingCost(required))
}
// missingCost is what a milter that cannot be reached costs, by its
// default_action (see CheckSocket).
func missingCost(required bool) string {
if required {
return fmt.Sprintf("The %s socket is missing. Postfix rejects mail with a temporary error until it is back.", name)
return "Postfix rejects mail with a temporary error until it is back."
}
return fmt.Sprintf("The %s socket is missing. Mail still goes out, but the send log is not being written.", name)
return "Mail still goes out, but the send log is not being written."
}