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>
This commit is contained in:
mix
2026-10-16 13:46:04 +03:00
co-authored by Claude Opus 5.5
parent 3f5cd9adf0
commit 9420d8c03d
10 changed files with 161 additions and 110 deletions
+12
View File
@@ -31,6 +31,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
+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 {} +
+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
+6 -5
View File
@@ -503,12 +503,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).
+12 -4
View File
@@ -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,
+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 -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")
}
}