Files
selfpost/internal/health/socket.go
T
mixandClaude Opus 5.5 1c6e04b7d3
test / test (push) Canceled after 0s
base-trixie: the image on Debian trixie, Postfix 3.10.13; Received: hidden on 465/587
The runtime base is debian:trixie-slim, the build stage golang:1.26-trixie:
Postfix 3.10.13 with upstream's 2026 security fixes up to the August
batch, libmilter 8.18.1, OpenSSL 3.5. Every citation of another program in
code, scripts and documents was re-read against the trixie sources
(dev/upstream; bookworm's kept in dev/upstream-bookworm) and updated;
development.md's cited-package table holds the trixie versions.

What the re-reading changed in behaviour:
- logtail: reject lines with sasl_method/sasl_username/sasl_sender
  (smtpd_check.c:1019-1026); cleanup's "removed (canceled)" forgets a
  port-25 id (cleanup_api.c:363-364).
- internal/postfix/retry.go reads postconf's stdout only: since 3.9 a
  deprecated-parameter warning on stderr made one value too many and the
  panel fell back to the compiled-in retry policy.
- logrotate-loop.sh: logrotate 3.22 exits 1 when it ignores a config;
  the loop refuses on that output whatever the status.
- postfix-config.sh: cyrus_sasl_config_path set explicitly (bookworm's
  07_sasl_config.diff is gone); maillog_file_permissions=0640; mynetworks
  explicit; Dockerfile: SYNC_CHROOT= in /etc/default/postfix,
  opendkim-tools dropped, openssl named, systemd-standalone-sysusers so
  cron does not bring the whole of systemd.
- The selfpost group is 10998, outside the system range 100-999: in
  trixie a package took 998 and the build failed (owner's decision:
  a conflict-free id, the distribution's own ids untouched). The
  entrypoint hands /data back by name when the user or the group differs.
  Guide, backup refusal text, help (design commit 68ec8b2), e2e.
- smtpd_hide_client_session=yes on smtps and submission (owner's
  decision): Received: names no client, TLS, login or protocol; port 25
  keeps the full header. e2e checks both.
- test/upstream/opendkim-probe.sh makes keys with openssl genrsa.

Tests: on the server, make e2e exit 0 (674 s, the image built on trixie),
go vet + go test 27 packages ok. CHANGELOG [Unreleased], guide,
architecture.md, security.md, roadmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:31:35 +03:00

110 lines
3.7 KiB
Go

package health
import (
"errors"
"fmt"
"net"
"os"
"syscall"
"time"
)
// Socket is the state of one milter socket Postfix connects to.
type Socket struct {
Name string
// Note qualifies the name where the socket is not one of the two Postfix
// always has ("inbound"); the Health table shows it beside the name.
Note string
Path string
Present bool
Status Status
Detail string
}
// 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
// log stops being written (build/postfix-config.sh smtpd_milters; Postfix
// 3.10.13, src/milter/milter8.c, milter8_comm_error(): tempfail answers "451
// 4.7.1 Service unavailable - try again later", accept answers nothing).
//
// 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 (libmilter 8.18.1,
// 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 == "" {
s.Status = StatusUnknown
s.Detail = "No socket path is configured."
return s
}
fi, err := os.Stat(path)
switch {
case err != nil:
s.Status = missingStatus(required)
s.Detail = missingDetail(name, required)
case fi.Mode()&os.ModeSocket == 0:
s.Status = missingStatus(required)
s.Detail = fmt.Sprintf("%s exists but is not a socket.", path)
default:
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
}
return StatusWarn
}
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 "Postfix rejects mail with a temporary error until it is back."
}
return "Mail still goes out, but the send log is not being written."
}