Files
selfpost/build/Dockerfile
T
mixandClaude Opus 5.5 bdeb01681d local-time: the panel in the container's zone, named on Settings
Agreed by the owner on 2026-10-13 (the local day, no "UTC" beside a
time); the zone is the container's TZ in .env and Settings only shows it,
his "Да" of 2026-10-17. Mockups c205106.

Every time the panel shows is formatted in time.Local and says no zone:
the logs' Time and the DMARC Received columns, a message's timeline,
Overview's "Updated", Health's certificate and renewal, the filter lists,
DMARC reports and windows, the auto limit's last recalculation. Stored
instants stay UTC. The days counted are the zone's: delivery_log_days and
sender_check_days key their rows by the local date, the sender check cuts
downtime at local midnights (byDay), the log filters' days run from local
midnight to local midnight. Notification mail and the test message write
the zone's abbreviation after a time - they are read away from the panel.
DMARC's one-day window label stays the reporter's (UTC) day.

Settings box 05, Time zone, read-only: the name (UTC without TZ), its
offset, and a TZ the image cannot load said to leave the times in UTC
(time/zoneinfo_unix.go, initLocal) rather than named. TZ is passed by the
compose file ("${TZ:-}"), in .env.example and the guide's table; tzdata is
named in the image (supervisor's python3.13 brings it today). Postfix
takes TZ through import_environment, so mail.log's stamps are the same
zone, which closes the trap the roadmap wrote down: the tailer has always
read them in time.Local.

Tests: a TestMain runs view, handlers, store and cmd/panel in UTC whatever
the machine's zone (as logtail's); TestSettingsShowsTheTimeZone,
TestTimesAreTheContainersZone, TestByDayInTheContainersZone, a Moscow day
in TestQueryInboundSpamFilters; envdoc's pattern takes two-letter names.
e2e time_zone_is_the_containers recreates the stand with
TZ=Europe/Moscow: date says MSK, Settings names the zone and UTC+03:00, a
test message's page gives its time and its mail.log lines in Moscow's.
go vet + go test (26 packages) and make e2e on the test server for this
tree.

Docs: guide (§ Settings, the TZ row, the days of Health and the logs),
architecture, schema-migrations, help (word for word with the mockup),
CHANGELOG [Unreleased] § Added, roadmap row done with the decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:34:28 +03:00

194 lines
10 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.
# tzdata is named for TZ (local-time): the zone of every time the panel shows
# and of the stamps Postfix writes in mail.log. supervisor's python3.13 brings
# it today (apt-cache rdepends --installed tzdata in the image, 2026-10-17);
# a TZ the image did not have would leave both in UTC without a word.
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 \
tzdata \
&& 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"]