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:
+52
-10
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user