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>
178 lines
6.1 KiB
Go
178 lines
6.1 KiB
Go
package health
|
|
|
|
import (
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestCheckMilterInetAnswering(t *testing.T) {
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Skipf("tcp unavailable here: %v", err)
|
|
}
|
|
defer l.Close()
|
|
|
|
got := CheckMilter("Spam filter", "inet:"+l.Addr().String(), true)
|
|
if got.Status != StatusOK || !got.Present || got.Detail != "Listening" {
|
|
t.Errorf("listening address: %+v", got)
|
|
}
|
|
if got.Name != "Spam filter" || got.Path != "inet:"+l.Addr().String() {
|
|
t.Errorf("name and path are not carried through: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckMilterInetRefused(t *testing.T) {
|
|
// A port that was just free and is closed again.
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Skipf("tcp unavailable here: %v", err)
|
|
}
|
|
addr := l.Addr().String()
|
|
l.Close()
|
|
|
|
got := CheckMilter("Spam filter", "inet:"+addr, true)
|
|
if got.Present || got.Status != StatusError {
|
|
t.Errorf("closed port, required: %+v", got)
|
|
}
|
|
if want := "Connection to " + addr + " refused."; got.Detail != want {
|
|
t.Errorf("detail = %q, want %q", got.Detail, want)
|
|
}
|
|
if got := CheckMilter("Spam filter", "inet:"+addr, false); got.Status != StatusWarn {
|
|
t.Errorf("closed port, optional: status %q", got.Status)
|
|
}
|
|
}
|
|
|
|
// What a failed dial says is a sentence about host:port, never the Go error.
|
|
func TestMilterDialDetail(t *testing.T) {
|
|
for _, c := range []struct {
|
|
name string
|
|
err error
|
|
want string
|
|
}{
|
|
{"timeout", timeoutErr{}, "No answer from antispam:11332 within 1 s."},
|
|
{"refused", &net.OpError{Op: "dial", Err: errors.New("connectex: No connection could be made because the target machine actively refused it.")}, "Connection to antispam:11332 refused."},
|
|
{"unix-style refused", &net.OpError{Op: "dial", Err: errors.New("connect: connection refused")}, "Connection to antispam:11332 refused."},
|
|
{"no such host", &net.DNSError{Err: "no such host", Name: "antispam", IsNotFound: true}, "The name antispam could not be resolved."},
|
|
{"other", errors.New("dial tcp 10.0.0.1:11332: some internal thing 0xc000"), "Could not connect to antispam:11332."},
|
|
} {
|
|
got := milterDialDetail(c.err, "antispam", "11332")
|
|
if got != c.want {
|
|
t.Errorf("%s: %q, want %q", c.name, got, c.want)
|
|
}
|
|
if strings.Contains(got, "dial tcp") || strings.Contains(got, "0xc0") {
|
|
t.Errorf("%s: raw error leaked: %q", c.name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
type timeoutErr struct{}
|
|
|
|
func (timeoutErr) Error() string { return "i/o timeout" }
|
|
func (timeoutErr) Timeout() bool { return true }
|
|
func (timeoutErr) Temporary() bool { return true }
|
|
|
|
func TestCheckMilterUnix(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sock := filepath.Join(dir, "filter.sock")
|
|
l, err := net.Listen("unix", sock)
|
|
if err != nil {
|
|
t.Skipf("unix sockets unavailable here: %v", err)
|
|
}
|
|
defer l.Close()
|
|
|
|
if got := CheckMilter("Spam filter", "unix:"+sock, true); got.Status != StatusOK || !got.Present {
|
|
t.Errorf("live socket: %+v", got)
|
|
}
|
|
|
|
missing := filepath.Join(dir, "gone.sock")
|
|
got := CheckMilter("Spam filter", "unix:"+missing, true)
|
|
if got.Present || got.Status != StatusError || got.Detail != "The socket file does not exist." {
|
|
t.Errorf("missing socket, required: %+v", got)
|
|
}
|
|
if got := CheckMilter("Spam filter", "unix:"+missing, false); got.Status != StatusWarn {
|
|
t.Errorf("missing socket, optional: status %q", got.Status)
|
|
}
|
|
}
|
|
|
|
func TestCheckMilterUnixNotASocket(t *testing.T) {
|
|
plain := filepath.Join(t.TempDir(), "plain")
|
|
if err := os.WriteFile(plain, nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := CheckMilter("Spam filter", "unix:"+plain, true)
|
|
if got.Present || got.Status != StatusError || got.Detail != "The path exists but is not a socket." {
|
|
t.Errorf("regular file in place of a socket: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestCheckMilterNotCheckable(t *testing.T) {
|
|
for _, addr := range []string{
|
|
"", "antispam:11332", "tcp:antispam:11332", "inet:antispam", "inet::11332", "inet:antispam:", "inet:antispam:http",
|
|
"inet:antispam:0", "inet:antispam:70000", "unix:", "unix", "inet:", " ",
|
|
} {
|
|
got := CheckMilter("Spam filter", addr, true)
|
|
if got.Status != StatusUnknown || got.Present {
|
|
t.Errorf("%q: status %q present=%v, want unknown and not present", addr, got.Status, got.Present)
|
|
}
|
|
if got.Detail == "" {
|
|
t.Errorf("%q: no detail", addr)
|
|
}
|
|
}
|
|
got := CheckMilter("Spam filter", "antispam:11332", true)
|
|
if want := "The address is not unix:/path or inet:host:port, so it cannot be checked."; got.Detail != want {
|
|
t.Errorf("detail = %q, want %q", got.Detail, want)
|
|
}
|
|
}
|
|
|
|
// inet:[::1]:port is the form Postfix takes for an IPv6 address.
|
|
func TestCheckMilterInet6(t *testing.T) {
|
|
l, err := net.Listen("tcp", "[::1]:0")
|
|
if err != nil {
|
|
t.Skipf("IPv6 loopback unavailable here: %v", err)
|
|
}
|
|
defer l.Close()
|
|
_, port, _ := net.SplitHostPort(l.Addr().String())
|
|
if _, err := strconv.Atoi(port); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := CheckMilter("Spam filter", "inet:[::1]:"+port, true); got.Status != StatusOK {
|
|
t.Errorf("IPv6 listener: %+v", got)
|
|
}
|
|
}
|
|
|
|
// A socket file whose listener is gone - a killed OpenDKIM or panel leaves
|
|
// one (unix(7)) - is not a listener, though the file is there (A-38).
|
|
func TestStaleSocketFileIsNotListening(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("a stale AF_UNIX file is a Linux matter here")
|
|
}
|
|
sock := filepath.Join(t.TempDir(), "stale.sock")
|
|
l, err := net.Listen("unix", sock)
|
|
if err != nil {
|
|
t.Skipf("unix sockets unavailable here: %v", err)
|
|
}
|
|
l.(*net.UnixListener).SetUnlinkOnClose(false)
|
|
l.Close()
|
|
if _, err := os.Stat(sock); err != nil {
|
|
t.Fatalf("the socket file should outlive its listener: %v", err)
|
|
}
|
|
|
|
got := CheckSocket("OpenDKIM", sock, true)
|
|
if got.Present || got.Status != StatusError || !strings.HasPrefix(got.Detail, "Nothing listens on the OpenDKIM socket") {
|
|
t.Errorf("stale required socket: %+v", got)
|
|
}
|
|
if got := CheckSocket("send-log", sock, false); got.Status != StatusWarn {
|
|
t.Errorf("stale optional socket: status %q", got.Status)
|
|
}
|
|
got = CheckMilter("Spam filter", "unix:"+sock, true)
|
|
if got.Present || got.Status != StatusError || got.Detail != "Nothing listens on the socket: the filter that made it has stopped." {
|
|
t.Errorf("stale filter socket: %+v", got)
|
|
}
|
|
}
|