From 9420d8c03de04a0c44dd2b955c3426e3aafa033d Mon Sep 17 00:00:00 2001 From: Mix Date: Fri, 16 Oct 2026 13:46:04 +0300 Subject: [PATCH] entrypoint: /data/opendkim removed, as planned for the release after 2.8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 12 +++ build/entrypoint.sh | 54 +++++-------- docs/architecture.md | 20 +++-- docs/development.md | 11 +-- docs/guide.md | 16 +++- docs/plans/panel-dkim-signing.md | 3 + docs/security.md | 9 +-- internal/backup/backup.go | 10 +-- internal/backup/backup_test.go | 5 -- test/e2e/upgrade_test.go | 131 +++++++++++++++++++++---------- 10 files changed, 161 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d72dab..c389fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 /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 The panel signs outgoing mail with DKIM itself, and OpenDKIM - a 2018 beta diff --git a/build/entrypoint.sh b/build/entrypoint.sh index 67663c7..4ea7455 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -87,7 +87,7 @@ drop_links() { done } 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/postfix/sender_login_maps /data/postfix/relay_domains \ /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 # 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 -# 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). +# normalised on its own below, as are the trees shared through group selfpost. # 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 # image gave the panel other ids (999:999 until 2.7.0, 10999:10999 since) - is # 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 {} + # 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, -# sign.go). Until 2.8.0 the keys were under /data/opendkim, shared with -# OpenDKIM through group selfpost. That tree is kept for an image of 2.7.x -# rolled back to (docs/plans/panel-dkim-signing.md, Q2), but nothing of -# 2.8.0 reads it beyond the copy below: it is the panel's alone like the new -# one - a 2.7.x start gives it back to group selfpost itself - and a backup -# leaves it out (internal/backup, excludedFromArchive). While /data/dkim -# holds no key and the old tree does, the keys are copied over. The copy is -# made as the panel user, which can read nothing there the panel could not, -# into a directory beside, renamed into place when it is whole: a start that -# stops midway copies again. A copy that fails stops the start (set -e) - the -# panel would otherwise make the server's own name a new key, and its -# 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.) +# sign.go). The selector table is written by the panel before its signer +# serves (cmd/panel/signer.go). Until 2.8.0 the keys were under +# /data/opendkim, shared with OpenDKIM; 2.8.x copied them to /data/dkim at its +# first start and kept the old tree for a rollback to 2.7.x until the next +# release, this one (docs/plans/panel-dkim-signing.md, Q2). It is removed +# once /data/dkim holds a key. A /data that never ran 2.8.x - keys under the +# old tree only - stops the start instead: the panel would make the server's +# own name a new key, and its published record would no longer match; 2.8.x +# copies them (guide § Upgrading). rm -rf removes a link, not what it points +# to, and follows none inside (GNU coreutils rm(1)); a link at the path is +# gone already (drop_links). (e2e TestStartsOnA28Data, TestRefusesA27Data.) has_keys() { [ -d "$1" ] && [ -n "$(find "$1" -type f -name '*.private' | head -n 1)" ]; } if [ -d /data/opendkim ]; then - chown -R panel:panel /data/opendkim - find /data/opendkim -type d -exec chmod 0700 {} + -exec chmod g-s {} + - find /data/opendkim -type f -exec chmod 0600 {} + + if ! has_keys /data/dkim/keys && has_keys /data/opendkim/keys; then + 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 + 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 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 chown -R panel:panel /data/dkim find /data/dkim -type d -exec chmod 0700 {} + -exec chmod g-s {} + diff --git a/docs/architecture.md b/docs/architecture.md index 7ce1c60..ffa0b09 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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). - **The old tree,** `/data/opendkim` - the keys, `KeyTable` and `SigningTable` until `2.8.0`, shared with OpenDKIM through group - `selfpost`. While `/data/dkim` holds no key and the old tree does, the - entrypoint copies `/data/opendkim/keys` to `/data/dkim/keys` as the panel - user (`setpriv`), into `/data/dkim.new`, renamed when whole; a copy that - fails stops the start - the server's name would otherwise get a new key - and its published record would no longer match. Keys, selectors and DNS - records do not change. The old tree is left for a rollback to a 2.7.x - image until the next release, made the panel's alone at every start like - the new one, and left out of backups (§ Persistence). + `selfpost`. `2.8.x` copied its keys to `/data/dkim/keys` at its first + start and kept it for a rollback to a 2.7.x image; since `2.9.0` the + entrypoint removes it once `/data/dkim` holds a key, and a `/data` with + keys under the old tree only - one that never ran 2.8.x - stops the start + (the server's name would otherwise get a new key and its published record + would no longer match). Under 2.8.x it was the panel's alone, like the + new one, and left out of backups. ### 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) | | `setup-token` | First-run setup token file | | `dkim/` | DKIM keys (`keys//.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 | | `postfix/sender_login_maps` | Login → From binding | | `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). **Restore:** panel button or `selfpost-backup` CLI — self-contained archive: -`data/` (SQLite snapshot + tree minus `log/`, the setup token, the old -`opendkim/` tree and any `tls/` under `/data`), `docker-compose.yml`, `.env`, +`data/` (SQLite snapshot + tree minus `log/`, the setup token and any +`tls/` under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present; version check on restore. Requires the project directory mounted read-only at `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the diff --git a/docs/development.md b/docs/development.md index 901ba9f..73ddf6c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 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 -what was delivered. `TestStartsOnA27Data` +what was delivered. `TestStartsOnA28Data` ([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 -`/data/dkim` unchanged and the selector table names it, the old tree becomes -the panel's alone, and a queue owned by other uids is handed to Postfix's -users by name. +a `/data` a 2.8.x image left: the old key tree `/data/opendkim` is removed, +the key under `/data/dkim` is kept unchanged and the selector table names +it, and a queue owned by other uids is handed to Postfix's 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 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). diff --git a/docs/guide.md b/docs/guide.md index af110e2..162bf12 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -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 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 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, @@ -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, `manifest.json` with the version that created it), plus `docker-compose.yml`, `.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 -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 +logs under `data/log/` are excluded, 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 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, diff --git a/docs/plans/panel-dkim-signing.md b/docs/plans/panel-dkim-signing.md index 8e8af34..635bada 100644 --- a/docs/plans/panel-dkim-signing.md +++ b/docs/plans/panel-dkim-signing.md @@ -171,6 +171,9 @@ dropped connection (a one-hour watchdog closes the signer). - [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 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, the 256-byte test rewritten for the new rule. diff --git a/docs/security.md b/docs/security.md index 18ff991..d34efea 100644 --- a/docs/security.md +++ b/docs/security.md @@ -410,11 +410,10 @@ operator can act on it. private key from `certs/` when present; a domain export carries the DKIM key and **working** application passwords in the clear (otherwise a transfer without recreating credentials would be impossible). The keys a backup - carries are those of `/data/dkim`; `/data/opendkim`, the tree 2.7.x read - and kept for a rollback, is left out (`internal/backup`, - `excludedFromArchive`, `2.8.0`): an archive restores into its own version - alone, and a second copy of every key - one that outlives a deleted - domain's - has no place in it. + carries are those of `/data/dkim`. `/data/opendkim`, the tree 2.7.x read, + was left out while 2.8.x kept it for a rollback (`2.8.0`); since `2.9.0` + the entrypoint removes it before the panel starts, so no second copy of + every key - one that outlived a deleted domain's - is left on disk. - 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 that the panel's user may read. `certs/` is the operator's, mounted diff --git a/internal/backup/backup.go b/internal/backup/backup.go index b06ceb1..246612e 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -105,11 +105,10 @@ const QueueDir = "postfix/queue" // 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 // 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 -// key tree OpenDKIM read until 2.8.0, kept on disk only for a rollback to 2.7.x: -// an archive restores into its own version alone, which reads the keys from -// /data/dkim, and a second copy of every key - one that outlives a deleted -// domain's - has no place in it. +// state to restore, and by far the largest thing under /data. (Until 2.9.0 +// "opendkim" was left out too: the key tree OpenDKIM read until 2.8.0, kept on +// disk for a rollback to 2.7.x; the entrypoint removes it before the panel +// starts since.) var excludedFromArchive = map[string]bool{ "selfpost.db": true, "selfpost.db-wal": true, @@ -118,7 +117,6 @@ var excludedFromArchive = map[string]bool{ "setup-token": true, "tls": true, "log": true, - "opendkim": true, ManifestName: true, ContentsName: true, contentsCheckedName: true, diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index 65dff05..cb73a41 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -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") - // 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, "postfix", "sender_login_maps"), "@example.com login") 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.1", DataArchivePrefix + "postfix/queue/deferred/sample", - DataArchivePrefix + "opendkim/keys/example.com/selfpost.private", - DataArchivePrefix + "opendkim/KeyTable", } { if _, ok := files[name]; ok { t.Errorf("archive should not contain %s", name) diff --git a/test/e2e/upgrade_test.go b/test/e2e/upgrade_test.go index c78d0ff..fb0c82d 100644 --- a/test/e2e/upgrade_test.go +++ b/test/e2e/upgrade_test.go @@ -1,6 +1,7 @@ package e2e import ( + "context" "crypto/rand" "crypto/rsa" "crypto/x509" @@ -13,51 +14,53 @@ import ( "time" ) -// TestStartsOnA27Data: the image starts on a /data an earlier image left - -// the DKIM keys under /data/opendkim, as 2.7.x kept them, and a Postfix queue -// owned by numbers that are not this image's postfix and postdrop. Without -// opendkim installed the package accounts get other numbers (postfix 101 -> -// 100, postdrop 103 -> 102 in 2.8.0), and the first rollout of 2.8.0 to the -// test server crash-looped on "postfix check" (2026-10-16): the queue is -// handed to Postfix's users by name before the check now. And the old key is -// the one signing: copied, not made anew, so the published record holds -// (build/entrypoint.sh; review of panel-dkim-signing, F5 - no test ran the -// copy). -func TestStartsOnA27Data(t *testing.T) { - const host = "mail.example.test" +// upgradeData is a /data an earlier image left, laid out for host: the +// server's key under /data/dkim as 2.8.x keeps it (unless only27), the old +// key tree /data/opendkim as 2.7.x kept it - 2.8.x left it for a rollback - +// and, with a queue, a Postfix queue owned by numbers that are not this +// image's postfix and postdrop, and a log rotation due at the start. It +// returns the directory and the key's PEM. +func upgradeData(t *testing.T, host string, only27 bool) (string, []byte) { + t.Helper() dataDir := t.TempDir() if err := os.Chmod(dataDir, 0o755); err != nil { 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) if err != nil { t.Fatal(err) } pemKey := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) - keyFile := filepath.Join(dataDir, "opendkim", "keys", host, "selfpost.private") - if err := os.MkdirAll(filepath.Dir(keyFile), 0o755); err != nil { - t.Fatal(err) + write := func(rel string, data []byte) { + p := filepath.Join(dataDir, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, data, 0o644); err != nil { + t.Fatal(err) + } } - if err := os.WriteFile(keyFile, pemKey, 0o644); err != nil { - 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 { - t.Fatal(err) + write(filepath.Join("opendkim", "keys", host, "selfpost.private"), pemKey) + 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 - // out as root in a throwaway container of the image. + // The owners and modes the earlier images left - 2.8.x made both key + // 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 -chown -R 10999:10998 /data/opendkim -find /data/opendkim -type d -exec chmod 2750 {} + -find /data/opendkim -type f -exec chmod 0640 {} + +if [ -d /data/dkim ]; then + chown -R 10999:10999 /data/dkim /data/opendkim + 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 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 @@ -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` if out, err := exec.Command("docker", "run", "--rm", "--entrypoint", "sh", "-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" @@ -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()) } - if got := exec1(`cmp /data/opendkim/keys/` + host + `/selfpost.private /data/dkim/keys/` + host + `/selfpost.private && echo same`); got != "same" { - t.Errorf("the key under /data/dkim is not the one copied: %s", got) + if got := exec1(`test -e /data/opendkim && echo there || echo gone`); got != "gone" { + 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" { 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" { 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") { - t.Errorf("the entrypoint did not say it copied the keys:\n%s", logs()) + if !strings.Contains(logs(), "entrypoint: removed /data/opendkim") { + 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 // 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) } } + +// 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") + } +}