Files
selfpost/build/Dockerfile
T
mixandClaude Opus 5.5 f1d90a2a1b
test / test (push) Waiting to run
go-milter: build it patched not to keep every header
The security documentation's last open item of panel-dkim-signing (the
owner's decision, 2026-10-16): go-milter v0.4.1 added every header of a
session to a map of its own, emptied only on ABORT, and handed it to
Headers - which no milter of the panel reads. Postfix bounds one header,
not their number, so a message of nothing but headers up to the message
size limit was held whole by the library in the sender check, the signer
and the journal milter on 465/587 and in the inbound journal milter on port
25, where strangers send; the signer's 1 MiB refusal came after it was all
in.

third_party/go-milter is v0.4.1 from the module cache (BSD 2-Clause,
LICENSE kept) with session.go changed: CodeHeader hands the header to
Header and keeps nothing, CodeEOH hands Headers an empty map. PATCHES.md
says what and why, and how to take a new upstream version. go.mod replaces
the module with it; the Dockerfile copies third_party before "go mod
download". TestTheLibraryKeepsNoHeaders holds it through the library's own
client over two messages of one connection. Comments, security.md (the
accepted risk becomes a note), development.md's library table, the plan and
CHANGELOG follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:19:57 +03:00

189 lines
9.8 KiB
Docker

# syntax=docker/dockerfile:1
#
# SelfPost — single Debian-slim image running postfix + panel under
# supervisord. Build from the repository root:
#
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
# ---- build stage -------------------------------------------------------------
FROM golang:1.26-trixie AS build
WORKDIR /src
# Version stamped into both binaries; MUST match the image tag.
ARG VERSION=dev
# Module metadata first for layer caching. third_party/go-milter is a module
# go.mod replaces go-milter with (third_party/go-milter/PATCHES.md): the
# download needs it.
COPY go.mod go.sum ./
COPY third_party ./third_party
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
ENV CGO_ENABLED=0
RUN go vet ./... \
&& go build -trimpath \
-ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=${VERSION}" \
-o /out/panel ./cmd/panel \
&& go build -trimpath \
-ldflags "-X github.com/mixeme/selfpost/internal/buildinfo.Version=${VERSION}" \
-o /out/selfpost-backup ./cmd/selfpost-backup
# ---- runtime stage -----------------------------------------------------------
FROM debian:trixie-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.
# (postfix 3.10.13-0+deb13u1, debian/templates:14 and :50:
# postfix/main_mailer_type and postfix/mailname are the questions the install
# asks; debian/postfix.postinst:156-171 writes main.cf for any answer but "No
# configuration".)
# openssl is named because postfix-cert-reload.sh runs it, not left to arrive
# with ca-certificates. python3 arrives with supervisor (supervisor 4.2.5-3,
# debian/control:29, ${python3:Depends}) - crashexit.py. sasl2-bin brings
# saslpasswd2 (cyrus-sasl2 2.1.28+dfsg1-9, debian/rules:207,
# debian/sasl2-bin.install:3, usr/sbin) and depends on db-util
# (debian/control:36); libsasl2-modules brings the PLAIN and LOGIN plugins
# (debian/libsasl2-modules.install:4 and :6); the sasldb auxprop plugin is in
# libsasl2-modules-db (debian/libsasl2-modules-db.install:1), a Depends of
# libsasl2-2 (debian/control:65). db-util is named for /usr/bin/db_dump, which
# the panel runs (internal/app/sasl.go); that db-util ships that path on
# trixie is not in a source read here - the image build and the e2e sasldb
# test say whether it does.
# No DKIM package is installed: the panel signs itself (internal/milter,
# sign.go) and makes the keys in Go (internal/domain/dkim.go); OpenDKIM left
# the image in 2.8.0 (docs/plans/panel-dkim-signing.md).
# systemd-standalone-sysusers is named so that cron-daemon-common, which
# logrotate brings (logrotate 3.22.0-1, debian/control: "Depends: cron |
# anacron | cron-daemon | systemd-sysv"), creates its group with it rather
# than with the whole of systemd, the first alternative of its "Depends:
# systemd | systemd-standalone-sysusers | systemd-sysusers" (dpkg -s in the
# image, 2026-10-15). Nothing here runs systemd.
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 \
sasl2-bin \
libsasl2-modules \
db-util \
supervisor \
logrotate \
ca-certificates \
openssl \
curl \
systemd-standalone-sysusers \
&& rm -rf /var/lib/apt/lists/*
# Debian's postfix runs its chroot sync on every "postfix check" and "postfix
# start-fg" (postfix 3.10.13-0+deb13u1, debian/patches/
# debian-run-configure-instance-from-create-missing.patch); with no service
# chrooted (build/postfix-config.sh) it would only copy /etc/resolv.conf and
# the like into the queue under /data on each start. SYNC_CHROOT empty turns
# it off (debian/configure-instance.in:22-24 and :44; debian/README.Debian:136-138).
RUN printf 'SYNC_CHROOT=\n' > /etc/default/postfix
# Unprivileged user for the panel process, with ids of its own outside the
# system range for the same reason as selfpost's below: its files under
# /data show on the host by number, and a number from that range is a
# system group of the host's too (useradd gave the panel group 998 in the
# trixie image, systemd-network on the test server's host; bookworm's
# 999:999 was the host's systemd-journal group). The entrypoint gives /data
# back to it by name on every start, so files of the old ids follow.
RUN groupadd --system --gid 10999 panel && useradd --system --uid 10999 --gid panel --no-create-home --shell /usr/sbin/nologin panel
# Shared group bridging the unprivileged services: the panel writes the
# application SASL accounts (sasldb2) and the Postfix sender map and opens the
# milter sockets, while Postfix (another user) must read and reach them.
# Membership in this group - plus setgid dirs under /data and /run/selfpost
# (set up in entrypoint.sh) - lets Postfix read the sasldb2/sender map and
# connect to the panel's milters. The DKIM keys are the panel's alone. (The
# supervisor control socket has a group of its own, `supervisor`, below.)
# The id is fixed, because the host sees it: 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). It lies outside the
# range the distribution hands out to system groups, 100-999 (adduser 3.152,
# /etc/adduser.conf: "Default: FIRST_SYSTEM_GID=100, LAST_SYSTEM_GID=999";
# passwd 1:4.17.4-2, /etc/login.defs: SYS_GID_MIN 101, SYS_GID_MAX 999 - both
# read in the image, 2026-10-15), so no package can take it, and far above
# the 1000 a host starts its users' groups at, so a host is unlikely to have
# a group of that number that would read the key too. Until 2.7.0 it was
# 998, inside that range: in the trixie image systemd took 998 for
# systemd-network and the build failed, and on the test server's host 998
# is systemd-network too (seen 2026-10-15).
# groupadd(8) refuses a --gid already in use, so an id taken after all would
# fail this build rather than share the group.
RUN groupadd --system --gid 10998 selfpost \
&& usermod -aG selfpost panel \
&& usermod -aG selfpost postfix
# The panel reads the mail queue with `postqueue -p` (and the status page's
# queue card). postqueue is setgid postdrop, which normally gives it the group
# needed to reach Postfix's showq socket - but the documented deployment runs
# with `no-new-privileges`, which disables setgid transitions, so the panel
# would always see "Permission denied". Making `panel` a real member of postdrop
# grants the same access without relying on a setgid escalation the hardening
# deliberately forbids. The same membership is what lets the panel submit its
# own notifications with sendmail(1) - postdrop, too, is setgid and would fail
# to write the maildrop directory (internal/notify/mailer.go). It does not let
# the panel bypass any Postfix restriction that a local user does not already
# have through the world-executable sendmail.
# (Postfix 3.10.13, conf/postfix-files:140-141: postdrop and postqueue are
# root:$setgid_group 2755; Debian sets them root:postdrop 02555, postfix
# 3.10.13-0+deb13u1, debian/postfix.postinst:134-141. 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 Postfix's user, which shares `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/selfpost /data \
&& 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
# Licence text shipped with the image (AGPL-3.0 conveyance). The panel also
# serves the same text at /license from an embedded copy. OFL.txt is the SIL
# Open Font License 1.1 that must travel with the IBM Plex WOFF2 files
# embedded in the panel binary (NOTICE).
COPY --chmod=0644 LICENSE NOTICE /usr/share/doc/selfpost/
COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.txt
# File modes are pinned here so a build context copied from a checkout without
# POSIX permissions (e.g. Windows tar sync) cannot land group-writable config
# that logrotate would silently ignore — see docs/development.md § Building
# binaries and the image.
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
COPY --chmod=0755 build/postfix-reload.sh /usr/local/bin/postfix-reload.sh
COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh
COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py
COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional),
# inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080.
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
EXPOSE 8080 465 587 25
# Liveness probe: panel HTTP plus mail-path processes (panel, postfix).
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
# The entrypoint fixes /data ownership (bind mount) as root, then execs
# supervisord, which becomes PID 1 and owns process supervision.
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]