From e6aceeb811d204dde447c475a94cbe929aa909c9 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Sat, 11 Jul 2026 15:14:08 +0300 Subject: [PATCH] Phase 1: Docker image, supervisord, three-process cold start Single bookworm-slim image running opendkim + panel + postfix under supervisord with enforced start ordering (spec 4): - build/Dockerfile: multi-stage static Go build; runtime installs postfix, opendkim, cyrus-sasl, supervisor, logrotate; unprivileged panel user (7.6.8). - build/supervisord.conf: priority ordering opendkim -> panel -> postfix; crashexit event listener terminates the container on any FATAL process. - build/postfix-wrapper.sh: waits for both milter sockets (test -S, 30s timeout) before `postfix start-fg`, exits non-zero on timeout. - panel: HTTP :8080 stub + /healthz, journal-milter socket stub (so the wrapper's readiness probe passes), log-tailer stub; SIGTERM graceful stop. Verified on the dev server: image builds, three processes live, panel serves the stub, wrapper waits for sockets, and an unrecoverable panel failure brings the container down cleanly. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 11 +++++ build/Dockerfile | 79 ++++++++++++++++++++++++++++++++++++ build/crashexit.py | 47 +++++++++++++++++++++ build/opendkim.conf | 14 +++++++ build/postfix-wrapper.sh | 33 +++++++++++++++ build/supervisord.conf | 67 ++++++++++++++++++++++++++++++ cmd/panel/httpserver.go | 67 ++++++++++++++++++++++++++++++ cmd/panel/journalstub.go | 48 ++++++++++++++++++++++ cmd/panel/logtailstub.go | 16 ++++++++ cmd/panel/main.go | 88 ++++++++++++++++++++++++++++++++++++++-- 10 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 build/Dockerfile create mode 100644 build/crashexit.py create mode 100644 build/opendkim.conf create mode 100644 build/postfix-wrapper.sh create mode 100644 build/supervisord.conf create mode 100644 cmd/panel/httpserver.go create mode 100644 cmd/panel/journalstub.go create mode 100644 cmd/panel/logtailstub.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e7b8881 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Keep the build context minimal and free of dev-only material. +.git +.gitignore +.gitattributes +dev +docs +bin +*.exe +README.md +LICENSE +Makefile diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..c85c3b9 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,79 @@ +# syntax=docker/dockerfile:1 +# +# SelfPost — single Debian-slim image running postfix + opendkim + panel under +# supervisord (spec 4). Build from the repository root: +# +# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev . + +# ---- build stage ------------------------------------------------------------- +FROM golang:1.26-bookworm AS build + +WORKDIR /src + +# Version stamped into both binaries; MUST match the image tag (spec 7.5.A). +ARG VERSION=dev + +# Module metadata first for layer caching. No go.sum yet — Phase 1 has no +# third-party dependencies. +COPY go.mod ./ +RUN go mod download + +COPY cmd ./cmd +COPY internal ./internal + +ENV CGO_ENABLED=0 +RUN go vet ./... \ + && go build -trimpath \ + -ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=${VERSION}" \ + -o /out/panel ./cmd/panel \ + && go build -trimpath \ + -ldflags "-X codeberg.org/mix/selfpost/internal/buildinfo.Version=${VERSION}" \ + -o /out/selfpost-backup ./cmd/selfpost-backup + +# ---- runtime stage ----------------------------------------------------------- +FROM debian:bookworm-slim AS runtime + +ENV DEBIAN_FRONTEND=noninteractive + +# Preseed Postfix so its install is non-interactive and yields a working +# main.cf. The real relay configuration is generated by the panel in Phase 5. +RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ + && echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + postfix \ + opendkim \ + opendkim-tools \ + sasl2-bin \ + libsasl2-modules \ + supervisor \ + logrotate \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Postfix logs to a plain file (via its built-in postlogd) so the panel's +# log-tailer has something to follow and container logging works without syslog. +RUN postconf -e "maillog_file=/var/log/mail.log" + +# Unprivileged user for the panel process (spec 7.6.8). +RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel + +# Runtime directories: milter sockets and the consolidated persistent root. +RUN mkdir -p /run/opendkim /run/selfpost /data \ + && chown opendkim:opendkim /run/opendkim \ + && chown panel:panel /run/selfpost /data + +COPY --from=build /out/panel /usr/local/bin/panel +COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup + +COPY build/opendkim.conf /etc/opendkim.conf +COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh +COPY build/crashexit.py /usr/local/bin/crashexit.py +COPY build/supervisord.conf /etc/supervisor/supervisord.conf +RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/crashexit.py + +# 8080 panel; 25 outbound; 465/587 inbound submission (used from Phase 5). +EXPOSE 8080 25 465 587 + +# supervisord is PID 1 and owns process supervision + ordering (spec 4). +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"] diff --git a/build/crashexit.py b/build/crashexit.py new file mode 100644 index 0000000..368d2d3 --- /dev/null +++ b/build/crashexit.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Supervisor event listener: bring the container down on unrecoverable failure. + +If any managed program exhausts its restart retries and enters FATAL, this +listener signals supervisord (its parent) to terminate, so the whole container +exits and Docker's `restart` policy can recreate it cleanly — rather than +lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4). + +Communication with supervisord uses the event listener protocol over stdin and +stdout, so stdout must carry only protocol tokens. +""" +import os +import signal +import sys + + +def write_stdout(s): + sys.stdout.write(s) + sys.stdout.flush() + + +def main(): + 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)) + if payload_len: + sys.stdin.read(payload_len) + + # We only subscribe to PROCESS_STATE_FATAL, so any event means a managed + # program can no longer be restarted. Take the container down. + sys.stderr.write( + "crashexit: a managed process entered FATAL; shutting down container\n" + ) + sys.stderr.flush() + os.kill(os.getppid(), signal.SIGTERM) + + write_stdout("RESULT 2\nOK") + + +if __name__ == "__main__": + main() diff --git a/build/opendkim.conf b/build/opendkim.conf new file mode 100644 index 0000000..510e414 --- /dev/null +++ b/build/opendkim.conf @@ -0,0 +1,14 @@ +# OpenDKIM — Phase 1 minimal config. +# +# The goal here is only that OpenDKIM starts and exposes its milter socket so +# the Postfix start wrapper's readiness probe passes. Per-domain signing +# (KeyTable / SigningTable, Mode s) is generated and reloaded by the panel in +# Phase 3; until keys exist we run in verify-only mode so startup needs no keys. + +Syslog no +UMask 007 +Mode v +Socket local:/run/opendkim/opendkim.sock +PidFile /run/opendkim/opendkim.pid +UserID opendkim +Background no diff --git a/build/postfix-wrapper.sh b/build/postfix-wrapper.sh new file mode 100644 index 0000000..fb1d2b5 --- /dev/null +++ b/build/postfix-wrapper.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would +# try to reach the milter sockets before they are listening. +# +# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter +# — are present, then execs `postfix start-fg`. If they are not ready within the +# timeout it exits non-zero WITHOUT starting Postfix, so supervisord/Docker see +# an explicit startup failure instead of a relay running blind. +# +# This handles cold start only. Runtime milter failures after a successful start +# are governed by fail-open (milter_default_action), configured in Phase 5. +set -eu + +OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}" +JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}" +TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}" +INTERVAL=1 + +elapsed=0 +for sock in "$OPENDKIM_SOCK" "$JOURNAL_SOCK"; do + while [ ! -S "$sock" ]; do + if [ "$elapsed" -ge "$TIMEOUT" ]; then + echo "postfix-wrapper: timed out after ${TIMEOUT}s waiting for milter socket $sock" >&2 + exit 1 + fi + sleep "$INTERVAL" + elapsed=$((elapsed + INTERVAL)) + done + echo "postfix-wrapper: milter socket ready: $sock" +done + +echo "postfix-wrapper: both milter sockets ready, starting postfix" +exec postfix start-fg diff --git a/build/supervisord.conf b/build/supervisord.conf new file mode 100644 index 0000000..9389e93 --- /dev/null +++ b/build/supervisord.conf @@ -0,0 +1,67 @@ +; SelfPost process supervision (spec 4). +; +; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens +; the journal-milter socket), then the Postfix wrapper — which additionally +; blocks until both milter sockets are ready before starting Postfix. +; +; If any managed process exhausts its restart retries (FATAL), the crashexit +; event listener brings the whole container down so Docker's restart policy can +; recreate it cleanly, rather than lingering with a dead component. + +[supervisord] +nodaemon=true +user=root +logfile=/dev/null +logfile_maxbytes=0 +pidfile=/run/supervisord.pid +loglevel=info + +[unix_http_server] +file=/run/supervisor.sock +chmod=0700 + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///run/supervisor.sock + +[program:opendkim] +command=/usr/sbin/opendkim -f -x /etc/opendkim.conf +priority=100 +autostart=true +autorestart=true +startretries=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:panel] +command=/usr/local/bin/panel +user=panel +priority=200 +autostart=true +autorestart=true +startretries=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:postfix] +command=/usr/local/bin/postfix-wrapper.sh +priority=300 +autostart=true +autorestart=true +startretries=3 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[eventlistener:crashexit] +command=/usr/local/bin/crashexit.py +events=PROCESS_STATE_FATAL +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go new file mode 100644 index 0000000..8e55203 --- /dev/null +++ b/cmd/panel/httpserver.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "time" +) + +// serveHTTP runs the panel's HTTP server until ctx is cancelled. Phase 1 serves +// only a placeholder page and a health check; the login flow and real UI arrive +// in Phase 2. +func serveHTTP(ctx context.Context, addr string) error { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", handleHealth) + mux.HandleFunc("/", handleIndex) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + + // Shut the server down cleanly when the process is asked to stop. + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + log.Printf("http panel listening on %s", addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +func handleHealth(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(indexHTML)) +} + +const indexHTML = ` + + + + +SelfPost + + +

SelfPost

+

The control panel is starting up. Administrator setup and login arrive in a later build.

+ + +` diff --git a/cmd/panel/journalstub.go b/cmd/panel/journalstub.go new file mode 100644 index 0000000..73a750a --- /dev/null +++ b/cmd/panel/journalstub.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "errors" + "log" + "net" + "os" + "path/filepath" +) + +// serveJournalStub opens the journal-milter Unix socket so the Postfix start +// wrapper's readiness probe (test -S) succeeds and the cold-start ordering +// (spec 4) can be exercised end to end. The real milter protocol handler is +// implemented in Phase 6; here connections are simply accepted and closed. +func serveJournalStub(ctx context.Context, socketPath string) error { + if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil { + return err + } + // Clear a stale socket left behind by an unclean shutdown, otherwise the + // listen below fails with "address already in use". + if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + ln, err := net.Listen("unix", socketPath) + if err != nil { + return err + } + + // Closing the listener unblocks Accept and unlinks the socket file. + go func() { + <-ctx.Done() + _ = ln.Close() + }() + + log.Printf("journal-milter stub listening on %s", socketPath) + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil { + return nil // expected during shutdown + } + return err + } + _ = conn.Close() + } +} diff --git a/cmd/panel/logtailstub.go b/cmd/panel/logtailstub.go new file mode 100644 index 0000000..480e604 --- /dev/null +++ b/cmd/panel/logtailstub.go @@ -0,0 +1,16 @@ +package main + +import ( + "context" + "log" +) + +// tailMailLog is the Phase 1 placeholder for the log-tailer role. In Phase 6 it +// will follow mail.log and reconcile send-log delivery statuses by queue-id; +// for now it just idles until shutdown so the role is present in the process +// tree and its wiring is exercised. +func tailMailLog(ctx context.Context, path string) error { + log.Printf("log-tailer stub active (will follow %s in Phase 6)", path) + <-ctx.Done() + return nil +} diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 53d0615..004a558 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -2,14 +2,20 @@ // single binary combines several roles (spec 7.1): the HTTP panel server, // the journal-milter, the mail.log tailer and the rate-limit checks. // -// This is the Phase 0 skeleton: it only reports its version so the build -// pipeline (ldflags stamping, docker build) can be wired up end to end. +// Phase 1 wires those roles up as a supervised process with a minimal HTTP +// stub, a journal-milter socket stub (so the Postfix start wrapper's readiness +// probe passes) and a log-tailer stub. Real behaviour lands in later phases. package main import ( + "context" "flag" "fmt" + "log" "os" + "os/signal" + "sync" + "syscall" "codeberg.org/mix/selfpost/internal/buildinfo" ) @@ -23,5 +29,81 @@ func main() { return } - fmt.Fprintf(os.Stdout, "selfpost panel %s (skeleton)\n", buildinfo.Version) + log.SetFlags(log.LstdFlags | log.LUTC) + log.SetPrefix("panel: ") + + if err := run(); err != nil { + log.Fatalf("fatal: %v", err) + } +} + +// config holds the runtime knobs the panel reads from the environment. Every +// value has a safe default so the binary also runs outside the container. +type config struct { + httpAddr string + journalSocket string + mailLog string +} + +func loadConfig() config { + return config{ + httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"), + journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"), + mailLog: envDefault("MAIL_LOG", "/var/log/mail.log"), + } +} + +func envDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// run starts the panel's three roles and blocks until a shutdown signal or the +// first fatal error from any role. A signal triggers a clean stop of all roles; +// a role error cancels the others and is returned so the process exits non-zero +// (letting supervisord/Docker see the failure — spec 4). +func run() error { + cfg := loadConfig() + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + log.Printf("starting selfpost panel %s", buildinfo.Version) + + var wg sync.WaitGroup + errc := make(chan error, 3) + + roles := []struct { + name string + fn func(context.Context) error + }{ + {"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg.httpAddr) }}, + {"journal-milter", func(ctx context.Context) error { return serveJournalStub(ctx, cfg.journalSocket) }}, + {"log-tailer", func(ctx context.Context) error { return tailMailLog(ctx, cfg.mailLog) }}, + } + + for _, r := range roles { + wg.Add(1) + go func(name string, fn func(context.Context) error) { + defer wg.Done() + if err := fn(ctx); err != nil { + errc <- fmt.Errorf("%s: %w", name, err) + } + }(r.name, r.fn) + } + + select { + case <-ctx.Done(): + log.Printf("shutdown signal received, stopping") + wg.Wait() + log.Printf("panel stopped cleanly") + return nil + case err := <-errc: + log.Printf("role failed: %v", err) + stop() // cancel ctx so the other roles wind down + wg.Wait() + return err + } }