diff --git a/build/Dockerfile b/build/Dockerfile index 1e418af..60cc1eb 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -70,8 +70,8 @@ RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel # sender map, while OpenDKIM and Postfix (different users) must read them. # Membership in this group — plus setgid dirs under /data (set up in # entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read -# the sasldb2/sender map, and lets the panel reach the supervisor control socket -# to signal OpenDKIM/Postfix reloads without any process running as root. +# the sasldb2/sender map. (The supervisor control socket has a group of its +# own, `supervisor`, below.) # The id is fixed, and it is the one the group has always had: an operator # gives the TLS private key on the host to this group by number, so that the # panel can put it into a full backup (guide § Taking a full backup). @@ -97,6 +97,11 @@ RUN groupadd --system --gid 998 selfpost \ # root:$setgid_group 2755. Linux, Documentation/userspace-api/no_new_privs.rst: # with no_new_privs, execve() no longer changes the gid for a setgid file.) RUN usermod -aG postdrop panel +# The supervisord control socket's group (supervisord.conf, [unix_http_server]): +# the panel alone - not OpenDKIM's or Postfix's users, which share `selfpost` +# for the files under /data. +RUN groupadd --system supervisor \ + && usermod -aG supervisor panel # Runtime directories: milter sockets and the consolidated persistent root. RUN mkdir -p /run/opendkim /run/selfpost /data \ diff --git a/build/entrypoint.sh b/build/entrypoint.sh index 5d427be..969ccec 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -180,6 +180,17 @@ chmod 2750 /run/opendkim /run/selfpost # (test(1), -S: true for any socket file, whether or not anything listens.) rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive +# The container's /dev/log: a link to the socket the syslog-relay program (the +# panel's binary, as `panel`) listens on, so what OpenDKIM logs through +# syslog(3) reaches the container's log (build/supervisord.conf; audit +# 2026-10-12, L30). The directory is the relay's and reachable by all: the +# socket is the container's local log, as /dev/log is on a host. +mkdir -p /run/syslog +chown panel:panel /run/syslog +chmod 0755 /run/syslog +rm -f /run/syslog/log +ln -sfn /run/syslog/log /dev/log + # Generate the outbound-relay Postfix configuration from the environment (spec # 5). Kept out of the image build so cert paths, rate limits, hostname and the # optional 587 service are all driven by env at run time, and re-derived on every diff --git a/build/opendkim.conf b/build/opendkim.conf index 4a96e46..86614d4 100644 --- a/build/opendkim.conf +++ b/build/opendkim.conf @@ -14,10 +14,25 @@ # dkimf_db_open(): a file or refile table is read line by line; an empty file # is an empty table, not an error.) -Syslog no +# Logged through syslog(3) to /dev/log, which the syslog-relay program passes +# to the container's log (build/supervisord.conf; audit 2026-10-12, L30). The +# errors are what is wanted - a key that cannot be read, a table that cannot +# be opened; a line per signed message (SyslogSuccess) is not. +Syslog yes UMask 007 Mode s +# relaxed/simple: the header fields relaxed, so a relay that refolds or +# respaces one does not break the signature; the body simple, as signed +# (opendkim.conf.5.in, Canonicalization: header/body, the default +# simple/simple; RFC 6376 § 3.4). OversignHeaders From lists From: in h= once +# more than it is present, so a From: added on the way fails the signature +# instead of being shown above the signed one (opendkim.conf.5.in, +# OversignHeaders; RFC 6376 § 8.15) - the replay 2.4.0's sender check is +# about. +Canonicalization relaxed/simple +OversignHeaders From + # SigningTable uses refile: so the "*@example.com" left-hand patterns match any # local-part for a domain; KeyTable maps each domain to its key and selector. # (opendkim/opendkim.conf.5.in, SigningTable: with "refile" the keys are diff --git a/build/supervisord.conf b/build/supervisord.conf index ba6b6d8..844698a 100644 --- a/build/supervisord.conf +++ b/build/supervisord.conf @@ -26,17 +26,20 @@ logfile_maxbytes=0 pidfile=/run/supervisord.pid loglevel=info -; The control socket is reachable by the shared `selfpost` group so the panel -; (running as the unprivileged `panel` user) can ask supervisord to send -; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still -; unreachable by world; only root and the two SelfPost services are in the -; group. No HTTP auth is configured because access is gated by socket -; permissions, not the network. (supervisor 4.2.5, docs/configuration.rst, -; [unix_http_server]: chmod and chown set the socket's mode and owner; without -; username and password no authentication is asked.) +; The control socket is reachable by the `supervisor` group, whose one member +; is the panel (the unprivileged `panel` user): it asks supervisord to signal +; OpenDKIM after it rewrites the DKIM tables, to run the Postfix reload, and +; for the processes' state. Whoever reaches the socket can stop, start or +; restart any program, so it is not the shared `selfpost` group's: OpenDKIM +; and Postfix are in that one for the files under /data, and a fault in either +; must not reach every other program (audit 2026-10-12, stage 5). Not +; reachable by world. No HTTP auth is configured because access is gated by +; socket permissions, not the network. (supervisor 4.2.5, +; docs/configuration.rst, [unix_http_server]: chmod and chown set the socket's +; mode and owner; without username and password no authentication is asked.) [unix_http_server] file=/run/supervisor.sock -chown=root:selfpost +chown=root:supervisor chmod=0770 [rpcinterface:supervisor] @@ -45,6 +48,23 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface [supervisorctl] serverurl=unix:///run/supervisor.sock +; The container's /dev/log (a link to the socket below, build/entrypoint.sh): +; what OpenDKIM - which logs through syslog(3) and nothing else - sends there +; comes out on stdout, into the container's log (internal/syslogrelay; audit +; 2026-10-12, L30). Before OpenDKIM, so its first lines are not lost; a +; message sent while nothing listens is dropped by syslog(3), not queued. +[program:syslog-relay] +command=/usr/local/bin/panel -syslog-relay=/run/syslog/log +user=panel +priority=50 +autostart=true +autorestart=true +startretries=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + [program:opendkim] command=/usr/sbin/opendkim -f -x /etc/opendkim.conf priority=100 diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 76e9d65..87a3cef 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -28,6 +28,7 @@ import ( "github.com/mixeme/selfpost/internal/inbound" "github.com/mixeme/selfpost/internal/logtail" "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/syslogrelay" ) func main() { @@ -47,12 +48,21 @@ func main() { } showVersion := flag.Bool("version", false, "print version and exit") + relay := flag.String("syslog-relay", "", "relay syslog(3) datagrams sent to this socket path to stdout, and do nothing else") flag.Parse() if *showVersion { fmt.Println(buildinfo.Version) return } + if *relay != "" { + // The container's /dev/log, for OpenDKIM (internal/syslogrelay; + // build/supervisord.conf, [program:syslog-relay]). + if err := syslogrelay.Run(*relay, os.Stdout); err != nil { + log.Fatal(err) + } + return + } log.SetFlags(log.LstdFlags | log.LUTC) log.SetPrefix("panel: ") diff --git a/docs/architecture.md b/docs/architecture.md index d9dd1ce..a64f34f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,6 +26,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)): | Program | User | Priority | Role | |---|---|---|---| +| `syslog-relay` | panel | 50 | The container's `/dev/log`: passes what OpenDKIM logs through syslog(3) to the container's log (`panel -syslog-relay`) | | `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter | | `panel` | panel | 200 | HTTP UI + sender check + journal-milter + log-tailer + rate-limit recalc; with the inbound relay on also the inbound journal milter | | `postfix` | root (wrapper) | 300 | MTA — started only after the milter sockets exist (OpenDKIM's, the sender check's, the journal milter's and, with the inbound relay on, the inbound journal milter's) | @@ -33,7 +34,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)): | `cert-reload` | root | 400 | Daily: re-copies a renewed TLS certificate for Postfix and reloads it | | `logrotate` | root | 400 | Periodic `mail.log` rotation | -Start order: OpenDKIM → panel (opens the sender check's and the journal-milter's sockets) → Postfix wrapper +Start order: syslog-relay → OpenDKIM → panel (opens the sender check's and the journal-milter's sockets) → Postfix wrapper polls unix sockets (timeout `MILTER_WAIT_TIMEOUT`, default 30s) then runs `postfix start-fg` and stays its parent: on TERM it runs `postfix stop`, so a stop of the program or of the container stops Postfix as Postfix means it to diff --git a/docs/security.md b/docs/security.md index 4633024..098edb1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -209,7 +209,10 @@ operator can act on it. - Rendering goes through `html/template` with auto-escaping (queue, log, journal, themes). - The panel process is **not root** (`user=panel` in supervisord); path access - is granted through the `selfpost` group with minimal permissions. + is granted through the `selfpost` group with minimal permissions. The + supervisord control socket, which can stop or restart any program, is the + `supervisor` group's, whose one member is the panel; OpenDKIM and Postfix + share `selfpost` for files and do not reach it (`2.6.0`). ### Mail path (security-relevant) diff --git a/internal/syslogrelay/syslogrelay.go b/internal/syslogrelay/syslogrelay.go new file mode 100644 index 0000000..ac1945f --- /dev/null +++ b/internal/syslogrelay/syslogrelay.go @@ -0,0 +1,87 @@ +// Package syslogrelay passes what programs in the container send to syslog(3) +// on to standard output, where supervisord takes it into the container's log. +// +// OpenDKIM logs through syslog(3) and nothing else (OpenDKIM 2.11.0~beta2, +// opendkim/opendkim.conf.5.in: Syslog, SyslogFacility and SyslogName are the +// only logging options; opendkim/opendkim.c calls openlog() and syslog()). +// syslog(3) sends each message as a datagram to /dev/log, and in the container +// nothing listened there: OpenDKIM's errors - a key it cannot read, a table it +// cannot open - went nowhere (audit 2026-10-12, L30). Postfix is not among the +// senders: it logs through its own postlogd (build/postfix-config.sh). +package syslogrelay + +import ( + "bytes" + "fmt" + "io" + "net" + "os" + "regexp" +) + +// stamp is the BSD syslog timestamp that follows the priority, "Oct 15 +// 09:00:00 " (RFC 3164 § 4.1.2: "Mmm dd hh:mm:ss", the day padded with a +// space). The container's log has times of its own. +var stamp = regexp.MustCompile(`^[A-Z][a-z]{2} [ 0-9][0-9] [0-9]{2}:[0-9]{2}:[0-9]{2} `) + +// Line turns one datagram into the line written out: the priority and the +// timestamp taken off, trailing newlines too; what remains is "ident[pid]: +// message". A severity of error or worse (RFC 3164 § 4.1.1: the priority is +// facility*8 + severity, and 0-3 are emergency to error) is said in front. +func Line(datagram []byte) string { + b := bytes.TrimRight(datagram, "\r\n\x00") + severity := -1 + if len(b) > 2 && b[0] == '<' { + if end := bytes.IndexByte(b, '>'); end > 1 && end <= 4 { + pri := 0 + ok := true + for _, c := range b[1:end] { + if c < '0' || c > '9' { + ok = false + break + } + pri = pri*10 + int(c-'0') + } + if ok { + severity = pri % 8 + b = b[end+1:] + } + } + } + if loc := stamp.FindIndex(b); loc != nil { + b = b[loc[1]:] + } + line := string(b) + if severity >= 0 && severity <= 3 { + line = "error: " + line + } + return line +} + +// Run listens on a datagram socket at path, which anyone in the container may +// write to, and writes each message to w as a line until the socket fails. +// /dev/log is a link to path (build/entrypoint.sh). +func Run(path string, w io.Writer) error { + _ = os.Remove(path) + conn, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"}) + if err != nil { + return fmt.Errorf("syslog relay: listen on %s: %w", path, err) + } + defer conn.Close() + // OpenDKIM runs as its own user; the socket is the container's local log, + // as /dev/log is on a host. + if err := os.Chmod(path, 0o666); err != nil { + return fmt.Errorf("syslog relay: %w", err) + } + buf := make([]byte, 64<<10) + for { + n, _, err := conn.ReadFromUnix(buf) + if err != nil { + return fmt.Errorf("syslog relay: read: %w", err) + } + if n == 0 { + continue + } + fmt.Fprintln(w, Line(buf[:n])) + } +} diff --git a/internal/syslogrelay/syslogrelay_test.go b/internal/syslogrelay/syslogrelay_test.go new file mode 100644 index 0000000..c7f0705 --- /dev/null +++ b/internal/syslogrelay/syslogrelay_test.go @@ -0,0 +1,76 @@ +package syslogrelay + +import ( + "bytes" + "net" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +func TestLine(t *testing.T) { + for in, want := range map[string]string{ + "<22>Oct 15 09:00:00 opendkim[45]: 3A2B: DKIM-Signature field added (s=sp, d=example.com)\n": "opendkim[45]: 3A2B: DKIM-Signature field added (s=sp, d=example.com)", + "<19>Oct 5 09:00:00 opendkim[45]: key retrieval failed": "error: opendkim[45]: key retrieval failed", + "<20>opendkim[45]: no timestamp": "opendkim[45]: no timestamp", + "not syslog at all": "not syslog at all", + "Oct 15 09:00:00 odd": "Oct 15 09:00:00 odd", + } { + if got := Line([]byte(in)); got != want { + t.Errorf("Line(%q) = %q, want %q", in, got, want) + } + } +} + +// syncBuffer is a bytes.Buffer the relay and the test may share. +type syncBuffer struct { + mu sync.Mutex + b bytes.Buffer +} + +func (s *syncBuffer) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.Write(p) +} + +func (s *syncBuffer) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.b.String() +} + +// What a program sends to the socket comes out as a line. +func TestRunRelaysADatagram(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no unixgram sockets on Windows; the container is Linux") + } + path := filepath.Join(t.TempDir(), "log") + out := &syncBuffer{} + go Run(path, out) + var conn net.Conn + var err error + for i := 0; i < 50; i++ { + if conn, err = net.Dial("unixgram", path); err == nil { + break + } + time.Sleep(20 * time.Millisecond) + } + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if _, err := conn.Write([]byte("<19>Oct 15 09:00:00 opendkim[7]: can't load key")); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && !strings.Contains(out.String(), "\n") { + time.Sleep(10 * time.Millisecond) + } + if got := out.String(); got != "error: opendkim[7]: can't load key\n" { + t.Errorf("relayed %q", got) + } +} diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index fade600..64f38b9 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -113,6 +113,9 @@ func TestE2E(t *testing.T) { if err := checkSupervisorProcesses(h); err != nil { t.Fatal(err) } + if err := checkOpenDKIMLogs(h); err != nil { + t.Fatal(err) + } if err := checkInboundRelayOff(h); err != nil { t.Fatal(err) } diff --git a/test/e2e/process_check.go b/test/e2e/process_check.go index 23d3394..3b90e80 100644 --- a/test/e2e/process_check.go +++ b/test/e2e/process_check.go @@ -14,7 +14,7 @@ import ( // excluded: autostart=false, so it rests STOPPED until its first run and // EXITED after it (supervisor 4.2.5, supervisor/process.py, // Subprocess.finish(); assumptions C-V10). -var wantRunning = []string{"opendkim", "panel", "postfix", "cert-reload", "logrotate"} +var wantRunning = []string{"syslog-relay", "opendkim", "panel", "postfix", "cert-reload", "logrotate"} // checkSupervisorProcesses shells into the container directly (not through the // panel's /overview page) so it works before an administrator account even @@ -129,3 +129,15 @@ func checkCitedPackageVersions(s *stack) error { } return nil } + +// checkOpenDKIMLogs waits for OpenDKIM's start line in the container's log: +// OpenDKIM logs through syslog(3) only, and /dev/log is the syslog-relay +// program's (audit 2026-10-12, L30). OpenDKIM 2.11.0~beta2, +// opendkim/opendkim.c: syslog(LOG_INFO, "%s v%s starting%s%s%s", +// DKIMF_PRODUCT, ...), DKIMF_PRODUCT "OpenDKIM Filter" (opendkim.h). +func checkOpenDKIMLogs(s *stack) error { + return waitFor("OpenDKIM's start line in the container log", 20*time.Second, 500*time.Millisecond, func() (bool, error) { + out := s.logs("selfpost") + return strings.Contains(out, "opendkim[") && strings.Contains(out, "OpenDKIM Filter v"), fmt.Errorf("no OpenDKIM line in the log") + }) +}