Compare commits

...
2 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 446feadd74 release: 2.9.0: DNS records folded, Settings re-checks, /data/opendkim gone
test / test (push) Canceled after 0s
Cut at the owner's word (2026-10-16, "потом режь 2.9.0").

dns-record-collapse (fffee9c, 51f303c): a record DNS holds as shown is one
line, its values behind Show values. settings-save-row (f95c553,
82a53e1): the Settings form is the left column, Save under its fields.
notification-mail-recheck (98e4dfc, 52a9811, 3f5cd9a): Re-check DNS on
Settings, POST /server/settings/dns-recheck. And the release after 2.8.0
removes /data/opendkim (9420d8c): the entrypoint deletes the 2.7.x key
tree once /data/dkim holds a key, and stops on a /data that never ran
2.8.x.

Each tree passed go vet, go test (26 packages) and make e2e on the test
server; dns-record-collapse and settings-save-row ran there as
2.8.0-post.51f303c and 2.8.0-post.82a53e1. No migration, schema 11.

CHANGELOG [2.9.0]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message; the 2.7.x rollback command keeps
naming 2.7.0); development's "from pin" and row 30; roadmap rows and
recommended order. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:46:26 +03:00
mixandClaude Opus 5.5 9420d8c03d entrypoint: /data/opendkim removed, as planned for the release after 2.8.0
2.8.x copied the keys from /data/opendkim to /data/dkim and kept the old
tree for a rollback to 2.7.x "until the next release"
(docs/plans/panel-dkim-signing.md, Q2); the owner's "потом режь 2.9.0"
(2026-10-16) makes this that release. The entrypoint now removes the tree
once /data/dkim holds a key, and says so. A /data with keys under the
old tree only - one that never ran 2.8.x - stops the start with a message
to run 2.8.x first, rather than let the panel give the server's own name
a new key its published record would not match; the tree is left as it
was. Gone with it: the copy into /data/dkim.new, the tree's
normalisation, its exemption from the general hand-over, its key link in
drop_links, and the backup's exclusion of "opendkim" (dead: the panel
never sees the tree now).

Tests: e2e TestStartsOnA28Data replaces TestStartsOnA27Data - a /data as
2.8.x left it (both trees, a queue of foreign uids, a rotation due): the
old tree is gone, the key under /data/dkim is the one left, the selector
table names it, the queue is Postfix's by name; TestRefusesA27Data - the
start stops, says why, leaves the old key and makes no new one. go vet +
go test (26 packages) and make e2e (all pass) on the test server for this
tree.

Docs: guide § Upgrading "To 2.9.0" (rollback to 2.8.x needs nothing, to
2.7.x only from a 2.7.x backup; the 2.7.x rollback paragraph says it is
for 2.8.x only and keeps naming 2.7.0), guide § backup, architecture
(the old tree, § Persistence's table and the restore line), security
§ Backup, development's e2e paragraph, the plan's key step; CHANGELOG
[Unreleased] § Removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:46:04 +03:00
13 changed files with 184 additions and 123 deletions
+21
View File
@@ -5,6 +5,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [2.9.0] - 2026-10-16
The panel's DNS records read faster: a record DNS already holds as shown
is one line, its values on request. Server › Settings puts its Save button
under its own fields and can re-check the records of the server's own
name. `/data/opendkim`, which 2.8.x kept for a rollback to 2.7.x, is
removed at the first start; an installation still on 2.7.x goes through
2.8.x first (guide, § Upgrading). No migration; schema 11.
### Changed ### Changed
- **A DNS record DNS already holds as shown is folded** (dns-record-collapse): - **A DNS record DNS already holds as shown is folded** (dns-record-collapse):
@@ -31,6 +40,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
Health, which nothing on Settings pointed to. `POST Health, which nothing on Settings pointed to. `POST
/server/settings/dns-recheck`, global role. /server/settings/dns-recheck`, global role.
### Removed
- **`/data/opendkim`, the key tree of 2.7.x** (panel-dkim-signing, as
planned for the release after 2.8.0): 2.8.x copied its keys to
`/data/dkim` and kept it for a rollback to 2.7.x; the first start of
this image removes it once `/data/dkim` holds a key, and says so. A
`/data` whose keys are under the old tree only - one that never ran
2.8.x - stops the start rather than give the server's own name a new
key: start 2.8.x once first (guide, § Upgrading). The copy and the
backup's exclusion of the tree went with it. A rollback from here goes
to 2.8.x; to 2.7.x only through a 2.7.x backup.
## [2.8.0] - 2026-10-16 ## [2.8.0] - 2026-10-16
The panel signs outgoing mail with DKIM itself, and OpenDKIM - a 2018 beta The panel signs outgoing mail with DKIM itself, and OpenDKIM - a 2018 beta
+3 -3
View File
@@ -112,8 +112,8 @@ outbound mail will not reach the real internet without DNS, PTR, and port 25.
> local image and pulls nothing: > local image and pulls nothing:
> >
> ```sh > ```sh
> docker build -f build/Dockerfile --build-arg VERSION=2.8.0 \ > docker build -f build/Dockerfile --build-arg VERSION=2.9.0 \
> -t ghcr.io/mixeme/selfpost:2.8.0 . > -t ghcr.io/mixeme/selfpost:2.9.0 .
> ``` > ```
```sh ```sh
@@ -122,7 +122,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:2.8.0 ghcr.io/mixeme/selfpost:2.9.0
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
+20 -34
View File
@@ -87,7 +87,7 @@ drop_links() {
done done
} }
drop_links /data/dkim /data/dkim.new /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \ drop_links /data/dkim /data/dkim.new /data/opendkim /data/sasl /data/postfix /data/antispam /data/log \
/data/dkim/keys /data/dkim/selectors /data/opendkim/keys \ /data/dkim/keys /data/dkim/selectors \
/data/sasl/sasldb2 /data/postfix/queue \ /data/sasl/sasldb2 /data/postfix/queue \
/data/postfix/sender_login_maps /data/postfix/relay_domains \ /data/postfix/sender_login_maps /data/postfix/relay_domains \
/data/postfix/transport /data/postfix/relay_recipients /data/postfix/tls_policy \ /data/postfix/transport /data/postfix/relay_recipients /data/postfix/tls_policy \
@@ -96,51 +96,37 @@ drop_links /data/dkim /data/dkim.new /data/opendkim /data/sasl /data/postfix /da
# under /data; make sure they stay writable without disturbing anything that a # under /data; make sure they stay writable without disturbing anything that a
# later phase deliberately hands to another service. /data/log is exempt: it is # later phase deliberately hands to another service. /data/log is exempt: it is
# deliberately owned by postfix (postlogd writes the delivery log there) and is # deliberately owned by postfix (postlogd writes the delivery log there) and is
# normalised on its own below, as are the trees shared through group selfpost # normalised on its own below, as are the trees shared through group selfpost.
# and the one OpenDKIM read until 2.8.0, which is left as it was (below).
# A file's owner is a number: an entry whose user or group is not the panel's # A file's owner is a number: an entry whose user or group is not the panel's
# by that number - unpacked by root, or written under another image whose # by that number - unpacked by root, or written under another image whose
# image gave the panel other ids (999:999 until 2.7.0, 10999:10999 since) - is # image gave the panel other ids (999:999 until 2.7.0, 10999:10999 since) - is
# handed over whole. # handed over whole.
find /data -mindepth 1 -maxdepth 1 ! -name log ! -name postfix ! -name opendkim ! -name sasl ! -name antispam \ find /data -mindepth 1 -maxdepth 1 ! -name log ! -name postfix ! -name sasl ! -name antispam \
\( ! -user panel -o ! -group panel \) -exec chown -R panel:panel {} + \( ! -user panel -o ! -group panel \) -exec chown -R panel:panel {} +
# DKIM keys and the selector table, /data/dkim: the panel's alone - it makes # DKIM keys and the selector table, /data/dkim: the panel's alone - it makes
# the keys, writes the table and signs (internal/domain, Keys; internal/milter, # the keys, writes the table and signs (internal/domain, Keys; internal/milter,
# sign.go). Until 2.8.0 the keys were under /data/opendkim, shared with # sign.go). The selector table is written by the panel before its signer
# OpenDKIM through group selfpost. That tree is kept for an image of 2.7.x # serves (cmd/panel/signer.go). Until 2.8.0 the keys were under
# rolled back to (docs/plans/panel-dkim-signing.md, Q2), but nothing of # /data/opendkim, shared with OpenDKIM; 2.8.x copied them to /data/dkim at its
# 2.8.0 reads it beyond the copy below: it is the panel's alone like the new # first start and kept the old tree for a rollback to 2.7.x until the next
# one - a 2.7.x start gives it back to group selfpost itself - and a backup # release, this one (docs/plans/panel-dkim-signing.md, Q2). It is removed
# leaves it out (internal/backup, excludedFromArchive). While /data/dkim # once /data/dkim holds a key. A /data that never ran 2.8.x - keys under the
# holds no key and the old tree does, the keys are copied over. The copy is # old tree only - stops the start instead: the panel would make the server's
# made as the panel user, which can read nothing there the panel could not, # own name a new key, and its published record would no longer match; 2.8.x
# into a directory beside, renamed into place when it is whole: a start that # copies them (guide § Upgrading). rm -rf removes a link, not what it points
# stops midway copies again. A copy that fails stops the start (set -e) - the # to, and follows none inside (GNU coreutils rm(1)); a link at the path is
# panel would otherwise make the server's own name a new key, and its # gone already (drop_links). (e2e TestStartsOnA28Data, TestRefusesA27Data.)
# published record would no longer match. The selector table is written by
# the panel before its signer serves (cmd/panel/signer.go). After a rollback
# to 2.7.x, a domain added there has its key only in the old tree: its
# directory is copied into /data/dkim/keys by hand (guide § Upgrading).
# (util-linux 2.41, setpriv(1): --reuid, --regid, --clear-groups; cp(1)
# -P: never follow a symbolic link in the source; chown -R and find never
# follow one either. GNU chmod keeps a directory's set-group-ID bit under a
# numeric mode unless it is cleared by name - "g-s" - and 2.7.x set it on
# the old tree: e2e TestStartsOnA27Data.)
has_keys() { [ -d "$1" ] && [ -n "$(find "$1" -type f -name '*.private' | head -n 1)" ]; } has_keys() { [ -d "$1" ] && [ -n "$(find "$1" -type f -name '*.private' | head -n 1)" ]; }
if [ -d /data/opendkim ]; then if [ -d /data/opendkim ]; then
chown -R panel:panel /data/opendkim if ! has_keys /data/dkim/keys && has_keys /data/opendkim/keys; then
find /data/opendkim -type d -exec chmod 0700 {} + -exec chmod g-s {} + echo "entrypoint: the DKIM keys are only under /data/opendkim, as 2.7.x kept them; start 2.8.x once first - it copies them to /data/dkim - then this image (guide, § Upgrading)" >&2
find /data/opendkim -type f -exec chmod 0600 {} + exit 1
fi
rm -rf /data/opendkim
echo "entrypoint: removed /data/opendkim, the key tree of 2.7.x; the keys are under /data/dkim" >&2
fi fi
rm -rf /data/dkim.new rm -rf /data/dkim.new
if ! has_keys /data/dkim/keys && has_keys /data/opendkim/keys; then
setpriv --reuid=panel --regid=panel --clear-groups -- \
sh -c 'mkdir /data/dkim.new && cp -RP /data/opendkim/keys /data/dkim.new/keys'
rm -rf /data/dkim
mv /data/dkim.new /data/dkim
echo "entrypoint: the DKIM keys were copied from /data/opendkim to /data/dkim; /data/opendkim is left for a rollback" >&2
fi
mkdir -p /data/dkim/keys mkdir -p /data/dkim/keys
chown -R panel:panel /data/dkim chown -R panel:panel /data/dkim
find /data/dkim -type d -exec chmod 0700 {} + -exec chmod g-s {} + find /data/dkim -type d -exec chmod 0700 {} + -exec chmod g-s {} +
+1 -1
View File
@@ -23,7 +23,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:2.8.0 image: ghcr.io/mixeme/selfpost:2.9.0
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+9 -11
View File
@@ -309,14 +309,13 @@ then OpenDKIM did, in a program of its own
lines; nothing is written to `mail.log` (nor did OpenDKIM write there). lines; nothing is written to `mail.log` (nor did OpenDKIM write there).
- **The old tree,** `/data/opendkim` - the keys, `KeyTable` and - **The old tree,** `/data/opendkim` - the keys, `KeyTable` and
`SigningTable` until `2.8.0`, shared with OpenDKIM through group `SigningTable` until `2.8.0`, shared with OpenDKIM through group
`selfpost`. While `/data/dkim` holds no key and the old tree does, the `selfpost`. `2.8.x` copied its keys to `/data/dkim/keys` at its first
entrypoint copies `/data/opendkim/keys` to `/data/dkim/keys` as the panel start and kept it for a rollback to a 2.7.x image; since `2.9.0` the
user (`setpriv`), into `/data/dkim.new`, renamed when whole; a copy that entrypoint removes it once `/data/dkim` holds a key, and a `/data` with
fails stops the start - the server's name would otherwise get a new key keys under the old tree only - one that never ran 2.8.x - stops the start
and its published record would no longer match. Keys, selectors and DNS (the server's name would otherwise get a new key and its published record
records do not change. The old tree is left for a rollback to a 2.7.x would no longer match). Under 2.8.x it was the panel's alone, like the
image until the next release, made the panel's alone at every start like new one, and left out of backups.
the new one, and left out of backups (§ Persistence).
### Panel binary ([cmd/panel](../cmd/panel)) ### Panel binary ([cmd/panel](../cmd/panel))
@@ -1100,7 +1099,6 @@ single-connection trade-off that follows from it.
| `selfpost.db` | SQLite panel state (users, sessions, domains, apps, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [Database](#database) and [schema-migrations.md](schema-migrations.md) | | `selfpost.db` | SQLite panel state (users, sessions, domains, apps, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [Database](#database) and [schema-migrations.md](schema-migrations.md) |
| `setup-token` | First-run setup token file | | `setup-token` | First-run setup token file |
| `dkim/` | DKIM keys (`keys/<name>/<selector>.private`) and the selector table (`selectors`) the signer reads; the panel's alone, `0700` / `0600` — § DKIM signing | | `dkim/` | DKIM keys (`keys/<name>/<selector>.private`) and the selector table (`selectors`) the signer reads; the panel's alone, `0700` / `0600` — § DKIM signing |
| `opendkim/` | The key tree OpenDKIM read until `2.8.0` (keys, `KeyTable`, `SigningTable`): copied to `dkim/` once, then kept only for a rollback to 2.7.x until the next release; the panel's alone, excluded from backups |
| `sasl/sasldb2` | Application SASL credentials | | `sasl/sasldb2` | Application SASL credentials |
| `postfix/sender_login_maps` | Login → From binding | | `postfix/sender_login_maps` | Login → From binding |
| `postfix/relay_domains` | Inbound domains accepted on port 25 | | `postfix/relay_domains` | Inbound domains accepted on port 25 |
@@ -1180,8 +1178,8 @@ the stored log lines follow;
`postfix reload` in `postrotate` — see § Log tailer above). `postfix reload` in `postrotate` — see § Log tailer above).
**Restore:** panel button or `selfpost-backup` CLI — self-contained archive: **Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
`data/` (SQLite snapshot + tree minus `log/`, the setup token, the old `data/` (SQLite snapshot + tree minus `log/`, the setup token and any
`opendkim/` tree and any `tls/` under `/data`), `docker-compose.yml`, `.env`, `tls/` under `/data`), `docker-compose.yml`, `.env`,
and `certs/` when present; and `certs/` when present;
version check on restore. Requires the project directory mounted read-only at version check on restore. Requires the project directory mounted read-only at
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
+8 -6
View File
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see 2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)). [§ Release image](#release-image)).
**Planned version cuts** (from pin `2.8.0`; adjust if semver changes mid-track): **Planned version cuts** (from pin `2.9.0`; adjust if semver changes mid-track):
| Stage | ID | Cut | | Stage | ID | Cut |
|---|---|---| |---|---|---|
@@ -140,6 +140,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
| 27 | port25-bare-newline | `2.6.1` (PATCH) | | 27 | port25-bare-newline | `2.6.1` (PATCH) |
| 28 | base-trixie | `2.7.0` | | 28 | base-trixie | `2.7.0` |
| 29 | panel-dkim-signing | `2.8.0` | | 29 | panel-dkim-signing | `2.8.0` |
| 30 | dns-record-collapse, settings-save-row, notification-mail-recheck; `/data/opendkim` removed | `2.9.0` |
Docs-only prep (checklists in plans, no product code) uses the same per-step Docs-only prep (checklists in plans, no product code) uses the same per-step
commit rule but **no** version cut until the next product stage ships. commit rule but **no** version cut until the next product stage ships.
@@ -503,12 +504,13 @@ record as a receiver would. `signature_over_awkward_headers`
([test/e2e/dkim_shapes_test.go](../test/e2e/dkim_shapes_test.go)) sends the ([test/e2e/dkim_shapes_test.go](../test/e2e/dkim_shapes_test.go)) sends the
header shapes Postfix hands a milter in its own way - folded, tab-led, no header shapes Postfix hands a milter in its own way - folded, tab-led, no
space after the colon, two `To:` - and an 8-bit body, and dkimpy verifies space after the colon, two `To:` - and an 8-bit body, and dkimpy verifies
what was delivered. `TestStartsOnA27Data` what was delivered. `TestStartsOnA28Data`
([test/e2e/upgrade_test.go](../test/e2e/upgrade_test.go)) starts the image on ([test/e2e/upgrade_test.go](../test/e2e/upgrade_test.go)) starts the image on
a `/data` a 2.7.x image left: the key under `/data/opendkim` is copied to a `/data` a 2.8.x image left: the old key tree `/data/opendkim` is removed,
`/data/dkim` unchanged and the selector table names it, the old tree becomes the key under `/data/dkim` is kept unchanged and the selector table names
the panel's alone, and a queue owned by other uids is handed to Postfix's it, and a queue owned by other uids is handed to Postfix's users by name;
users by name. `TestRefusesA27Data` has the start stop on a `/data` whose keys are under
the old tree only, which is left as it was.
Waits poll with a timeout (`waitFor`); a fixed sleep is the exception and says Waits poll with a timeout (`waitFor`); a fixed sleep is the exception and says
why where it stands (`sender_check_test.go`: a downtime worth a second or two, why where it stands (`sender_check_test.go`: a downtime worth a second or two,
and 35 s to show a row is not written again). and 35 s to show a row is not written again).
+16 -8
View File
@@ -204,14 +204,14 @@ cat ./data/setup-token
#### Fixed image tag #### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `2.8.0`. **No 2.x image is deliberately never `:latest`. The current pin is `2.9.0`. **No 2.x image is
published on ghcr.io yet** (the newest there is `1.3.0`); until one is, build published on ghcr.io yet** (the newest there is `1.3.0`); until one is, build
it from the checkout under the pinned name, and Compose uses the local image it from the checkout under the pinned name, and Compose uses the local image
instead of pulling: instead of pulling:
```sh ```sh
docker build -f build/Dockerfile --build-arg VERSION=2.8.0 \ docker build -f build/Dockerfile --build-arg VERSION=2.9.0 \
-t ghcr.io/mixeme/selfpost:2.8.0 . -t ghcr.io/mixeme/selfpost:2.9.0 .
``` ```
`VERSION` must equal the tag: it is the version the backup check compares `VERSION` must equal the tag: it is the version the backup check compares
@@ -684,7 +684,17 @@ was, for a rollback, until the next release, and which a full backup leaves
out. The selector table, `data/dkim/selectors`, is written from the database out. The selector table, `data/dkim/selectors`, is written from the database
at every start, before the signer serves. at every start, before the signer serves.
**Rolling back to 2.7.x** after that: the 2.7.x image signs with OpenDKIM **To 2.9.0** from 2.8.x there is nothing to do: the first start removes
`data/opendkim`, the key tree 2.7.x read, which 2.8.x kept for a rollback,
and says so (`entrypoint: removed /data/opendkim, the key tree of 2.7.x;
the keys are under /data/dkim`); the keys under `data/dkim` stay as they
are. From 2.7.x, start 2.8.x once first: an image of 2.9.0 on a `data`
whose keys are under `data/opendkim` only stops at the start and says so,
rather than give the server's own name a new key. Rolling back from 2.9.x
to 2.8.x needs nothing; to 2.7.x, the old tree is gone - only a full backup
of 2.7.x brings it back.
**Rolling back to 2.7.x** from 2.8.x (only): the 2.7.x image signs with OpenDKIM
from `data/opendkim`, which holds the keys as they were at the upgrade. A from `data/opendkim`, which holds the keys as they were at the upgrade. A
domain added under 2.8.x has no key there, and 2.7.x sends its mail domain added under 2.8.x has no key there, and 2.7.x sends its mail
unsigned; one deleted and added again under 2.8.x has its old key there, unsigned; one deleted and added again under 2.8.x has its old key there,
@@ -771,9 +781,7 @@ as themselves, the host's IPv6 address and `127.0.0.1` as `172.18.0.1`.
domains' DKIM keys under `data/dkim/`, all applications' SASL credentials, domains' DKIM keys under `data/dkim/`, all applications' SASL credentials,
`manifest.json` with the version that created it), plus `docker-compose.yml`, `manifest.json` with the version that created it), plus `docker-compose.yml`,
`.env`, and `certs/` from the operator directory next to `./data`. Delivery `.env`, and `certs/` from the operator directory next to `./data`. Delivery
logs under `data/log/` are excluded, so is `data/opendkim/` (the key tree an logs under `data/log/` are excluded, and so is the Postfix queue: the archive is taken while the
installation upgraded from 2.7.x keeps for a rollback, see
[Upgrading](#upgrading)), and so is the Postfix queue: the archive is taken while the
instance runs, and a copy of a queue Postfix is working on can hold a message instance runs, and a copy of a queue Postfix is working on can hold a message
twice or not at all. Mail still waiting for delivery is therefore not carried twice or not at all. Mail still waiting for delivery is therefore not carried
over, and a restored instance starts with an empty queue; to keep the queue, over, and a restored instance starts with an empty queue; to keep the queue,
@@ -936,7 +944,7 @@ A version mismatch at step 5 refuses to start and leaves `/data` untouched —
the panel exits with a message naming the tag to use, e.g.: the panel exits with a message naming the tag to use, e.g.:
``` ```
backup: this backup was created by SelfPost 2.7.0 but this image is 2.8.0 — restore into the matching image (selfpost:2.7.0) backup: this backup was created by SelfPost 2.8.0 but this image is 2.9.0 — restore into the matching image (selfpost:2.8.0)
``` ```
Fix the tag in `docker-compose.yml` (build that tag as under [Fixed image Fix the tag in `docker-compose.yml` (build that tag as under [Fixed image
+3
View File
@@ -171,6 +171,9 @@ dropped connection (a one-hour watchdog closes the signer).
- [x] Keys: `/data/opendkim/keys` -> `/data/dkim/keys` (Q2): the entrypoint - [x] Keys: `/data/opendkim/keys` -> `/data/dkim/keys` (Q2): the entrypoint
copies the tree once and leaves the old one for a rollback to 2.7.x until copies the tree once and leaves the old one for a rollback to 2.7.x until
the next release; KeyTable and SigningTable go with the new layout. the next release; KeyTable and SigningTable go with the new layout.
*2.9.0:* the entrypoint removes the old tree once `/data/dkim` holds a
key, and stops on a `/data` with keys under the old tree only; the copy
and the backup exclusion are gone.
- [x] Tests: `test/upstream/*.sh`, e2e's OpenDKIM log and process checks, - [x] Tests: `test/upstream/*.sh`, e2e's OpenDKIM log and process checks,
the 256-byte test rewritten for the new rule. the 256-byte test rewritten for the new rule.
+4 -4
View File
@@ -38,9 +38,9 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
| password-reset | Password reset by e-mail | agreed (2026-10-13) | — | — | | password-reset | Password reset by e-mail | agreed (2026-10-13) | — | — |
| local-time | The panel shows time, and counts its days, in a time zone other than UTC | agreed (2026-10-13; design questions open) | — | — | | local-time | The panel shows time, and counts its days, in a time zone other than UTC | agreed (2026-10-13; design questions open) | — | — |
| notification-mail-recheck | The Notification mail records on Server › Settings can be checked again from where they are shown | done 2026-10-16, unreleased | — | — | | notification-mail-recheck | The Notification mail records on Server › Settings can be checked again from where they are shown | shipped `2.9.0` | — | — |
| settings-save-row | Server › Settings: the Save button no longer stands alone under a long right column | done 2026-10-16, unreleased | — | — | | settings-save-row | Server › Settings: the Save button no longer stands alone under a long right column | shipped `2.9.0` | — | — |
| dns-record-collapse | A DNS record that is right is shown folded: its name and its tag, the values on request | done 2026-10-16, unreleased | — | — | | dns-record-collapse | A DNS record that is right is shown folded: its name and its tag, the values on request | shipped `2.9.0` | — | — |
| go-milter-upstream | Prepare an issue and a PR for go-milter: its header map outlives a message and is unbounded | prepared 2026-10-16, the owner posts | — | [third_party/go-milter/PATCHES.md](../third_party/go-milter/PATCHES.md) | | go-milter-upstream | Prepare an issue and a PR for go-milter: its header map outlives a message and is unbounded | prepared 2026-10-16, the owner posts | — | [third_party/go-milter/PATCHES.md](../third_party/go-milter/PATCHES.md) |
| own-mta-builds | Analysis: build Postfix and OpenDKIM ourselves, patched, instead of Debian's packages | analysed 2026-10-15: no own builds; three rows proposed instead | — | [own-mta-builds-2026-10-15.md](own-mta-builds-2026-10-15.md) | | own-mta-builds | Analysis: build Postfix and OpenDKIM ourselves, patched, instead of Debian's packages | analysed 2026-10-15: no own builds; three rows proposed instead | — | [own-mta-builds-2026-10-15.md](own-mta-builds-2026-10-15.md) |
| port25-bare-newline | The inbound relay's port 25 refuses bare newlines in DATA (`smtpd_forbid_bare_newline=normalize`) | shipped `2.6.1` | — | — | | port25-bare-newline | The inbound relay's port 25 refuses bare newlines in DATA (`smtpd_forbid_bare_newline=normalize`) | shipped `2.6.1` | — | — |
@@ -64,7 +64,7 @@ stages 1 and 2 as `2.5.0` the same day, stage 2b as `2.5.1`, stage 2c as
`2.6.0` the same day; stage 6 (documents and tests, no cut) closed the `2.6.0` the same day; stage 6 (documents and tests, no cut) closed the
audit. Of the three rows from [own-mta-builds](#own-mta-builds), audit. Of the three rows from [own-mta-builds](#own-mta-builds),
**port25-bare-newline** shipped as `2.6.1` the same day and **port25-bare-newline** shipped as `2.6.1` the same day and
**base-trixie** as `2.7.0`, and **panel-dkim-signing** as `2.8.0` (2026-10-16). What is agreed after it is marked so in the index; every other **base-trixie** as `2.7.0`, and **panel-dkim-signing** as `2.8.0` (2026-10-16). **dns-record-collapse**, **settings-save-row** and **notification-mail-recheck** shipped together as `2.9.0` the same day, with `/data/opendkim` removed as panel-dkim-signing planned. What is agreed after it is marked so in the index; every other
remaining item is a candidate. panel-docs shipped in remaining item is a candidate. panel-docs shipped in
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]` [CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
(its ingest path first ran in `2.0.0` and was reviewed on 2026-10-11, see (its ingest path first ran in `2.0.0` and was reviewed on 2026-10-11, see
+4 -5
View File
@@ -410,11 +410,10 @@ operator can act on it.
private key from `certs/` when present; a domain export carries the DKIM key private key from `certs/` when present; a domain export carries the DKIM key
and **working** application passwords in the clear (otherwise a transfer and **working** application passwords in the clear (otherwise a transfer
without recreating credentials would be impossible). The keys a backup without recreating credentials would be impossible). The keys a backup
carries are those of `/data/dkim`; `/data/opendkim`, the tree 2.7.x read carries are those of `/data/dkim`. `/data/opendkim`, the tree 2.7.x read,
and kept for a rollback, is left out (`internal/backup`, was left out while 2.8.x kept it for a rollback (`2.8.0`); since `2.9.0`
`excludedFromArchive`, `2.8.0`): an archive restores into its own version the entrypoint removes it before the panel starts, so no second copy of
alone, and a second copy of every key - one that outlives a deleted every key - one that outlived a deleted domain's - is left on disk.
domain's - has no place in it.
- Links under `certs/` (and `certs` itself as a link) are followed, in the - Links under `certs/` (and `certs` itself as a link) are followed, in the
container's view: the archive carries whatever regular file they lead to container's view: the archive carries whatever regular file they lead to
that the panel's user may read. `certs/` is the operator's, mounted that the panel's user may read. `certs/` is the operator's, mounted
+4 -6
View File
@@ -105,11 +105,10 @@ const QueueDir = "postfix/queue"
// fresh one is written instead); a "tls" directory under /data is skipped when // fresh one is written instead); a "tls" directory under /data is skipped when
// an operator pointed TLS_CERT_FILE inside /data; and "log" is Postfix's raw // an operator pointed TLS_CERT_FILE inside /data; and "log" is Postfix's raw
// delivery log plus its fourteen rotated files, which is diagnostic output, not // delivery log plus its fourteen rotated files, which is diagnostic output, not
// state to restore, and by far the largest thing under /data. "opendkim" is the // state to restore, and by far the largest thing under /data. (Until 2.9.0
// key tree OpenDKIM read until 2.8.0, kept on disk only for a rollback to 2.7.x: // "opendkim" was left out too: the key tree OpenDKIM read until 2.8.0, kept on
// an archive restores into its own version alone, which reads the keys from // disk for a rollback to 2.7.x; the entrypoint removes it before the panel
// /data/dkim, and a second copy of every key - one that outlives a deleted // starts since.)
// domain's - has no place in it.
var excludedFromArchive = map[string]bool{ var excludedFromArchive = map[string]bool{
"selfpost.db": true, "selfpost.db": true,
"selfpost.db-wal": true, "selfpost.db-wal": true,
@@ -118,7 +117,6 @@ var excludedFromArchive = map[string]bool{
"setup-token": true, "setup-token": true,
"tls": true, "tls": true,
"log": true, "log": true,
"opendkim": true,
ManifestName: true, ManifestName: true,
ContentsName: true, ContentsName: true,
contentsCheckedName: true, contentsCheckedName: true,
-5
View File
@@ -44,9 +44,6 @@ func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
} }
writeFile(t, filepath.Join(dataDir, "dkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY") writeFile(t, filepath.Join(dataDir, "dkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
// The key tree OpenDKIM read until 2.8.0, left on disk for a rollback.
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "OLD KEY")
writeFile(t, filepath.Join(dataDir, "opendkim", "KeyTable"), "example.com example.com:selfpost:/data/opendkim/keys/example.com/selfpost.private")
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB") writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB")
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login") writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
writeFile(t, filepath.Join(dataDir, "antispam", "deny_domain.map"), "evil.example\n") writeFile(t, filepath.Join(dataDir, "antispam", "deny_domain.map"), "evil.example\n")
@@ -144,8 +141,6 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
DataArchivePrefix + "log/mail.log", DataArchivePrefix + "log/mail.log",
DataArchivePrefix + "log/mail.log.1", DataArchivePrefix + "log/mail.log.1",
DataArchivePrefix + "postfix/queue/deferred/sample", DataArchivePrefix + "postfix/queue/deferred/sample",
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
DataArchivePrefix + "opendkim/KeyTable",
} { } {
if _, ok := files[name]; ok { if _, ok := files[name]; ok {
t.Errorf("archive should not contain %s", name) t.Errorf("archive should not contain %s", name)
+88 -37
View File
@@ -1,6 +1,7 @@
package e2e package e2e
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/x509" "crypto/x509"
@@ -13,51 +14,53 @@ import (
"time" "time"
) )
// TestStartsOnA27Data: the image starts on a /data an earlier image left - // upgradeData is a /data an earlier image left, laid out for host: the
// the DKIM keys under /data/opendkim, as 2.7.x kept them, and a Postfix queue // server's key under /data/dkim as 2.8.x keeps it (unless only27), the old
// owned by numbers that are not this image's postfix and postdrop. Without // key tree /data/opendkim as 2.7.x kept it - 2.8.x left it for a rollback -
// opendkim installed the package accounts get other numbers (postfix 101 -> // and, with a queue, a Postfix queue owned by numbers that are not this
// 100, postdrop 103 -> 102 in 2.8.0), and the first rollout of 2.8.0 to the // image's postfix and postdrop, and a log rotation due at the start. It
// test server crash-looped on "postfix check" (2026-10-16): the queue is // returns the directory and the key's PEM.
// handed to Postfix's users by name before the check now. And the old key is func upgradeData(t *testing.T, host string, only27 bool) (string, []byte) {
// the one signing: copied, not made anew, so the published record holds t.Helper()
// (build/entrypoint.sh; review of panel-dkim-signing, F5 - no test ran the
// copy).
func TestStartsOnA27Data(t *testing.T) {
const host = "mail.example.test"
dataDir := t.TempDir() dataDir := t.TempDir()
if err := os.Chmod(dataDir, 0o755); err != nil { if err := os.Chmod(dataDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
certDir := t.TempDir()
if err := writeSelfSignedCert(certDir+"/fullchain.pem", certDir+"/privkey.pem"); err != nil {
t.Fatalf("generate throwaway TLS cert: %v", err)
}
// The server's key as 2.7.x kept it, and its KeyTable.
key, err := rsa.GenerateKey(rand.Reader, 2048) key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
pemKey := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) pemKey := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
keyFile := filepath.Join(dataDir, "opendkim", "keys", host, "selfpost.private") write := func(rel string, data []byte) {
if err := os.MkdirAll(filepath.Dir(keyFile), 0o755); err != nil { p := filepath.Join(dataDir, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(keyFile, pemKey, 0o644); err != nil { if err := os.WriteFile(p, data, 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(dataDir, "opendkim", "KeyTable"), }
[]byte(host+" "+host+":selfpost:/data/opendkim/keys/"+host+"/selfpost.private\n"), 0o644); err != nil { write(filepath.Join("opendkim", "keys", host, "selfpost.private"), pemKey)
t.Fatal(err) write(filepath.Join("opendkim", "KeyTable"), []byte(host+" "+host+":selfpost:/data/opendkim/keys/"+host+"/selfpost.private\n"))
if !only27 {
write(filepath.Join("dkim", "keys", host, "selfpost.private"), pemKey)
write(filepath.Join("dkim", "selectors"), []byte(host+" selfpost\n"))
} }
// The owners and modes 2.7.x left, and a queue of foreign numbers, laid // The owners and modes the earlier images left - 2.8.x made both key
// out as root in a throwaway container of the image. // trees the panel's alone, 2.7.x the old one group selfpost's with the
// set-group-ID bit - and a queue of foreign numbers, laid out as root in
// a throwaway container of the image.
layout := `set -e layout := `set -e
chown -R 10999:10998 /data/opendkim if [ -d /data/dkim ]; then
find /data/opendkim -type d -exec chmod 2750 {} + chown -R 10999:10999 /data/dkim /data/opendkim
find /data/opendkim -type f -exec chmod 0640 {} + find /data/dkim /data/opendkim -type d -exec chmod 0700 {} +
find /data/dkim /data/opendkim -type f -exec chmod 0600 {} +
else
chown -R 10999:10998 /data/opendkim
find /data/opendkim -type d -exec chmod 2750 {} +
find /data/opendkim -type f -exec chmod 0640 {} +
fi
q=/data/postfix/queue q=/data/postfix/queue
for d in active bounce corrupt defer deferred flush hold incoming private saved trace; do for d in active bounce corrupt defer deferred flush hold incoming private saved trace; do
mkdir -p $q/$d; chown 4242:4243 $q/$d; chmod 700 $q/$d mkdir -p $q/$d; chown 4242:4243 $q/$d; chmod 700 $q/$d
@@ -70,7 +73,26 @@ echo "Oct 14 00:00:00 mail postfix/master[1]: daemon started" > /data/log/mail.l
printf 'logrotate state -- version 2\n"/data/log/mail.log" 2026-10-14-0:0:0\n' > /data/log/.logrotate.status` printf 'logrotate state -- version 2\n"/data/log/mail.log" 2026-10-14-0:0:0\n' > /data/log/.logrotate.status`
if out, err := exec.Command("docker", "run", "--rm", "--entrypoint", "sh", if out, err := exec.Command("docker", "run", "--rm", "--entrypoint", "sh",
"-v", dataDir+":/data", "selfpost:e2e", "-c", layout).CombinedOutput(); err != nil { "-v", dataDir+":/data", "selfpost:e2e", "-c", layout).CombinedOutput(); err != nil {
t.Fatalf("lay out the 2.7 /data: %v\n%s", err, out) t.Fatalf("lay out the earlier /data: %v\n%s", err, out)
}
return dataDir, pemKey
}
// TestStartsOnA28Data: the image starts on a /data 2.8.x left - the keys
// under /data/dkim, the old tree /data/opendkim still there for a rollback to
// 2.7.x, which 2.9.0 ends - and a Postfix queue owned by numbers that are not
// this image's postfix and postdrop. The old tree is removed and the key
// under /data/dkim is the one kept, unchanged, so the published record holds.
// The queue is handed to Postfix's users by name before "postfix check": the
// first rollout of 2.8.0 crash-looped on it when removing opendkim shifted
// the package accounts' numbers (2026-10-16); a removed package can shift
// them again.
func TestStartsOnA28Data(t *testing.T) {
const host = "mail.example.test"
dataDir, pemKey := upgradeData(t, host, false)
certDir := t.TempDir()
if err := writeSelfSignedCert(certDir+"/fullchain.pem", certDir+"/privkey.pem"); err != nil {
t.Fatalf("generate throwaway TLS cert: %v", err)
} }
name := "selfpost-e2e-upgrade" name := "selfpost-e2e-upgrade"
@@ -106,20 +128,20 @@ printf 'logrotate state -- version 2\n"/data/log/mail.log" 2026-10-14-0:0:0\n' >
t.Fatalf("%v\n%s", err, logs()) t.Fatalf("%v\n%s", err, logs())
} }
if got := exec1(`cmp /data/opendkim/keys/` + host + `/selfpost.private /data/dkim/keys/` + host + `/selfpost.private && echo same`); got != "same" { if got := exec1(`test -e /data/opendkim && echo there || echo gone`); got != "gone" {
t.Errorf("the key under /data/dkim is not the one copied: %s", got) t.Errorf("/data/opendkim is %s after the start", got)
}
if got := exec1(`cat /data/dkim/keys/` + host + `/selfpost.private`); got != strings.TrimSpace(string(pemKey)) {
t.Error("the key under /data/dkim is not the one 2.8.x left")
} }
if got := exec1(`cat /data/dkim/selectors`); got != host+" selfpost" { if got := exec1(`cat /data/dkim/selectors`); got != host+" selfpost" {
t.Errorf("selector table = %q", got) t.Errorf("selector table = %q", got)
} }
if got := exec1(`stat -c '%U:%G %a' /data/opendkim/keys/` + host + `/selfpost.private /data/opendkim/keys`); got != "panel:panel 600\npanel:panel 700" {
t.Errorf("the old tree is not the panel's alone: %q", got)
}
if got := exec1(`stat -c '%U:%G' /data/postfix/queue/active /data/postfix/queue/maildrop /data/postfix/queue/pid`); got != "postfix:postfix\npostfix:postdrop\nroot:postfix" { if got := exec1(`stat -c '%U:%G' /data/postfix/queue/active /data/postfix/queue/maildrop /data/postfix/queue/pid`); got != "postfix:postfix\npostfix:postdrop\nroot:postfix" {
t.Errorf("the queue is not Postfix's by name: %q", got) t.Errorf("the queue is not Postfix's by name: %q", got)
} }
if !strings.Contains(logs(), "the DKIM keys were copied from /data/opendkim to /data/dkim") { if !strings.Contains(logs(), "entrypoint: removed /data/opendkim") {
t.Errorf("the entrypoint did not say it copied the keys:\n%s", logs()) t.Errorf("the entrypoint did not say it removed the old tree:\n%s", logs())
} }
// A rotation due at the start runs before Postfix does: it rotates, and // A rotation due at the start runs before Postfix does: it rotates, and
// its postrotate asks no reload of a Postfix not running yet (the test // its postrotate asks no reload of a Postfix not running yet (the test
@@ -132,3 +154,32 @@ printf 'logrotate state -- version 2\n"/data/log/mail.log" 2026-10-14-0:0:0\n' >
t.Errorf("the rotation at the start failed:\n%s", l) t.Errorf("the rotation at the start failed:\n%s", l)
} }
} }
// TestRefusesA27Data: a /data that never ran 2.8.x keeps the keys under
// /data/opendkim only. The start stops and says to run 2.8.x first, which
// copies them, rather than let the panel make the server's own name a new key
// that its published record would not match; the old tree is left as it was.
func TestRefusesA27Data(t *testing.T) {
const host = "mail.example.test"
dataDir, _ := upgradeData(t, host, true)
name := "selfpost-e2e-upgrade-27"
_ = exec.Command("docker", "rm", "-f", name).Run()
defer exec.Command("docker", "rm", "-f", name).Run()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
out, err := exec.CommandContext(ctx, "docker", "run", "--rm", "--name", name,
"-e", "SELFPOST_HOSTNAME="+host, "-v", dataDir+":/data", "selfpost:e2e").CombinedOutput()
if err == nil || ctx.Err() != nil {
t.Fatalf("the image started on a 2.7 /data (err %v, %v):\n%s", err, ctx.Err(), out)
}
if !strings.Contains(string(out), "the DKIM keys are only under /data/opendkim") {
t.Errorf("the refusal does not say why:\n%s", out)
}
if _, err := os.Stat(filepath.Join(dataDir, "opendkim", "keys", host, "selfpost.private")); err != nil {
t.Errorf("the old key is gone after the refusal: %v", err)
}
if _, err := os.Stat(filepath.Join(dataDir, "dkim", "keys", host)); err == nil {
t.Error("a key directory was made under /data/dkim before the refusal")
}
}