test / test (push) Waiting to run
The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.
Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.
Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.
Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.
KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.
e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.
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 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("dkim-signer", sock, true)
|
|
if got.Present || got.Status != StatusError || !strings.HasPrefix(got.Detail, "Nothing listens on the dkim-signer 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)
|
|
}
|
|
}
|