Files
selfpost/build/crashexit.py
T
mixandClaude Opus 5.5 faa3c11ae7
test / test (push) Waiting to run
panel-dkim-signing stage 2: OpenDKIM removed
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>
2026-10-16 01:05:39 +03:00

116 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Supervisor event listener: bring the container down on unrecoverable failure.
If a managed program of the mail path exhausts its restart retries and
enters FATAL, or keeps crashing - CRASH_LIMIT unexpected exits within
CRASH_WINDOW seconds - this listener signals supervisord (its parent) to
terminate, so the whole container exits and Docker's `restart` policy starts it
again, rather than lingering "alive" with a dead Postfix or panel.
FATAL alone did not cover the second: a program reaches FATAL only through
exits before its startsecs, and one that crashed after it was restarted for
ever (audit 2026-10-12, L32; supervisor 4.2.5, supervisor/process.py,
Subprocess.finish()). logrotate is not on the mail path: its FATAL (a config
it would ignore) stays on Server > Health, and mail keeps flowing
(assumptions C-V5).
Communication with supervisord uses the event listener protocol over stdin and
stdout, so stdout must carry only protocol tokens.
supervisor 4.2.5, docs/events.rst, "Event Listener Notification Protocol": the
listener writes READY\\n, reads one header line of key:value tokens whose len
is the payload's length and eventname the event's type, reads the payload, and
answers "RESULT 2\\nOK". A PROCESS_STATE_FATAL payload is a token set,
"processname:cat groupname:cat from_state:BACKOFF"; a PROCESS_STATE_EXITED one
adds "expected:0" for an exit code the program's exitcodes do not list, "1"
otherwise (docs/events.rst; supervisor/events.py, ProcessStateEvent.payload()).
supervisord starts the listener as its own child, and on SIGTERM it shuts down
(docs/running.rst, "Signals"); it is PID 1 here (build/entrypoint.sh, exec).
CRASHEXIT_DRYRUN=1 writes "crashexit: would shut down" instead of signalling,
for the test (build/crashexit_test.go).
"""
import os
import signal
import sys
import time
# Programs whose failure does not take the container down
# (build/supervisord.conf): logrotate is not on the mail path, postfix-reload
# runs on demand - a reload it could not start is reported to whoever asked for
# it - and cert-reload looks for a renewed certificate once a day (a crash of
# it leaves the one Postfix serves).
SPARED = {"logrotate", "postfix-reload", "cert-reload"}
# CRASH_LIMIT unexpected exits of one program within CRASH_WINDOW seconds are a
# program that does not stay up. A program on the mail path restarts in
# seconds; five times in ten minutes is no passing fault.
CRASH_LIMIT = 5
CRASH_WINDOW = 600
def write_stdout(s):
sys.stdout.write(s)
sys.stdout.flush()
def say(s):
sys.stderr.write("crashexit: " + s + "\n")
sys.stderr.flush()
def shut_down():
if os.environ.get("CRASHEXIT_DRYRUN") == "1":
say("would shut down")
return
os.kill(os.getppid(), signal.SIGTERM)
def main():
crashes = {} # program -> times of its unexpected exits in the window
while True:
# Tell supervisord we are ready for the next event.
write_stdout("READY\n")
line = sys.stdin.readline()
if not line:
return
headers = dict(pair.split(":", 1) for pair in line.split())
payload_len = int(headers.get("len", 0))
payload = sys.stdin.read(payload_len) if payload_len else ""
tokens = dict(
pair.split(":", 1) for pair in payload.split() if ":" in pair
)
name = tokens.get("processname", "")
event = headers.get("eventname", "PROCESS_STATE_FATAL")
if event == "PROCESS_STATE_EXITED":
if name not in SPARED and tokens.get("expected") == "0":
now = time.monotonic()
times = [t for t in crashes.get(name, []) if now - t < CRASH_WINDOW]
times.append(now)
crashes[name] = times
if len(times) >= CRASH_LIMIT:
say(
"%s exited unexpectedly %d times in %d seconds; shutting down container"
% (name, len(times), CRASH_WINDOW)
)
shut_down()
crashes[name] = []
elif name in SPARED:
say(
"%s entered FATAL; it is not on the mail path, the container stays up"
% name
)
else:
say(
"%s entered FATAL; shutting down container"
% (name or "a managed process")
)
shut_down()
write_stdout("RESULT 2\nOK")
if __name__ == "__main__":
main()