Compare commits

...
14 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 d65d632505 release: 2.0.1: DMARC ingest drops and bounds; Full backup from the panel
test / test (push) Canceled after 0s
A patch on 2.0.0, all of it found on the test server after the cut:
  - a message that is not a report this relay keeps is dropped at once
    instead of being retried for days;
  - a report is bounded before it is stored, and reports are kept per
    domain (security review of the ingest path, nothing Critical or High);
  - Full backup from the panel works (it had answered 200 with ten bytes
    since 1.3.0);
  - no backup holds the Postfix queue, and the panel says why.

Cut: CHANGELOG [Unreleased] -> [2.0.1]; image pin 2.0.1 in compose,
README, guide; version table.

On the test server, on dd0fefd: go vet ./... ok, go test ./... ok
(22 packages), make e2e ok.

Full detail: CHANGELOG.md (2.0.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:14:00 +03:00
mixandClaude Opus 5.5 dd0fefdcb7 backup: the panel's archive is the command's, private key included
Owner, 2026-10-11: the private key is needed, and a backup from the
panel and one from the console have to be the same.

The commit that made the panel's backup work skipped a file under
certs/ the panel could not open - on the test server the private key,
which the certificate hook leaves to root and Postfix's group. That gave
an archive with a certificate and no key, and two kinds of archive.

- Params.Unprivileged is gone. Nothing is left out on account of who
  makes the archive; the queue is out for everyone (previous commit).
- When the panel cannot read a file under certs/, backup.Check says so
  before the download and the panel refuses with what to do on the host:
  chgrp 998 certs/privkey.pem && chmod 640. backup.UnreadableCert tells
  that case from any other failure.
- 998 is the container's selfpost group, which holds the panel and
  Postfix. Its id was whatever groupadd gave; the Dockerfile now fixes
  it at the value it has always had, because an operator addresses it
  by number from the host.
- Guide: method 1 is the same archive as method 2, with the permission
  the key needs and a note for whatever renews the certificate. Help and
  the box follow the design commit before this one.
- e2e: the restore scenario first makes the key unreadable and expects
  the refusal naming the file and the way out, then takes the backup and
  expects the key in it; nothing is put back by hand before the restart.

  go vet ./internal/backup/ ./internal/web/handlers/: ok
  go test ./internal/backup/ ./cmd/... ./internal/web/...: ok
  make e2e on the test server: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:43:10 +03:00
mixandClaude Opus 5.5 75ebbf6a57 design: the panel's backup needs the private key readable, and says so
Owner, 2026-10-11: the key is needed and the panel's archive must be the console's. The box no longer says a certificate file may be left out; Help says the key has to be readable by the panel (group 998 on the host) and that the panel refuses, naming the file, until it is.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:43:10 +03:00
mixandClaude Opus 5.5 ede3783734 panel-redesign: Full backup and Help say why the queue is left out
Follows the design commit before it: the paragraph of the Full backup box and the Backup topic of Help.

  go test -count=1 ./internal/web/...: ok
  Backup page at 1280: no overflow, the box holds the longer paragraph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:16:30 +03:00
mixandClaude Opus 5.5 d70371a50c design: Full backup and Help say why the queue is left out
Owner, 2026-10-11: the user has to be told why. The box gives the reason in a sentence - the queue cannot be copied reliably while the server is running; the Help topic explains it (Postfix keeps moving messages, a copy could hold one twice or miss it), what follows (waiting mail is not carried over, a restored server starts with an empty queue) and what to do to keep the queue (stop the container, archive the project directory).

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:16:30 +03:00
mixandClaude Opus 5.5 21e961cb29 backup: no full backup holds the Postfix queue
Owner, 2026-10-11: the queue goes out of the command's archive too.

The commit that made the panel's backup work left the queue in the
archive of selfpost-backup, which runs as root and can read it. But that
copy was never the queue: the archive is made while the instance runs,
Postfix moves messages between the queue's directories all the time, and
a message on its way could be in the archive twice or not at all. One
rule now - QueueDir is left out of every archive - and
Params.Unprivileged is only about a certs/ file the panel may not open.

What is not carried over is mail still waiting for delivery; a restored
instance builds an empty queue (the e2e restore scenario starts one).
To keep the queue: stop the instance and tar the project directory, as
the guide's methods 3 and 4 already describe.

Guide: the description of a full backup, methods 1 and 2 and the
comparison table. Help follows the design commit before this one.

  go vet ./internal/backup/: ok
  go test ./internal/backup/ ./cmd/... ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:06:41 +03:00
mixandClaude Opus 5.5 82f212ab39 design: Help says no backup holds the Postfix queue
Owner, 2026-10-11: the queue is left out of the command's archive as well as the panel's. The Help topic no longer says selfpost-backup takes it; the command still takes the private key the panel cannot read.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:06:41 +03:00
mixandClaude Opus 5.5 b55b33ae92 e2e: full backup from the panel, restore into an empty directory
After the rate-limit checks the run takes a full backup through the
panel, reads the archive through while the container can still say what
went wrong, then removes data/, certs/, docker-compose.yml and .env,
unpacks the archive into the project directory, puts the private key
back (the panel's archive comes without it and without the queue, both
asserted), and starts the stand again.

What has to hold: manifest.json is consumed by the first start, the
administrator signs in, the domain shows the same DKIM value, and a
message sent with the application's old password reaches the sink and
verifies against the record published before the backup.

The port-25 scenarios then run on the restored installation.

make e2e on the test server: ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:55:19 +03:00
mixandClaude Opus 5.5 8c9a71e6a0 backup: the panel's full backup works, without the queue
Found by the e2e restore scenario of the next commit: the Full backup
button answered 200 with ten bytes. The archive includes the Postfix
queue, Postfix keeps every queue file and directory readable by its own
user alone (not configurable), and the panel runs as its own user - the
walk stopped at /data/postfix/queue/active with "permission denied"
after the headers were out. So since the queue moved under /data in
1.3.0. Confirmed on the test server: selfpost-backup as root makes the
whole archive, the same command as user panel fails the same way.

Owner, 2026-10-11: the panel's archive comes without the queue.

- backup.Params.Unprivileged: the queue is left out, and so is a file
  under certs/ that cannot be opened (the private key on the test
  server is root and Postfix's group only), named through OnWarn. The
  selfpost-backup command leaves it unset and takes both, as before.
- backup.Check reads through everything Create would archive, writing
  nothing. The panel runs it before the first byte: a failure is a
  message on the page, not a download cut short.
- The box, Help (design commit before this one) and the guide say what
  the panel's archive leaves out; the guide's comparison table has rows
  for the queue and the private key, and tells to put the key back
  before starting a restored instance.

  go vet ./internal/backup/ ./internal/web/handlers/: ok
  go test ./internal/backup/ ./cmd/... ./internal/web/...: ok
  make e2e on the test server, with the restore scenario: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:55:18 +03:00
mixandClaude Opus 5.5 646ec1e149 design: Full backup says what the panel's archive leaves out
Owner, 2026-10-11: the archive made from the panel comes without the Postfix queue (variant 1). The Full backup box and the Help topic say so, and name the other thing the panel may not read - a certificate file, usually the private key - and the command that takes both.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:55:18 +03:00
mixandClaude Opus 5.5 8496b289ec e2e: the run ends on port 25, with a report, junk and inbound mail
Until now the suite only read back the configuration generated for the
two optional port-25 features. DMARC ingest shipped in 1.7.0 with a
delivery service Postfix could not start, and nothing sent a report
through it until a real one arrived on the test server on 2026-10-11.

The last steps recreate the container with INBOUND_RELAY_ENABLE and
DMARC_REPORTS_ENABLE on, over the same /data, and talk to port 25 as a
stranger's mail server:

- the container log has no postconf warning, the session survives;
- a domain takes the SelfPost-hosted report address; a gzip report
  mailed to it appears under Outbound > DMARC reports; an address nobody
  chose is refused at RCPT;
- a report on another domain and a message with no report are accepted,
  dropped with "dropped: ..." on their delivery lines, counted as two
  parse failures, absent from the queue and from the reports;
- an inbound domain with the sink as upstream and one listed recipient:
  mail for it reaches the sink, another recipient and another domain are
  refused at RCPT.

The stage directory now holds docker-compose.yml and .env, as an
operator's project directory does (a full backup needs them; the
restore scenario that uses it is not in this commit - the panel's full
backup turned out not to work, see the next one).

make e2e on the test server: ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:40:47 +03:00
mixandClaude Opus 5.5 4dd46ea355 dmarc: a report is bounded, kept per domain, and ingested one at a time
Security review of the DMARC report path (Fable, 2026-10-11), which ran
for the first time in 2.0.0. No Critical or High finding. Fixed:

- Medium: a report was bounded only as 8 MiB of XML - some 160 000
  records or one name of megabytes, all of it rows and table cells. At
  most 5 000 records (more is ErrRejected, dropped), text fields cut to
  255 bytes on a character boundary, a record's count capped.
- Medium: the cap of 500 reports was for the whole server, so a stranger
  filing under one domain's address evicted every other domain's
  reports. PruneDMARCReports counts per domain.
- Low: the pipe service ran up to default_process_limit (100) commands
  at once against the panel's database; maxproc is 1.
- Low: the reason logged for a dropped message carried the sender's
  bytes (header line, zip entry name) as they came; cut to 200 bytes and
  reduced to printable ASCII, zip entry names quoted.
- Low: SELFPOST_DATA_DIR / SELFPOST_DB_PATH with whitespace or "$" would
  reach the command through export_environment as another path; refused
  at start when DMARC ingest is on.

Left as they are, told to the owner: the parse-failure count is a
lifetime figure shown under "This week"; a report larger than the
command reads is dropped as unreadable; nested multipart is not searched.

The hub's Retention line follows the design commit before this one.
security.md: the review, and the accepted-risk entry says what a
stranger can and cannot do now. The roadmap no longer calls the review
pending.

  go vet ./cmd/... ./internal/dmarc/ ./internal/store/: ok
  go test ./cmd/... ./internal/dmarc/ ./internal/store/ ./internal/web/...: ok
  make e2e on the test server (with the port-25 scenarios of the next
  commit): ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:40:31 +03:00
mixandClaude Opus 5.5 505260e227 design: the DMARC hub says reports are kept 500 a domain
The retention cap is counted per domain (the security commit after this one); the Ingest box says so: 500 reports a domain or 90 days.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:40:30 +03:00
mixandClaude Opus 5.5 09d9625f32 dmarc-ingest: what is not a report this relay keeps is dropped, not retried
Owner, 2026-10-11: foreign and malformed reports are turned away.

The command answered every failure with EX_TEMPFAIL, so Postfix kept a
message that could never be stored and offered it again for days, each
attempt adding to the parse-failure count. IngestMessage now marks those
failures (unreadable as an aggregate report, a domain not sent from
here, another domain's report address) with ErrRejected; the command
counts one failure, writes the reason where Postfix puts it on the delivery line of the mail log
and reports the delivery as done. No bounce: the envelope sender of such
a message is whatever its author wrote, and the report addresses are
public. A failing store is still EX_TEMPFAIL.

  go vet ./cmd/... ./internal/dmarc/: ok
  go test ./cmd/panel/ ./internal/dmarc/ ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:28:12 +03:00
38 changed files with 1250 additions and 86 deletions
+74
View File
@@ -5,6 +5,80 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [2.0.1] - 2026-10-11
### Changed
- **A message that is not a report this relay keeps is dropped at once.**
One that cannot be read as an aggregate report, reports on a domain not
sent from here, or came to another domain's report address used to be put
off and offered again by Postfix for days, counted as a parse failure each
time. It is now accepted and discarded - no retry, and no bounce, since the
envelope sender of such a message is whatever its author wrote. It counts
as one parse failure and the reason is in the mail log, on the message's
own delivery line (Server › System log, `dropped: …`). Only a failure of
the database is still retried.
- **A full backup no longer holds the Postfix queue**, from the panel or from
`selfpost-backup`. It 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 not carried over; a restored instance starts
with an empty queue. To keep the queue, stop the instance and `tar` the
project directory.
- DMARC reports are kept 500 **for each domain** (and 90 days), not 500 for
the whole server - see Security.
### Fixed
- **Full backup from the panel works.** Since the Postfix queue moved under
`/data` in 1.3.0 the button answered with a 200 and ten bytes: the archive
includes the queue, Postfix keeps the queue readable by its own user alone,
and the panel is not that user ("open /data/postfix/queue/active:
permission denied" in the log). `selfpost-backup` run with `docker exec`
was not affected - it runs as root. The queue is no longer part of a backup
(see Changed), so the two archives are the same again. Everything is read
through before the download starts, so a failure is a message on the page
and not a file cut short. One thing the panel may still be unable to read
is the private key in `certs/`: it then refuses and says what to do -
`chgrp 998 certs/privkey.pem && chmod 640 certs/privkey.pem` on the host,
998 being the container's `selfpost` group, whose id is now fixed in the
image.
### Security
Review of the DMARC report path (Fable, 2026-10-11), which started to run for
the first time in 2.0.0. Nothing Critical or High.
- **A report is bounded before it is stored.** The XML comes from anyone who
knows a report address, and its 8 MiB limit left room for some 160 000
records or a single name of megabytes - rows in the database and cells of
the report page. A report holds at most 5 000 records (a larger one is
dropped), every text field is cut to 255 bytes, and a record's count is
capped so the sums cannot wrap.
- **One domain's reports cannot push out another's.** The cap of 500 was for
the whole server, so a stranger filing reports under one domain's address
could evict every other domain's history. It is counted per domain now.
- The delivery command runs one at a time, not up to a hundred in parallel.
- The reason written to the mail log for a dropped message is cut to 200
bytes and anything outside printable ASCII is replaced: parts of it are the
sender's own bytes (a header line, a file name inside an archive).
- `SELFPOST_DATA_DIR` / `SELFPOST_DB_PATH` with whitespace or `$` are refused
at start when DMARC ingest is on, instead of handing the delivery command
another path and so an empty database of its own.
### Added
- e2e: the run now ends on port 25 with both optional features on. A DMARC
report is mailed to a domain's hosted address and has to appear under
Outbound › DMARC reports; a report on another domain and a message with no
report are accepted and dropped; mail for an inbound domain reaches its
upstream and other recipients are refused. Until now the suite only read
back the generated configuration of these features.
- e2e: full backup and restore. The run takes a backup from the panel, throws
the installation away, unpacks the archive into the empty project directory
and starts it: the administrator signs in, the domain has the same DKIM key,
and mail sent by the application verifies against the record published
before the backup.
## [2.0.0] - 2026-10-11 ## [2.0.0] - 2026-10-11
The panel redesign. A breaking release: every panel URL changed and the The panel redesign. A breaking release: every panel URL changed and the
+1 -1
View File
@@ -112,7 +112,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.0.0 ghcr.io/mixeme/selfpost:2.0.1
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
+4 -1
View File
@@ -62,7 +62,10 @@ RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read # entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read
# the sasldb2/sender map, and lets the panel reach the supervisor control socket # the sasldb2/sender map, and lets the panel reach the supervisor control socket
# to signal OpenDKIM/Postfix reloads without any process running as root. # to signal OpenDKIM/Postfix reloads without any process running as root.
RUN groupadd --system selfpost \ # The id is fixed, and it is the one the group has always had: an operator
# gives the TLS private key on the host to this group by number, so that the
# panel can put it into a full backup (guide § Taking a full backup).
RUN groupadd --system --gid 998 selfpost \
&& usermod -aG selfpost panel \ && usermod -aG selfpost panel \
&& usermod -aG selfpost opendkim \ && usermod -aG selfpost opendkim \
&& usermod -aG selfpost postfix && usermod -aG selfpost postfix
+13 -1
View File
@@ -291,11 +291,23 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# attribute" on every report. They go into the service line itself. # attribute" on every report. They go into the service line itself.
# ${recipient} is expanded by pipe(8), not by the shell: the command # ${recipient} is expanded by pipe(8), not by the shell: the command
# learns which report address the message came to from its argument. # learns which report address the message came to from its argument.
postconf -M 'dmarc-ingest/unix=dmarc-ingest unix - n n - - pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}' # One at a time (the 1 before "pipe"): reports are in no hurry, every run
# writes to the database the panel writes to, and a hundred of them in
# parallel - Postfix's default - is what a flood of reports would start.
postconf -M 'dmarc-ingest/unix=dmarc-ingest unix - n n - 1 pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}'
# One recipient per run, so ${recipient} is one argument. # One recipient per run, so ${recipient} is one argument.
postconf -e "dmarc-ingest_destination_recipient_limit=1" postconf -e "dmarc-ingest_destination_recipient_limit=1"
# pipe(8) starts the command with export_environment only, so what the # pipe(8) starts the command with export_environment only, so what the
# command reads from the container's environment is named there. # command reads from the container's environment is named there.
# export_environment is a list split at whitespace in which Postfix
# expands "$": a path with either would reach the command as another
# path, and it would quietly open a database of its own.
case "${SELFPOST_DATA_DIR:-}${SELFPOST_DB_PATH:-}" in
*[[:space:]]* | *'$'*)
echo "postfix-config: SELFPOST_DATA_DIR and SELFPOST_DB_PATH must not contain whitespace or \$ when DMARC_REPORTS_ENABLE=true" >&2
exit 1
;;
esac
DMARC_INGEST_ENV="DMARC_REPORTS_ENABLE=true" DMARC_INGEST_ENV="DMARC_REPORTS_ENABLE=true"
[ -z "${SELFPOST_DATA_DIR:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DATA_DIR=${SELFPOST_DATA_DIR}" [ -z "${SELFPOST_DATA_DIR:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DATA_DIR=${SELFPOST_DATA_DIR}"
[ -z "${SELFPOST_DB_PATH:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DB_PATH=${SELFPOST_DB_PATH}" [ -z "${SELFPOST_DB_PATH:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DB_PATH=${SELFPOST_DB_PATH}"
+35 -1
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -28,14 +29,47 @@ func runDMARCIngestMode() error {
recipient := ingestRecipient(os.Args[1:]) recipient := ingestRecipient(os.Args[1:])
if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil { if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil {
if !errors.Is(err, dmarc.ErrRejected) {
return err
}
// Not a report this relay keeps. It is dropped: deferring would have
// Postfix try it again for days, and a bounce would go to an envelope
// sender anyone can write - the report addresses are public.
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil { if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
log.Printf("dmarc ingest: record failure: %v", incrErr) log.Printf("dmarc ingest: record failure: %v", incrErr)
} }
return err logRejected(err)
} }
return nil return nil
} }
// logRejected leaves the reason in the mail log. Postfix puts the start of
// what a delivery command wrote - about a hundred bytes - into its own line
// for the message ("status=sent (delivered via dmarc-ingest service (...))"),
// so the reason goes out first and bare: no prefix, no date, no recipient,
// all of which that line already has. postlog is no use here: pipe(8) starts
// the command without the group that may write to Postfix's log socket.
func logRejected(reason error) {
fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.Error()+": ")))
}
// logSafe makes a reason fit for a log line. Parts of it are the sender's own
// bytes - a header line, a file name inside an archive, a piece of XML - so it
// is cut short and anything that is not printable ASCII becomes "?", whatever
// Postfix would have done with it.
func logSafe(s string) string {
const max = 200
if len(s) > max {
s = s[:max]
}
return strings.Map(func(r rune) rune {
if r < 0x20 || r > 0x7e {
return '?'
}
return r
}, s)
}
// ingestRecipient is the report address the message was delivered to. Postfix's // ingestRecipient is the report address the message was delivered to. Postfix's
// pipe(8) hands it over as an argument (${recipient} in the service's argv, // pipe(8) hands it over as an argument (${recipient} in the service's argv,
// build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8). // build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8).
+14
View File
@@ -18,3 +18,17 @@ func TestIngestRecipient(t *testing.T) {
} }
} }
} }
func TestLogSafe(t *testing.T) {
got := logSafe("zip entry \"a\nstatus=bounced\tx\" \x1b[31m é")
if want := "zip entry \"a?status=bounced?x\" ?[31m ?"; got != want {
t.Errorf("logSafe = %q, want %q", got, want)
}
long := make([]byte, 1000)
for i := range long {
long[i] = 'a'
}
if got := logSafe(string(long)); len(got) != 200 {
t.Errorf("logSafe kept %d bytes of 1000, want 200", len(got))
}
}
+1 -1
View File
@@ -21,7 +21,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:2.0.0 image: ghcr.io/mixeme/selfpost:2.0.1
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}"
+1 -1
View File
@@ -78,7 +78,7 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p> <p class="mb-3"><code>data/</code> (database, DKIM keys, credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. The Postfix queue is left out: it cannot be copied reliably while the server is running. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spbk</code>)</label></div> <div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spbk</code>)</label></div>
<div class="sp-pair"> <div class="sp-pair">
+1 -1
View File
@@ -80,7 +80,7 @@
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div> <div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div> <div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+&lt;domain&gt;@mail.example.org</dd></div> <div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+&lt;domain&gt;@mail.example.org</dd></div>
<div><dt>Retention</dt><dd>500 reports or 90 days</dd></div> <div><dt>Retention</dt><dd>500 reports a domain or 90 days</dd></div>
</dl> </dl>
</div> </div>
</div> </div>
+1 -1
View File
@@ -135,7 +135,7 @@
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div> <div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys, application credentials and the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div> <div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 998 on the host (<code>chgrp 998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div> <div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div> <div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
</div> </div>
@@ -12,7 +12,7 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p> <p class="mb-3"><code>data/</code> (database, DKIM keys, credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. The Postfix queue is left out: it cannot be copied reliably while the server is running. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spbk</code>)</label></div> <div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spbk</code>)</label></div>
<div class="sp-pair"> <div class="sp-pair">
@@ -14,7 +14,7 @@
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div> <div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div> <div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+&lt;domain&gt;@mail.example.org</dd></div> <div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+&lt;domain&gt;@mail.example.org</dd></div>
<div><dt>Retention</dt><dd>500 reports or 90 days</dd></div> <div><dt>Retention</dt><dd>500 reports a domain or 90 days</dd></div>
</dl> </dl>
</div> </div>
</div> </div>
@@ -69,7 +69,7 @@
</div> </div>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div> <div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys, application credentials and the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div> <div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 998 on the host (<code>chgrp 998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div> <div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div> <div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
</div> </div>
+3 -2
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.0.0`; adjust if semver changes mid-track): **Planned version cuts** (from pin `2.0.1`; adjust if semver changes mid-track):
| Stage | ID | Cut | | Stage | ID | Cut |
|---|---|---| |---|---|---|
@@ -124,7 +124,8 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
| 11 | panel-redesign plan: component kit, stages, reorder (docs only, owner-requested cut) | `1.9.4` (PATCH) | | 11 | panel-redesign plan: component kit, stages, reorder (docs only, owner-requested cut) | `1.9.4` (PATCH) |
| 12 | GHCR release fix: keep per-arch images, sweep orphaned versions (CI only) | `1.9.5` (PATCH) | | 12 | GHCR release fix: keep per-arch images, sweep orphaned versions (CI only) | `1.9.5` (PATCH) |
| 13 | panel-redesign | `2.0.0` (MAJOR, no compatibility) | | 13 | panel-redesign | `2.0.0` (MAJOR, no compatibility) |
| 14 | inbound-antispam-panel | `2.1.0` (planned; was `1.10.0`, reordered 2026-10-09) | | 14 | DMARC ingest drops and bounds, full backup from the panel, no queue in a backup | `2.0.1` (PATCH) |
| 15 | inbound-antispam-panel | `2.1.0` (planned; was `1.10.0`, reordered 2026-10-09) |
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.
+33 -8
View File
@@ -183,7 +183,7 @@ 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.0.0`. Intermediate deliberately never `:latest`. The current pin is `2.0.1`. Intermediate
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check (see image was published. Pinning matters because of the backup version check (see
[Full backup and restore](#full-backup-and-restore)): the panel binary's [Full backup and restore](#full-backup-and-restore)): the panel binary's
@@ -542,10 +542,14 @@ journal-milter (level 2) is down. There is no per-IP bypass.
### Full backup and restore ### Full backup and restore
**Full backup** is a self-contained project archive: `data/` (SQLite, all **Full backup** is a self-contained project archive: `data/` (SQLite, all
domains' DKIM keys, all applications' SASL credentials, the Postfix queue, domains' DKIM keys, all applications' SASL credentials, `manifest.json` with
`manifest.json` with the version that created it), plus `docker-compose.yml`, the version that created it), plus `docker-compose.yml`, `.env`, and `certs/`
`.env`, and `certs/` from the operator directory next to `./data`. Delivery from the operator directory next to `./data`. Delivery logs under `data/log/`
logs under `data/log/` are excluded. The base compose file mounts the project 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,
stop the instance and use `tar` (methods 3 and 4 below). The base compose file mounts the project
directory read-only at `/selfpost-deploy` so the panel and CLI can read those directory read-only at `/selfpost-deploy` so the panel and CLI can read those
deploy files — without that mount, *Full backup* refuses with an error. deploy files — without that mount, *Full backup* refuses with an error.
@@ -556,12 +560,25 @@ instance keeps running, what goes into the archive, and what happens on restore.
**1. Panel — while the container is running (no downtime).** *Server › Backup* **1. Panel — while the container is running (no downtime).** *Server › Backup*
→ *Full backup*. *Encrypt with a password* is ticked; untick it for a plain → *Full backup*. *Encrypt with a password* is ticked; untick it for a plain
archive. Same archive as the CLI below. archive. Same archive as the CLI below. The panel runs as its own user, not as
root, so **the private key in `certs/` has to be readable by it**: give the
file to the container's `selfpost` group, which has the fixed id 998 and holds
both the panel and Postfix —
```sh
chgrp 998 certs/privkey.pem && chmod 640 certs/privkey.pem
```
— and have whatever renews the certificate do the same (a certbot deploy hook,
for one). A key readable by root alone is enough for Postfix, but then the
panel refuses to make a backup and names the file: it does not hand over an
archive without the key.
**2. `selfpost-backup` CLI — while the container is running (no downtime).** **2. `selfpost-backup` CLI — while the container is running (no downtime).**
The panel button and this command call the same code path: a consistent SQLite The panel button and this command call the same code path: a consistent SQLite
snapshot via `VACUUM INTO`, then a gzip tar of `data/` (minus diagnostics) plus snapshot via `VACUUM INTO`, then a gzip tar of `data/` (minus diagnostics) plus
deploy files. deploy files. `docker exec` runs the command as root, so it reads the private
key whatever its permissions.
```sh ```sh
docker exec <container> selfpost-backup > selfpost-backup.tar.gz docker exec <container> selfpost-backup > selfpost-backup.tar.gz
@@ -616,6 +633,8 @@ methods avoid it by stopping first.
| Self-contained archive | Yes | Yes | No — deploy files separate | | Self-contained archive | Yes | Yes | No — deploy files separate |
| SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped | | SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped |
| `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) | | `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) |
| Postfix queue | No | Yes | Yes |
| `certs/` private key | Yes (the panel needs it readable by group 998) | Yes | No — deploy files separate |
| `data/manifest.json` | Written (version stamp) | Absent | Absent | | `data/manifest.json` | Written (version stamp) | Absent | Absent |
| Restore version check | Yes — must match image tag | No | No | | Restore version check | Yes — must match image tag | No | No |
| Post-restore *Resync* | Yes, on first boot | No | No | | Post-restore *Resync* | Yes, on first boot | No | No |
@@ -891,7 +910,13 @@ sees the *Ingest* box — whether ingest works, the last report received, report
kept this week and parse failures. Open a domain's roll-up from the list or kept this week and parse failures. Open a domain's roll-up from the list or
from *DMARC reports* in the domain page's menu. A user with the *domain* role from *DMARC reports* in the domain page's menu. A user with the *domain* role
sees the reports of the domains assigned to them, without the *Ingest* box. sees the reports of the domains assigned to them, without the *Ingest* box.
Summaries are pruned (500 kept, 90 days max). Summaries are pruned (500 kept for each domain, 90 days max).
A message that is not a report the relay keeps — unreadable as an aggregate
report, about a domain not sent from here, or delivered to another domain's
report address — is dropped without a retry and without a bounce. It adds one
to *parse failures*, and the reason is on the message's delivery line in the mail log
(*Server › System log*, `dropped: …`).
**Not an open relay.** The inbound smtpd offers no SASL. With DMARC ingest **Not an open relay.** The inbound smtpd offers no SASL. With DMARC ingest
alone, `check_recipient_access` permits only configured report addresses; alone, `check_recipient_access` permits only configured report addresses;
+2 -1
View File
@@ -47,7 +47,8 @@ development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11); th
**inbound-antispam-panel** (`2.1.0`), built in the new component kit — **inbound-antispam-panel** (`2.1.0`), built in the new component kit —
styling its two screens once instead of twice. panel-docs shipped in styling its two screens once instead of twice. 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]`
(security review of the ingest path pending); domain-stats-auto-ratelimit in (its ingest path first ran in `2.0.0` and was reviewed on 2026-10-11, see
[security.md](security.md)); domain-stats-auto-ratelimit in
`[1.6.0]`; send-log-retention in `[1.5.0]`; inbound-relay in `[1.4.0]`; `[1.6.0]`; send-log-retention in `[1.5.0]`; inbound-relay in `[1.4.0]`;
queue-retries in `[1.3.1]`; the 2026-08-13 full-tree review follow-ups are in queue-retries in `[1.3.1]`; the 2026-08-13 full-tree review follow-ups are in
`[1.3.0]`. Candidates need explicit agreement before they join the queue. `[1.3.0]`. Candidates need explicit agreement before they join the queue.
+17 -3
View File
@@ -26,7 +26,14 @@ the user form did not end that user's sessions, internal errors reached forms a
(see [Authentication and sessions](#authentication-and-sessions) and [Errors (see [Authentication and sessions](#authentication-and-sessions) and [Errors
shown to users](#errors-shown-to-users)); two deliberate behaviours are shown to users](#errors-shown-to-users)); two deliberate behaviours are
recorded under Accepted risks (upstream of a delegated inbound domain, profile recorded under Accepted risks (upstream of a delegated inbound domain, profile
saved without the password). saved without the password). The 2026-10-11 review (Fable) covered the DMARC
report path from port 25 to the report pages, which ran for the first time in
2.0.0 - the 2026-08-19 pass read code that Postfix could not yet start: no
Critical or High finding; a report's size was bounded only as bytes (now 5 000
records, 255 bytes a field), the retention cap was one for the whole server
(now per domain), the delivery command could run a hundred at once (now one),
and the reason logged for a dropped message carried the sender's bytes as they
came (now cut and reduced to printable ASCII).
(2) **Accepted risks** — (2) **Accepted risks** —
deliberate departures beyond the mandatory, recorded so the decision is not deliberate departures beyond the mandatory, recorded so the decision is not
lost. lost.
@@ -274,8 +281,15 @@ deferred item from the roadmap.
reporters are arbitrary third parties and the mailbox address is published in reporters are arbitrary third parties and the mailbox address is published in
DNS. The ingest already constrains the meaningful part — a report cannot be DNS. The ingest already constrains the meaningful part — a report cannot be
filed under a domain this instance does not host, and the claimed domain is filed under a domain this instance does not host, and the claimed domain is
cross-checked against the `+tag` of the delivery address — and the data is cross-checked against the `+tag` of the delivery address (effective since
advisory statistics only; nothing enforces off it. The trigger to revisit is 2.0.0: before it the recipient never reached the command) — and the data is
advisory statistics only; nothing enforces off it. What a stranger can do is
limited to a domain that chose a SelfPost-hosted address, through that
address: file reports under it, replace one whose reporter and id they know,
and fill that domain's 500 — not another domain's. A message that is not a
report the relay keeps is dropped, not bounced (the envelope sender is the
author's to write); a genuine report the parser cannot read is lost the same
way, with a count and a line in the mail log. The trigger to revisit is
any feature that lets report contents drive an automatic action (policy any feature that lets report contents drive an automatic action (policy
changes, rate limiting, alerting an operator into a config change). changes, rate limiting, alerting an operator into a config change).
- **The application auth-IP allow-list is enforced fail-open.** The check runs - **The application auth-IP allow-list is enforced fail-open.** The check runs
+84 -10
View File
@@ -1,16 +1,16 @@
// Package backup implements SelfPost's full-server backup and the restore // Package backup implements SelfPost's full-server backup and the restore
// version guard (architecture.md § Persistence). A full backup is a // version guard (architecture.md § Persistence). A full backup is a
// gzip-compressed tar of the consolidated persistent state under /data — the // gzip-compressed tar of the consolidated persistent state under /data — the
// SQLite database (as a consistent snapshot), the per-domain DKIM keys, the // SQLite database (as a consistent snapshot), the per-domain DKIM keys and the
// SASL database, and the Postfix queue — plus docker-compose.yml, .env, and // SASL database — plus docker-compose.yml, .env, and certs/ from the
// certs/ from the operator's deploy directory, and a manifest recording the // operator's deploy directory, and a manifest recording the SelfPost version
// SelfPost version that produced it. Postfix delivery logs under log/ are // that produced it. Postfix delivery logs under log/ are excluded
// excluded (diagnostics, not state). // (diagnostics, not state), and so is the Postfix queue (see QueueDir).
// //
// Restore is not a separate code path in the panel: a backup is extracted into // Restore is not a separate code path in the panel: a backup is extracted into
// the operator's project directory (data/, docker-compose.yml, .env, certs/) // the operator's project directory (data/, docker-compose.yml, .env, certs/)
// before first start. The archive carries everything needed to bring the // before first start. The archive carries everything needed to bring the
// instance back — DKIM keys, sasldb2, sender map, queue, and deploy files — // instance back — DKIM keys, sasldb2, sender map, and deploy files —
// so the operator only adjusts hostname or proxy settings on a new host. The // so the operator only adjusts hostname or proxy settings on a new host. The
// restore-specific steps the panel runs are CheckRestore, which refuses to // restore-specific steps the panel runs are CheckRestore, which refuses to
// boot if the manifest's version does not match the running binary so // boot if the manifest's version does not match the running binary so
@@ -28,6 +28,7 @@ import (
"compress/gzip" "compress/gzip"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
@@ -81,6 +82,18 @@ type Params struct {
OnWarn func(string) OnWarn func(string)
} }
// QueueDir is the Postfix queue, relative to the data directory. No backup
// holds it (owner, 2026-10-11). An archive is made while the instance runs,
// and Postfix moves messages between the queue's directories all the time: a
// copy taken then can hold a message twice or not at all, so what it would
// bring back is not the queue. Nor could the panel read it - Postfix keeps
// every queue file readable by its own user alone, which is why the panel's
// backup failed outright from 1.3.0 to 2.0.0. What is not carried over is mail
// still waiting for delivery; a start without a queue directory builds an
// empty one. Whoever needs the queue stops the instance and archives the
// project directory (guide § Taking a full backup).
const QueueDir = "postfix/queue"
// excludedFromArchive lists the data-directory entries a backup never carries. // excludedFromArchive lists the data-directory entries a backup never carries.
// The live database files are replaced by a consistent VACUUM INTO snapshot // The live database files are replaced by a consistent VACUUM INTO snapshot
// written under the canonical name; the setup token is transient bootstrap // written under the canonical name; the setup token is transient bootstrap
@@ -145,7 +158,7 @@ func Create(w io.Writer, p Params) error {
return err return err
} }
if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive); err != nil { if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue); err != nil {
return err return err
} }
@@ -181,6 +194,52 @@ func validateDeployRoot(deployRoot string) error {
return nil return nil
} }
// skipFunc says whether the entry at path (rel to the root being walked) is
// left out of the archive. A directory left out is not descended into.
type skipFunc func(path, rel string, d fs.DirEntry) bool
// skipQueue leaves the Postfix queue out of every archive.
func skipQueue(_, rel string, _ fs.DirEntry) bool {
return filepath.ToSlash(rel) == QueueDir
}
// Check reads through everything Create would put into the archive, without
// writing one, and returns the first thing that cannot be read. The panel
// streams its archive to the browser, so once Create has begun a failure can
// only cut the download short; this is run first, while a refusal can still be
// shown as a page.
//
// The panel is not root, and nothing is left out on that account: its archive
// is the one selfpost-backup makes, or it is not made. What it most likely
// cannot read is the private key under certs/ - see UnreadableCert.
func Check(p Params) error {
if p.DataDir == "" || p.DBPath == "" {
return fmt.Errorf("backup: DataDir and DBPath are required")
}
if err := ValidateDeployRoot(p.DeployRoot); err != nil {
return err
}
if err := addTree(nil, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue); err != nil {
return err
}
return addDeployFiles(nil, p.DeployRoot, nil)
}
// UnreadableCert reports whether err, from Check or Create, is a file under
// the deploy root's certs/ that the caller is not allowed to read, and names
// it relative to the deploy root ("certs/privkey.pem").
func UnreadableCert(err error, deployRoot string) (name string, ok bool) {
var pathErr *fs.PathError
if !errors.As(err, &pathErr) || !errors.Is(err, fs.ErrPermission) {
return "", false
}
rel, relErr := filepath.Rel(filepath.Join(deployRoot, CertsDirName), pathErr.Path)
if relErr != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return "", false
}
return CertsDirName + "/" + filepath.ToSlash(rel), true
}
func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) error { func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) error {
for _, name := range []string{ComposeFileName, EnvFileName} { for _, name := range []string{ComposeFileName, EnvFileName} {
src := filepath.Join(deployRoot, name) src := filepath.Join(deployRoot, name)
@@ -206,14 +265,16 @@ func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) erro
} }
return fmt.Errorf("backup: stat %s: %w", CertsDirName, err) return fmt.Errorf("backup: stat %s: %w", CertsDirName, err)
} }
return addTree(tw, certsDir, CertsDirName+"/", nil) return addTree(tw, certsDir, CertsDirName+"/", nil, nil)
} }
// addTree walks root and adds every regular file (and directory, to preserve // addTree walks root and adds every regular file (and directory, to preserve
// empty ones and modes) to tw under archivePrefix + path relative to root. // empty ones and modes) to tw under archivePrefix + path relative to root.
// When exclude is non-nil, top-level names relative to root are skipped. // When exclude is non-nil, top-level names relative to root are skipped; skip,
// when non-nil, leaves out whatever it says. With a nil tw nothing is written:
// every file is opened and closed, which is how Check finds what cannot be read.
// Non-regular, non-directory entries (symlinks, sockets) are skipped. // Non-regular, non-directory entries (symlinks, sockets) are skipped.
func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool) error { func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool, skip skipFunc) error {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
@@ -236,12 +297,22 @@ func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool
} }
} }
if skip != nil && skip(path, rel, d) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
info, err := d.Info() info, err := d.Info()
if err != nil { if err != nil {
return err return err
} }
switch { switch {
case d.IsDir(): case d.IsDir():
if tw == nil {
return nil
}
hdr := &tar.Header{ hdr := &tar.Header{
Typeflag: tar.TypeDir, Typeflag: tar.TypeDir,
Name: name + "/", Name: name + "/",
@@ -282,6 +353,9 @@ func writeTarFile(tw *tar.Writer, name string, mode fs.FileMode, srcPath string)
return fmt.Errorf("backup: open %s: %w", srcPath, err) return fmt.Errorf("backup: open %s: %w", srcPath, err)
} }
defer f.Close() defer f.Close()
if tw == nil {
return nil
}
info, err := f.Stat() info, err := f.Stat()
if err != nil { if err != nil {
+70 -1
View File
@@ -5,7 +5,9 @@ import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -112,7 +114,6 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private", DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
DataArchivePrefix + "sasl/sasldb2", DataArchivePrefix + "sasl/sasldb2",
DataArchivePrefix + "postfix/sender_login_maps", DataArchivePrefix + "postfix/sender_login_maps",
DataArchivePrefix + "postfix/queue/deferred/sample",
ComposeFileName, ComposeFileName,
EnvFileName, EnvFileName,
CertsDirName + "/fullchain.pem", CertsDirName + "/fullchain.pem",
@@ -129,6 +130,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
DataArchivePrefix + "selfpost.db-shm", DataArchivePrefix + "selfpost.db-shm",
DataArchivePrefix + "log/mail.log", DataArchivePrefix + "log/mail.log",
DataArchivePrefix + "log/mail.log.1", DataArchivePrefix + "log/mail.log.1",
DataArchivePrefix + "postfix/queue/deferred/sample",
} { } {
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)
@@ -235,3 +237,70 @@ func TestCheckRestoreWrongFormatRejected(t *testing.T) {
t.Error("CheckRestore wrong format reported a restore") t.Error("CheckRestore wrong format reported a restore")
} }
} }
// No archive holds the Postfix queue; the rest is there, the private key
// included, and Check agrees that it can be made.
func TestCreateLeavesTheQueueOut(t *testing.T) {
dataDir, dbPath, deployRoot := seedDataDir(t)
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}
if err := Check(p); err != nil {
t.Fatalf("Check: %v", err)
}
var buf bytes.Buffer
if err := Create(&buf, p); err != nil {
t.Fatalf("Create: %v", err)
}
files := readArchive(t, buf.Bytes())
for name := range files {
if strings.HasPrefix(name, DataArchivePrefix+QueueDir) {
t.Errorf("the panel's archive holds %s", name)
}
}
for _, name := range []string{
DataArchivePrefix + "selfpost.db",
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
DataArchivePrefix + "sasl/sasldb2",
DataArchivePrefix + "postfix/sender_login_maps",
ComposeFileName, EnvFileName,
CertsDirName + "/fullchain.pem",
CertsDirName + "/privkey.pem",
} {
if _, ok := files[name]; !ok {
t.Errorf("archive missing %s", name)
}
}
}
// A file under certs/ that may not be read is told apart from any other
// failure, so the panel can say what to do about it.
func TestUnreadableCert(t *testing.T) {
root := filepath.Join("srv", "deploy")
denied := func(path string) error {
return fmt.Errorf("backup: open %s: %w", path, &fs.PathError{Op: "open", Path: path, Err: fs.ErrPermission})
}
if name, ok := UnreadableCert(denied(filepath.Join(root, "certs", "privkey.pem")), root); !ok || name != "certs/privkey.pem" {
t.Errorf("certs/privkey.pem: got %q, %v", name, ok)
}
if name, ok := UnreadableCert(denied(filepath.Join(root, ".env")), root); ok {
t.Errorf(".env was taken for a certificate file: %q", name)
}
if name, ok := UnreadableCert(denied(filepath.Join("data", "postfix", "x")), root); ok {
t.Errorf("a data file was taken for a certificate file: %q", name)
}
missing := &fs.PathError{Op: "open", Path: filepath.Join(root, "certs", "privkey.pem"), Err: fs.ErrNotExist}
if _, ok := UnreadableCert(missing, root); ok {
t.Error("a missing file was taken for one that may not be read")
}
}
// Check finds what Create would stumble over, before anything is written.
func TestCheckReportsWhatCannotBeRead(t *testing.T) {
dataDir, dbPath, deployRoot := seedDataDir(t)
if err := os.Remove(filepath.Join(deployRoot, EnvFileName)); err != nil {
t.Fatal(err)
}
if err := Check(Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}); err == nil {
t.Fatal("Check passed with .env missing from the deploy root")
}
}
+109
View File
@@ -0,0 +1,109 @@
package dmarc
import (
"errors"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/store"
)
func reportXML(orgName, domain, records string) string {
return `<?xml version="1.0"?><feedback><report_metadata><org_name>` + orgName +
`</org_name><email>a@example.net</email><report_id>1</report_id>` +
`<date_range><begin>1723593600</begin><end>1723680000</end></date_range></report_metadata>` +
`<policy_published><domain>` + domain + `</domain><p>none</p></policy_published>` + records + `</feedback>`
}
const oneRecord = `<record><row><source_ip>203.0.113.10</source_ip><count>3</count>` +
`<policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated></row>` +
`<identifiers><header_from>example.com</header_from></identifiers></record>`
// The XML is a stranger's: what it may make the relay store is bounded.
func TestParseAggregateBoundsWhatAReportHolds(t *testing.T) {
long := strings.Repeat("é", 4000)
got, err := ParseAggregate([]byte(reportXML(long, "example.com", oneRecord)))
if err != nil {
t.Fatal(err)
}
if len(got.Reporter) > maxFieldLen || !strings.HasPrefix(long, got.Reporter) || got.Reporter == "" {
t.Fatalf("reporter name is %d bytes, want the start of the name in at most %d", len(got.Reporter), maxFieldLen)
}
if strings.ContainsRune(got.Reporter, '�') {
t.Fatal("the name was cut in the middle of a character")
}
huge := `<record><row><source_ip>203.0.113.10</source_ip><count>9223372036854775807</count>` +
`<policy_evaluated><dkim>pass</dkim></policy_evaluated></row></record>`
got, err = ParseAggregate([]byte(reportXML("r", "example.com", huge+huge)))
if err != nil {
t.Fatal(err)
}
if got.PassCount != 2*maxRecordCount {
t.Fatalf("pass count = %d, want two records clamped to %d each", got.PassCount, maxRecordCount)
}
if _, err := ParseAggregate([]byte(reportXML("r", "example.com", strings.Repeat(oneRecord, maxReportRecords)))); err != nil {
t.Fatalf("a report of %d records was refused: %v", maxReportRecords, err)
}
if _, err := ParseAggregate([]byte(reportXML("r", "example.com", strings.Repeat(oneRecord, maxReportRecords+1)))); err == nil {
t.Fatalf("a report of %d records was accepted", maxReportRecords+1)
}
}
// A report over the bound is one the relay does not keep: dropped, not retried.
func TestIngestMessageRejectsAReportWithTooManyRecords(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
xml := reportXML("r", "example.com", strings.Repeat(oneRecord, maxReportRecords+1))
err = IngestMessage(st, strings.NewReader(gzipMessage(t, xml)), "dmarc-reports+example.com@mail.example.com", time.Now().UTC())
if !errors.Is(err, ErrRejected) {
t.Fatalf("err = %v, want ErrRejected", err)
}
}
// Reports filed under one domain do not push another domain's reports out.
func TestIngestKeepsEachDomainsReportsApart(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
now := time.Now().UTC()
if _, err := st.InsertDMARCReport(store.DMARCReport{
Domain: "quiet.example", Reporter: "genuine", ReportID: "1", PeriodBegin: now, PeriodEnd: now, ReceivedAt: now.Add(-time.Hour),
}); err != nil {
t.Fatal(err)
}
for i := 0; i < store.DMARCReportsMaxKeep+20; i++ {
if _, err := st.InsertDMARCReport(store.DMARCReport{
Domain: "flooded.example", Reporter: "stranger", ReportID: strings.Repeat("x", 1+i), PeriodBegin: now, PeriodEnd: now, ReceivedAt: now,
}); err != nil {
t.Fatal(err)
}
}
if err := st.PruneDMARCReports("flooded.example"); err != nil {
t.Fatal(err)
}
quiet, err := st.ListDMARCReports(store.DMARCReportScope{Domains: []string{"quiet.example"}}, 10)
if err != nil {
t.Fatal(err)
}
if len(quiet) != 1 {
t.Fatalf("the other domain has %d reports after the flood, want its 1", len(quiet))
}
flooded, err := st.ListDMARCReports(store.DMARCReportScope{Domains: []string{"flooded.example"}}, 10000)
if err != nil {
t.Fatal(err)
}
if len(flooded) > store.DMARCReportsMaxKeep {
t.Fatalf("the flooded domain keeps %d reports, want at most %d", len(flooded), store.DMARCReportsMaxKeep)
}
}
+16 -6
View File
@@ -3,6 +3,7 @@ package dmarc
import ( import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"io" "io"
"mime" "mime"
@@ -16,6 +17,15 @@ import (
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
) )
// ErrRejected marks a message that is not a report this relay keeps and never
// will be: it cannot be read as an aggregate report, or it reports on a domain
// that is not sent from here, or it came to another domain's address. Sending
// it again changes nothing, so the caller drops it. Any other error is the
// store's and worth another try.
var ErrRejected = errors.New("dmarc report rejected")
func rejected(err error) error { return fmt.Errorf("%w: %w", ErrRejected, err) }
// IngestMessage parses a raw RFC 5322 message from r and stores any DMARC // IngestMessage parses a raw RFC 5322 message from r and stores any DMARC
// aggregate attachment it finds. // aggregate attachment it finds.
// //
@@ -34,11 +44,11 @@ func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt ti
} }
payload, err := extractAggregatePayload(raw) payload, err := extractAggregatePayload(raw)
if err != nil { if err != nil {
return err return rejected(err)
} }
parsed, err := ParseAggregate(payload) parsed, err := ParseAggregate(payload)
if err != nil { if err != nil {
return err return rejected(err)
} }
recipient = strings.ToLower(strings.TrimSpace(recipient)) recipient = strings.ToLower(strings.TrimSpace(recipient))
if err := checkReportDomain(st, parsed.Domain, recipient); err != nil { if err := checkReportDomain(st, parsed.Domain, recipient); err != nil {
@@ -74,7 +84,7 @@ func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt ti
if _, err := st.InsertDMARCReport(rep); err != nil { if _, err := st.InsertDMARCReport(rep); err != nil {
return err return err
} }
return st.PruneDMARCReports() return st.PruneDMARCReports(rep.Domain)
} }
// checkReportDomain refuses a report whose policy domain is not one of this // checkReportDomain refuses a report whose policy domain is not one of this
@@ -82,17 +92,17 @@ func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt ti
// delivered to. // delivered to.
func checkReportDomain(st *store.Store, domain, recipient string) error { func checkReportDomain(st *store.Store, domain, recipient string) error {
if domain == "" { if domain == "" {
return fmt.Errorf("dmarc report: no policy domain") return rejected(fmt.Errorf("no policy domain"))
} }
if tagged := TaggedDomain(recipient); tagged != "" && tagged != domain { if tagged := TaggedDomain(recipient); tagged != "" && tagged != domain {
return fmt.Errorf("dmarc report: domain %q does not match the address it was sent to (%s)", domain, recipient) return rejected(fmt.Errorf("domain %q does not match the address it was sent to (%s)", domain, recipient))
} }
ok, err := st.DomainExists(domain) ok, err := st.DomainExists(domain)
if err != nil { if err != nil {
return err return err
} }
if !ok { if !ok {
return fmt.Errorf("dmarc report: %q is not a domain configured on this relay", domain) return rejected(fmt.Errorf("%q is not a domain configured on this relay", domain))
} }
return nil return nil
} }
+50 -15
View File
@@ -9,6 +9,7 @@ import (
"io" "io"
"strings" "strings"
"time" "time"
"unicode/utf8"
) )
// ParsedReport is the structured form of a DMARC aggregate XML payload. // ParsedReport is the structured form of a DMARC aggregate XML payload.
@@ -74,6 +75,31 @@ type feedbackXML struct {
} `xml:"record"` } `xml:"record"`
} }
// What a report may hold. The XML comes from anyone who knows a report address,
// and eight megabytes of it (maxAggregateXML) fit some 160 000 empty records or
// one name of eight megabytes - every one of them a row or a table cell later.
// A real report has tens of records, a few thousand from the largest
// receivers, and no field longer than a line.
const (
maxReportRecords = 5000
maxFieldLen = 255
maxRecordCount = 1_000_000_000
)
// field trims a text value of the report and cuts it to maxFieldLen bytes, on
// a character boundary.
func field(s string) string {
s = strings.TrimSpace(s)
if len(s) <= maxFieldLen {
return s
}
s = s[:maxFieldLen]
for len(s) > 0 && !utf8.ValidString(s) {
s = s[:len(s)-1]
}
return s
}
// ParseAggregate decodes gzip- or zip-compressed, or raw, DMARC aggregate XML. // ParseAggregate decodes gzip- or zip-compressed, or raw, DMARC aggregate XML.
func ParseAggregate(raw []byte) (ParsedReport, error) { func ParseAggregate(raw []byte) (ParsedReport, error) {
data, err := decompress(raw) data, err := decompress(raw)
@@ -87,34 +113,43 @@ func ParseAggregate(raw []byte) (ParsedReport, error) {
if doc.Metadata.ReportID == "" || doc.Policy.Domain == "" { if doc.Metadata.ReportID == "" || doc.Policy.Domain == "" {
return ParsedReport{}, fmt.Errorf("dmarc xml: missing report_id or domain") return ParsedReport{}, fmt.Errorf("dmarc xml: missing report_id or domain")
} }
if len(doc.Records) > maxReportRecords {
return ParsedReport{}, fmt.Errorf("dmarc xml: %d records, more than the %d a report may hold", len(doc.Records), maxReportRecords)
}
// Cut, not refused: a domain name is at most 253 bytes, so a longer one
// names nothing this relay sends for and is turned away as such.
out := ParsedReport{ out := ParsedReport{
Reporter: strings.TrimSpace(doc.Metadata.OrgName), Reporter: field(doc.Metadata.OrgName),
ReportID: strings.TrimSpace(doc.Metadata.ReportID), ReportID: field(doc.Metadata.ReportID),
ContactEmail: strings.TrimSpace(doc.Metadata.Email), ContactEmail: field(doc.Metadata.Email),
PeriodBegin: time.Unix(doc.Metadata.DateRange.Begin, 0).UTC(), PeriodBegin: time.Unix(doc.Metadata.DateRange.Begin, 0).UTC(),
PeriodEnd: time.Unix(doc.Metadata.DateRange.End, 0).UTC(), PeriodEnd: time.Unix(doc.Metadata.DateRange.End, 0).UTC(),
Domain: strings.ToLower(strings.TrimSpace(doc.Policy.Domain)), Domain: strings.ToLower(field(doc.Policy.Domain)),
PolicyP: strings.TrimSpace(doc.Policy.P), PolicyP: field(doc.Policy.P),
PolicySP: strings.TrimSpace(doc.Policy.SP), PolicySP: field(doc.Policy.SP),
PolicyPct: doc.Policy.Pct, PolicyPct: doc.Policy.Pct,
PolicyADKIM: strings.TrimSpace(doc.Policy.ADKIM), PolicyADKIM: field(doc.Policy.ADKIM),
PolicyASPF: strings.TrimSpace(doc.Policy.ASPF), PolicyASPF: field(doc.Policy.ASPF),
} }
if out.PolicyPct == 0 { if out.PolicyPct == 0 {
out.PolicyPct = 100 out.PolicyPct = 100
} }
for _, rec := range doc.Records { for _, rec := range doc.Records {
row := ParsedRecord{ row := ParsedRecord{
SourceIP: strings.TrimSpace(rec.Row.SourceIP), SourceIP: field(rec.Row.SourceIP),
Count: rec.Row.Count, Count: rec.Row.Count,
Disposition: strings.TrimSpace(rec.Row.Policy.Disposition), Disposition: field(rec.Row.Policy.Disposition),
SPFResult: strings.TrimSpace(rec.Row.Policy.SPF), SPFResult: field(rec.Row.Policy.SPF),
DKIMResult: strings.TrimSpace(rec.Row.Policy.DKIM), DKIMResult: field(rec.Row.Policy.DKIM),
HeaderFrom: strings.TrimSpace(rec.Identifiers.HeaderFrom), HeaderFrom: field(rec.Identifiers.HeaderFrom),
} }
if row.Count <= 0 { if row.Count <= 0 {
row.Count = 1 row.Count = 1
} }
// Bounded so that the sums of a report cannot wrap around.
if row.Count > maxRecordCount {
row.Count = maxRecordCount
}
if dmarcRecordPasses(row) { if dmarcRecordPasses(row) {
out.PassCount += row.Count out.PassCount += row.Count
} else { } else {
@@ -172,12 +207,12 @@ func unzip(raw []byte) ([]byte, error) {
} }
rc, err := f.Open() rc, err := f.Open()
if err != nil { if err != nil {
return nil, fmt.Errorf("dmarc zip open %s: %w", f.Name, err) return nil, fmt.Errorf("dmarc zip open %q: %w", field(f.Name), err)
} }
data, err := io.ReadAll(io.LimitReader(rc, maxAggregateXML)) data, err := io.ReadAll(io.LimitReader(rc, maxAggregateXML))
rc.Close() rc.Close()
if err != nil { if err != nil {
return nil, fmt.Errorf("dmarc zip read %s: %w", f.Name, err) return nil, fmt.Errorf("dmarc zip read %q: %w", field(f.Name), err)
} }
return data, nil return data, nil
} }
+45 -4
View File
@@ -4,6 +4,7 @@ import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"errors"
"testing" "testing"
"time" "time"
@@ -126,8 +127,9 @@ func TestIngestMessageRejectsUnknownDomain(t *testing.T) {
} }
defer st.Close() defer st.Close()
if err := IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), "dmarc-reports@mail.example.com", time.Now().UTC()); err == nil { err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), "dmarc-reports@mail.example.com", time.Now().UTC())
t.Fatal("report for an unconfigured domain was accepted") if !errors.Is(err, ErrRejected) {
t.Fatalf("report for an unconfigured domain: err = %v, want ErrRejected", err)
} }
list, err := st.ListDMARCReports(store.DMARCReportScope{AllDomains: true}, 10) list, err := st.ListDMARCReports(store.DMARCReportScope{AllDomains: true}, 10)
if err != nil { if err != nil {
@@ -155,8 +157,47 @@ func TestIngestMessageRejectsTagMismatch(t *testing.T) {
err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)),
"dmarc-reports+other.example@mail.example.com", time.Now().UTC()) "dmarc-reports+other.example@mail.example.com", time.Now().UTC())
if err == nil { if !errors.Is(err, ErrRejected) {
t.Fatal("report was accepted at an address tagged for another domain") t.Fatalf("report at an address tagged for another domain: err = %v, want ErrRejected", err)
}
}
// What cannot be read as a report is rejected, not put off: Postfix would
// offer the same bytes again for days.
func TestIngestMessageRejectsWhatIsNotAReport(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
for name, msg := range map[string]string{
"plain text": "From: someone@example.net\r\nSubject: hello\r\n\r\nnot a report\r\n",
"no headers": "garbage",
"broken gzip": "From: a@example.net\r\nContent-Type: application/gzip\r\n\r\nnot gzip at all",
} {
err := IngestMessage(st, bytes.NewBufferString(msg), "dmarc-reports+example.com@mail.example.com", time.Now().UTC())
if !errors.Is(err, ErrRejected) {
t.Errorf("%s: err = %v, want ErrRejected", name, err)
}
}
}
// A failing store is not the report's fault: the message is to be tried again.
func TestIngestMessageStoreFailureIsNotARejection(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
st.Close()
err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), "dmarc-reports+example.com@mail.example.com", time.Now().UTC())
if err == nil || errors.Is(err, ErrRejected) {
t.Fatalf("closed store: err = %v, want an error that is not ErrRejected", err)
} }
} }
+9 -5
View File
@@ -131,14 +131,18 @@ func (s *Store) InsertDMARCReport(rep DMARCReport) (int64, error) {
return id, nil return id, nil
} }
// PruneDMARCReports enforces count and age caps. // PruneDMARCReports enforces the age cap for every domain and the count cap
func (s *Store) PruneDMARCReports() error { // for domain. The count is kept per domain on purpose: reports are accepted
// from anyone who knows a domain's report address, and with one cap for the
// whole instance a stranger filing reports under one domain would push every
// other domain's reports out.
func (s *Store) PruneDMARCReports(domain string) error {
cutoff := time.Now().UTC().AddDate(0, 0, -DMARCReportsMaxAgeDays).Format(time.RFC3339) cutoff := time.Now().UTC().AddDate(0, 0, -DMARCReportsMaxAgeDays).Format(time.RFC3339)
if _, err := s.db.Exec(`DELETE FROM dmarc_reports WHERE received_at < ?`, cutoff); err != nil { if _, err := s.db.Exec(`DELETE FROM dmarc_reports WHERE received_at < ?`, cutoff); err != nil {
return fmt.Errorf("prune dmarc by age: %w", err) return fmt.Errorf("prune dmarc by age: %w", err)
} }
var count int var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM dmarc_reports`).Scan(&count); err != nil { if err := s.db.QueryRow(`SELECT COUNT(*) FROM dmarc_reports WHERE domain = ?`, domain).Scan(&count); err != nil {
return fmt.Errorf("count dmarc reports: %w", err) return fmt.Errorf("count dmarc reports: %w", err)
} }
if count <= DMARCReportsMaxKeep { if count <= DMARCReportsMaxKeep {
@@ -147,8 +151,8 @@ func (s *Store) PruneDMARCReports() error {
excess := count - DMARCReportsMaxKeep excess := count - DMARCReportsMaxKeep
_, err := s.db.Exec(` _, err := s.db.Exec(`
DELETE FROM dmarc_reports WHERE id IN ( DELETE FROM dmarc_reports WHERE id IN (
SELECT id FROM dmarc_reports ORDER BY received_at ASC LIMIT ? SELECT id FROM dmarc_reports WHERE domain = ? ORDER BY received_at ASC LIMIT ?
)`, excess) )`, domain, excess)
if err != nil { if err != nil {
return fmt.Errorf("prune dmarc by count: %w", err) return fmt.Errorf("prune dmarc by count: %w", err)
} }
+1 -1
View File
@@ -58,7 +58,7 @@ func TestPruneDMARCReports(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
if err := st.PruneDMARCReports(); err != nil { if err := st.PruneDMARCReports("example.com"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
list, _ := st.ListDMARCReports(DMARCReportScope{AllDomains: true}, 100) list, _ := st.ListDMARCReports(DMARCReportScope{AllDomains: true}, 100)
+32 -9
View File
@@ -70,11 +70,30 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr) h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
return return
} }
params := backup.Params{
DataDir: h.cfg.DataDir,
DBPath: h.cfg.DBPath,
Version: h.cfg.Version,
DeployRoot: h.cfg.DeployRoot,
OnWarn: func(msg string) {
logf("panel: full backup: %s", msg)
},
}
if err := backup.ValidateDeployRoot(h.cfg.DeployRoot); err != nil { if err := backup.ValidateDeployRoot(h.cfg.DeployRoot); err != nil {
logf("panel: full backup: %v", err) logf("panel: full backup: %v", err)
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", deployBackupErr(err)) h.renderBackupPageWith(w, r, http.StatusBadRequest, "", deployBackupErr(err))
return return
} }
// Everything is read through once before the first byte goes out. Past the
// headers a failure can only cut the download short - which is what the
// button did from 1.3.0 to 2.0.0, with a 200 and ten bytes, because the
// queue was not the panel's to read. Nothing is skipped to make the archive
// possible: it is the one selfpost-backup makes, or it is refused.
if err := backup.Check(params); err != nil {
logf("panel: full backup: %v", err)
h.renderBackupPageWith(w, r, http.StatusInternalServerError, "", unreadableBackupErr(err, h.cfg.DeployRoot))
return
}
stamp := time.Now().UTC().Format("20060102-150405") stamp := time.Now().UTC().Format("20060102-150405")
filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", stamp) filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", stamp)
@@ -109,15 +128,7 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
sink = env sink = env
} }
if err := backup.Create(sink, backup.Params{ if err := backup.Create(sink, params); err != nil {
DataDir: h.cfg.DataDir,
DBPath: h.cfg.DBPath,
Version: h.cfg.Version,
DeployRoot: h.cfg.DeployRoot,
OnWarn: func(msg string) {
logf("panel: full backup: %s", msg)
},
}); err != nil {
logf("panel: full backup failed: %v", err) logf("panel: full backup failed: %v", err)
return return
} }
@@ -319,6 +330,18 @@ func decryptErrorMessage(err error) string {
} }
} }
// unreadableBackupErr phrases what backup.Check found. The usual case is the
// private key: the panel runs as its own user, and a key readable by root
// alone is not its to read. The way out is on the host, so the message says
// what to do there (guide § Taking a full backup).
func unreadableBackupErr(err error, deployRoot string) string {
if name, ok := backup.UnreadableCert(err, deployRoot); ok {
return "The panel is not allowed to read " + name + ", and a backup without it would not bring the server back. " +
"On the host, give the file to group 998 and make it group-readable (chgrp 998, chmod 640), then try again. Nothing was downloaded."
}
return "Could not read everything a backup holds. Nothing was downloaded; the reason is in the panel's log."
}
// deployBackupErr phrases a pre-flight backup failure for the operator. // deployBackupErr phrases a pre-flight backup failure for the operator.
func deployBackupErr(err error) string { func deployBackupErr(err error) string {
if strings.Contains(err.Error(), "DeployRoot") || strings.Contains(err.Error(), "deploy root") { if strings.Contains(err.Error(), "DeployRoot") || strings.Contains(err.Error(), "deploy root") {
@@ -49,7 +49,7 @@ func TestDMARCHubShowsIngestAndRecentReports(t *testing.T) {
body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc") body := getBody(t, s.h.HandleDMARCList, "/outbound/dmarc")
has(t, "hub", body, has(t, "hub", body,
`<h1 class="title is-3">DMARC reports</h1>`, `<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`, `<h1 class="title is-3">DMARC reports</h1>`, `<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
`1 kept · 0 parse failures`, `Hosted addresses`, `dmarc-reports&#43;&lt;domain&gt;@mail.example.org`, `500 reports or 90 days`, `1 kept · 0 parse failures`, `Hosted addresses`, `dmarc-reports&#43;&lt;domain&gt;@mail.example.org`, `500 reports a domain or 90 days`,
`<h2>Recent reports</h2>`, `<a href="/outbound/dmarc/domains/1">example.org</a>`, `Outlook.com`, `<h2>Recent reports</h2>`, `<a href="/outbound/dmarc/domains/1">example.org</a>`, `Outlook.com`,
"<td>1\u00a0903</td>", `<span class="tag is-warning is-light">7</span>`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View</a>`) "<td>1\u00a0903</td>", `<span class="tag is-warning is-light">7</span>`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View</a>`)
lacks(t, "hub", body, `None yet`) lacks(t, "hub", body, `None yet`)
+1 -1
View File
@@ -113,7 +113,7 @@ func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
last, last,
{Label: "This week", Value: Plain(FormatCount(int64(in.KeptThisWeek)) + " kept · " + plural(int64(in.ParseFailures), "parse failure", "parse failures"))}, {Label: "This week", Value: Plain(FormatCount(int64(in.KeptThisWeek)) + " kept · " + plural(int64(in.ParseFailures), "parse failure", "parse failures"))},
{Label: "Hosted addresses", Value: Plain(in.Hosted), Mono: true, Small: true}, {Label: "Hosted addresses", Value: Plain(in.Hosted), Mono: true, Small: true},
{Label: "Retention", Value: Plain(FormatCount(int64(in.RetentionMax)) + " reports or " + plural(int64(in.RetentionDays), "day", "days"))}, {Label: "Retention", Value: Plain(FormatCount(int64(in.RetentionMax)) + " reports a domain or " + plural(int64(in.RetentionDays), "day", "days"))},
}, },
Recent: Box{No: "02", Title: "Recent reports"}, Recent: Box{No: "02", Title: "Recent reports"},
Empty: EmptyState{Icon: "ti-report-analytics", Text: Plain("No reports yet.")}, Empty: EmptyState{Icon: "ti-report-analytics", Text: Plain("No reports yet.")},
+1 -1
View File
@@ -12,7 +12,7 @@ func TestDMARCHubShowsIngestAndTheLatestReports(t *testing.T) {
pageHas(t, "DMARC reports", out, pageHas(t, "DMARC reports", out,
`<h1 class="title is-3">DMARC reports</h1>`, `<code>rua=</code>`, `<h1 class="title is-3">DMARC reports</h1>`, `<code>rua=</code>`,
`<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`, `<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
`<dd>2026-09-21 06:12 UTC</dd>`, `<dd>14 kept · 0 parse failures</dd>`, `<dt>Hosted addresses</dt>`, `<dd class="sp-mono sp-small">dmarc-reports&#43;&lt;domain&gt;@mail.example.org</dd>`, `<dd>500 reports or 90 days</dd>`, `<dd>2026-09-21 06:12 UTC</dd>`, `<dd>14 kept · 0 parse failures</dd>`, `<dt>Hosted addresses</dt>`, `<dd class="sp-mono sp-small">dmarc-reports&#43;&lt;domain&gt;@mail.example.org</dd>`, `<dd>500 reports a domain or 90 days</dd>`,
`<th>Received, UTC</th>`, `09-21 06:12`, `<a href="/outbound/dmarc/domains/1">example.org</a>`, `<th>Received, UTC</th>`, `09-21 06:12`, `<a href="/outbound/dmarc/domains/1">example.org</a>`,
"<td>1 903</td>", `<span class="tag is-warning is-light">7</span>`, `<td>0</td><td class="sp-actions"><a href="/outbound/dmarc/reports/41">View</a>`, "<td>1 903</td>", `<span class="tag is-warning is-light">7</span>`, `<td>0</td><td class="sp-actions"><a href="/outbound/dmarc/reports/41">View</a>`,
) )
+6 -2
View File
@@ -188,9 +188,13 @@ var inboundTopics = []HelpTopic{
var serverTopics = []HelpTopic{ var serverTopics = []HelpTopic{
{ID: HelpBackup, Title: "Backup and moving a domain", Body: []Text{ {ID: HelpBackup, Title: "Backup and moving a domain", Body: []Text{
Rich("Full backup downloads one archive of the whole instance: ", Code("data/"), " (the database, DKIM keys, application credentials and the queue), ", Rich("Full backup downloads one archive of the whole instance: ", Code("data/"), " (the database, DKIM keys and application credentials), ",
Code("docker-compose.yml"), ", ", Code(".env"), " and ", Code("certs/"), Code("docker-compose.yml"), ", ", Code(".env"), " and ", Code("certs/"),
". It holds secrets, so leave Encrypt with a password ticked: the file is sealed as ", Code(".spbk"), ". The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, "+
"so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server "+
"starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in ", Code("certs/"),
" has to be readable by the panel: give it to group 998 on the host (", Code("chgrp 998"), ", ", Code("chmod 640"),
"). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as ", Code(".spbk"),
", the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a ", ", the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a ",
Code(".spbk"), " first with ", Code("selfpost-backup -decrypt"), Code(".spbk"), " first with ", Code("selfpost-backup -decrypt"),
") and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and ", ") and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and ",
+1 -1
View File
@@ -13,7 +13,7 @@
<div class="column is-6"> <div class="column is-6">
{{template "box_open" .Full}} {{template "box_open" .Full}}
{{template "body_open"}} {{template "body_open"}}
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p> <p class="mb-3"><code>data/</code> (database, DKIM keys, credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. The Postfix queue is left out: it cannot be copied reliably while the server is running. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
<form class="sp-form" method="post" action="{{.FullAction}}"> <form class="sp-form" method="post" action="{{.FullAction}}">
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" value="1"{{if .Encrypt}} checked{{end}} data-encrypt-toggle> Encrypt with a password (<code>.spbk</code>)</label></div> <div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" value="1"{{if .Encrypt}} checked{{end}} data-encrypt-toggle> Encrypt with a password (<code>.spbk</code>)</label></div>
<div data-encrypt-fields> <div data-encrypt-fields>
+26 -2
View File
@@ -70,6 +70,12 @@ const (
recipient = "rcpt@sink.e2e.test" recipient = "rcpt@sink.e2e.test"
) )
// The one administrator of the run.
const (
adminUser = "e2eadmin"
adminPassword = "correct-horse-battery-staple"
)
// scenario carries state forward between the ordered subtests of TestE2E: // scenario carries state forward between the ordered subtests of TestE2E:
// each later step needs something an earlier one produced (the domain id, the // each later step needs something an earlier one produced (the domain id, the
// DKIM record, the application credentials, an observed client IP). // DKIM record, the application credentials, an observed client IP).
@@ -131,10 +137,10 @@ func TestE2E(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := p.setup(token, "e2eadmin", "correct-horse-battery-staple"); err != nil { if err := p.setup(token, adminUser, adminPassword); err != nil {
t.Fatalf("setup: %v", err) t.Fatalf("setup: %v", err)
} }
if err := p.login("e2eadmin", "correct-horse-battery-staple"); err != nil { if err := p.login(adminUser, adminPassword); err != nil {
t.Fatalf("login: %v", err) t.Fatalf("login: %v", err)
} }
sc.panel = p sc.panel = p
@@ -268,6 +274,24 @@ func TestE2E(t *testing.T) {
run("negative_level1_ratelimit", func(t *testing.T) { run("negative_level1_ratelimit", func(t *testing.T) {
testLevel1RateLimit(t, sc) testLevel1RateLimit(t, sc)
}) })
// From here on the installation is no longer the one the run started: it
// is restored from its own backup, then switched to accept mail on port 25.
run("full_backup_restore", func(t *testing.T) {
testFullBackupRestore(t, sc)
})
run("enable_port25_features", func(t *testing.T) {
testEnablePort25Features(t, sc)
})
run("dmarc_report_reaches_the_panel", func(t *testing.T) {
testDMARCReportReachesThePanel(t, sc)
})
run("dmarc_junk_is_dropped", func(t *testing.T) {
testDMARCJunkIsDropped(t, sc)
})
run("inbound_relay_delivers_and_refuses", func(t *testing.T) {
testInboundRelay(t, sc)
})
} }
// trimZoneSuffix strips the "e2e.test" suffix a fully-qualified DKIM record // trimZoneSuffix strips the "e2e.test" suffix a fully-qualified DKIM record
+81
View File
@@ -0,0 +1,81 @@
package e2e
import (
"fmt"
"net"
"net/smtp"
"time"
)
// port25Addr is where compose.override.yml publishes port 25: no TLS required,
// no AUTH offered - a stranger's mail server talking to this one.
const port25Addr = "127.0.0.1:20025"
// deliverPort25 is one transaction on port 25 the way another mail server makes
// it: HELO, MAIL, RCPT, DATA with message as it stands. The result says at
// which stage it was refused, like attemptSend's.
func deliverPort25(from, to, message string) sendResult {
var res sendResult
conn, err := net.DialTimeout("tcp", port25Addr, 10*time.Second)
if err != nil {
res.dialErr = fmt.Errorf("dial %s: %w", port25Addr, err)
return res
}
c, err := smtp.NewClient(conn, selfpostHostname)
if err != nil {
res.dialErr = fmt.Errorf("smtp handshake: %w", err)
_ = conn.Close()
return res
}
defer c.Close()
if err := c.Hello("mx.stranger.e2e.test"); err != nil {
res.dialErr = err
return res
}
if err := c.Mail(from); err != nil {
res.mailErr = err
_ = c.Quit()
return res
}
if err := c.Rcpt(to); err != nil {
res.rcptErr = err
_ = c.Quit()
return res
}
w, err := c.Data()
if err != nil {
res.dataErr = err
_ = c.Quit()
return res
}
if _, err := w.Write([]byte(message)); err != nil {
res.dataErr = err
} else if err := w.Close(); err != nil {
res.dataErr = err
}
_ = c.Quit()
return res
}
// waitForPort25Ready polls port 25 until Postfix greets on it. A bare connect
// is not enough: Docker's proxy accepts on the published port whether or not
// anything listens behind it.
func waitForPort25Ready(timeout time.Duration) error {
return waitFor("port 25 to greet", timeout, 500*time.Millisecond, func() (bool, error) {
conn, err := net.DialTimeout("tcp", port25Addr, 2*time.Second)
if err != nil {
return false, err
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 4)
n, err := conn.Read(buf)
if err != nil {
return false, err
}
if string(buf[:n]) != "220 " {
return false, fmt.Errorf("greeting starts with %q", buf[:n])
}
return true, nil
})
}
+1 -1
View File
@@ -34,7 +34,7 @@ type port25Case struct {
// flags=, user= and argv= are arguments of pipe(8); set as -o parameters they // flags=, user= and argv= are arguments of pipe(8); set as -o parameters they
// are dropped, and a bare "pipe" dies on every report with "fatal: missing // are dropped, and a bare "pipe" dies on every report with "fatal: missing
// user= command-line attribute" - the state of 1.7.0 through 1.9.5. // user= command-line attribute" - the state of 1.7.0 through 1.9.5.
const ingestService = "dmarc-ingest unix - n n - - pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}" const ingestService = "dmarc-ingest unix - n n - 1 pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}"
var port25Cases = []port25Case{ var port25Cases = []port25Case{
{ {
+274
View File
@@ -0,0 +1,274 @@
package e2e
import (
"bytes"
"compress/gzip"
"encoding/base64"
"fmt"
"net/url"
"regexp"
"strings"
"testing"
"time"
)
// The stand starts with both port-25 features off, as a new installation does.
// The last part of the run switches them on and uses them from outside, the
// way a stranger's mail server would: until 2.0.1 the suite only read back the
// generated configuration, and DMARC ingest shipped in 1.7.0 with a delivery
// service Postfix could not start - nothing ever sent a report through it.
// testEnablePort25Features recreates the container with the inbound relay and
// DMARC ingest on, over the same /data.
func testEnablePort25Features(t *testing.T, sc *scenario) {
h.env = append(h.env, "INBOUND_RELAY_ENABLE=true", "DMARC_REPORTS_ENABLE=true")
if err := h.recreateSelfpost(); err != nil {
t.Fatalf("recreate selfpost with the port-25 features on: %v", err)
}
if err := waitForPanelReady(); err != nil {
t.Fatalf("panel after enabling the port-25 features: %v\n%s", err, h.logs("selfpost"))
}
if err := waitForSMTPSReady(60 * time.Second); err != nil {
t.Fatal(err)
}
if err := waitForPort25Ready(60 * time.Second); err != nil {
t.Fatalf("%v\n%s", err, h.logs("selfpost"))
}
if err := checkSupervisorProcesses(h); err != nil {
t.Fatal(err)
}
// Nothing in the container's own output may complain about the generated
// configuration: the broken delivery service of 1.7.0-1.9.5 was announced
// there at every start ("unmatched service_name/type") and went unread.
logs := h.logs("selfpost")
for _, bad := range []string{"unmatched service_name", "postconf: warning", "postconf: fatal"} {
if strings.Contains(logs, bad) {
t.Fatalf("the container log has %q with the port-25 features on:\n%s", bad, logs)
}
}
resp, err := sc.panel.status()
if err != nil {
t.Fatal(err)
}
if resp.Request.URL.Path != "/overview" {
t.Fatalf("the session did not survive the recreate: landed on %s", resp.Request.URL.Path)
}
}
const reportReporter = "reporter.e2e.test"
// aggregateXML is a DMARC aggregate report on domain, as a receiver sends it.
func aggregateXML(domain, reportID string) string {
return `<?xml version="1.0"?>
<feedback>
<report_metadata>
<org_name>` + reportReporter + `</org_name>
<email>noreply@` + reportReporter + `</email>
<report_id>` + reportID + `</report_id>
<date_range><begin>1723593600</begin><end>1723680000</end></date_range>
</report_metadata>
<policy_published>
<domain>` + domain + `</domain>
<p>none</p><sp>none</sp><pct>100</pct>
<adkim>r</adkim><aspf>r</aspf>
</policy_published>
<record>
<row>
<source_ip>203.0.113.10</source_ip>
<count>7</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim><spf>pass</spf>
</policy_evaluated>
</row>
<identifiers><header_from>` + domain + `</header_from></identifiers>
</record>
</feedback>
`
}
// reportMessage is the mail a receiver sends a report in: one gzip attachment,
// base64 in 76-column lines.
func reportMessage(t *testing.T, to, xml string) string {
t.Helper()
var gz bytes.Buffer
zw := gzip.NewWriter(&gz)
if _, err := zw.Write([]byte(xml)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
encoded := base64.StdEncoding.EncodeToString(gz.Bytes())
var b strings.Builder
fmt.Fprintf(&b, "From: noreply@%s\r\nTo: %s\r\nSubject: Report domain: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\n",
reportReporter, to, senderDomain, time.Now().UTC().Format(time.RFC1123Z))
b.WriteString("Content-Type: application/gzip; name=\"report.xml.gz\"\r\nContent-Transfer-Encoding: base64\r\n\r\n")
for len(encoded) > 76 {
b.WriteString(encoded[:76] + "\r\n")
encoded = encoded[76:]
}
b.WriteString(encoded + "\r\n")
return b.String()
}
// testDMARCReportReachesThePanel gives the sending domain a SelfPost-hosted
// report address and mails a report to it on port 25. It has to show up under
// Outbound > DMARC reports.
func testDMARCReportReachesThePanel(t *testing.T, sc *scenario) {
address := "dmarc-reports+" + senderDomain + "@" + selfpostHostname
resp, body, err := sc.panel.postForm("/outbound/domains/"+sc.domainID+"/settings/reports", url.Values{"dmarc_rua_mode": {"hosted"}})
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 || resp.Request.URL.Path != "/outbound/domains/"+sc.domainID {
t.Fatalf("choosing the hosted report address: status %d, landed on %s:\n%.800s", resp.StatusCode, resp.Request.URL.Path, body)
}
// An address nobody chose is refused before DATA, hosted form or not.
res := deliverPort25("noreply@"+reportReporter, "dmarc-reports+nobody.e2e.test@"+selfpostHostname, "Subject: x\r\n\r\nx\r\n")
if res.rcptErr == nil {
t.Fatalf("port 25 accepted a report address no domain uses (first error: %v)", res.firstErr())
}
res = deliverPort25("noreply@"+reportReporter, address, reportMessage(t, address, aggregateXML(senderDomain, "e2e-report-1")))
if !res.ok() {
t.Fatalf("port 25 did not accept a report at %s: %v", address, res.firstErr())
}
if err := waitFor("the report to appear under Outbound > DMARC reports", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
resp, body, err := sc.panel.get("/outbound/dmarc")
if err != nil {
return false, err
}
if resp.StatusCode != 200 {
return false, fmt.Errorf("GET /outbound/dmarc = %d", resp.StatusCode)
}
if strings.Contains(body, reportReporter) {
return true, nil
}
return false, fmt.Errorf("no report from %s on the page yet; mail log:\n%s", reportReporter, mailLogTail(h, "dmarc-ingest"))
}); err != nil {
t.Fatal(err)
}
}
// testDMARCJunkIsDropped mails two things that are not reports this relay
// keeps to the same address: a report on somebody else's domain, and a message
// with no report in it. Each is accepted, dropped at once with the reason in
// the mail log, and counted; neither stays in the queue to be tried again and
// neither becomes a report.
func testDMARCJunkIsDropped(t *testing.T, sc *scenario) {
address := "dmarc-reports+" + senderDomain + "@" + selfpostHostname
junk := map[string]string{
"a report on another domain": reportMessage(t, address, aggregateXML("stranger.e2e.test", "e2e-report-2")),
"a message with no report": "From: someone@stranger.e2e.test\r\nTo: " + address + "\r\nSubject: hello\r\n\r\nnot a report\r\n",
}
for what, msg := range junk {
if res := deliverPort25("someone@stranger.e2e.test", address, msg); !res.ok() {
t.Fatalf("%s was refused on port 25 instead of being accepted and dropped: %v", what, res.firstErr())
}
}
dropped := regexp.MustCompile(`status=sent \(delivered via dmarc-ingest service \(dropped: [^)]`)
if err := waitFor("both messages to be dropped", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
log := mailLogTail(h, "dmarc-ingest")
if n := len(dropped.FindAllString(log, -1)); n != len(junk) {
return false, fmt.Errorf("%d of %d dropped so far; mail log:\n%s", n, len(junk), log)
}
return true, nil
}); err != nil {
t.Fatal(err)
}
queue, err := h.execIn("selfpost", "postqueue", "-p")
if err != nil {
t.Fatalf("postqueue -p: %v\n%s", err, queue)
}
if !strings.Contains(queue, "Mail queue is empty") {
t.Fatalf("a dropped message is still in the queue:\n%s", queue)
}
_, body, err := sc.panel.get("/outbound/dmarc")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(body, "2 parse failures") {
t.Fatalf("the DMARC page does not count the two dropped messages as parse failures:\n%s", body)
}
if strings.Contains(body, "stranger.e2e.test") {
t.Fatal("the report on another domain was stored")
}
}
// mailLogTail returns the lines of the end of the mail log that contain filter.
func mailLogTail(s *stack, filter string) string {
out, err := s.execIn("selfpost", "tail", "-n", "400", "/data/log/mail.log")
if err != nil {
return "(mail log unreadable: " + err.Error() + ")"
}
var b strings.Builder
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, filter) {
b.WriteString(line + "\n")
}
}
return b.String()
}
const (
inboundDomain = "in.e2e.test"
inboundRecipient = "user@" + inboundDomain
)
// testInboundRelay sets up an inbound domain with the sink as its upstream and
// one listed recipient, then talks to port 25 as a stranger: mail for the
// listed recipient reaches the upstream, mail for anyone else at the domain
// and for a domain that is not configured is refused before DATA.
func testInboundRelay(t *testing.T, sc *scenario) {
resp, body, err := sc.panel.postForm("/inbound/domains", url.Values{"name": {inboundDomain}})
if err != nil {
t.Fatal(err)
}
m := regexp.MustCompile(`^/inbound/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path)
if m == nil {
t.Fatalf("add inbound domain: landed on %s:\n%.800s", resp.Request.URL.Path, body)
}
base := "/inbound/domains/" + m[1]
// The sink by address: an upstream named by an IP is delivered to as it
// stands, with no MX lookup in between.
resp, body, err = sc.panel.postForm(base+"/upstream", url.Values{"host": {"10.77.0.11"}, "port": {"25"}, "tls_mode": {"none"}})
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 || resp.Request.URL.Path != base {
t.Fatalf("set the upstream: status %d, landed on %s:\n%.800s", resp.StatusCode, resp.Request.URL.Path, body)
}
resp, body, err = sc.panel.postForm(base+"/recipients", url.Values{"recipient_mode": {"list"}, "addresses": {inboundRecipient}})
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 || resp.Request.URL.Path != base {
t.Fatalf("set the recipients: status %d, landed on %s:\n%.800s", resp.StatusCode, resp.Request.URL.Path, body)
}
message := func(to, token string) string {
return fmt.Sprintf("From: someone@stranger.e2e.test\r\nTo: %s\r\nSubject: e2e %s\r\nDate: %s\r\n\r\nmail for the inbound domain\r\n",
to, token, time.Now().UTC().Format(time.RFC1123Z))
}
token := uniqueToken("inbound")
if res := deliverPort25("someone@stranger.e2e.test", inboundRecipient, message(inboundRecipient, token)); !res.ok() {
t.Fatalf("port 25 did not accept mail for %s: %v", inboundRecipient, res.firstErr())
}
if _, err := findSinkMessage(h.stageDir, token, 30*time.Second); err != nil {
t.Fatalf("mail for the inbound domain never reached its upstream: %v\n%s", err, mailLogTail(h, "postfix/"))
}
for _, to := range []string{"nobody@" + inboundDomain, "someone@elsewhere.e2e.test"} {
res := deliverPort25("someone@stranger.e2e.test", to, message(to, uniqueToken("refused")))
if res.rcptErr == nil {
t.Fatalf("port 25 accepted the recipient %s (first error: %v)", to, res.firstErr())
}
}
}
+198
View File
@@ -0,0 +1,198 @@
package e2e
import (
"archive/tar"
"bytes"
"compress/gzip"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// testFullBackupRestore takes a full backup from the panel, throws the
// installation away and brings it back the way the guide tells an operator to
// (§ Full backup and restore): unpack the archive into an empty project
// directory, start the same image. What has to come back is what an operator
// cannot recreate - the administrator, the domain with its DKIM key, the
// application with its password - and the proof is the same one the first
// send gave: a message signed with the restored key verifies against the
// record that was published before the backup.
func testFullBackupRestore(t *testing.T, sc *scenario) {
// A private key the panel may not read - the state a server is in until
// its operator gives the key to group 998. The panel does not make an
// archive without it: it refuses, names the file and says what to do.
keyPath := filepath.Join(h.stageDir, "certs", "privkey.pem")
if err := os.Chmod(keyPath, 0o600); err != nil {
t.Fatal(err)
}
resp, body, err := sc.panel.postForm("/server/backup", url.Values{})
if err != nil {
t.Fatalf("full backup with an unreadable key: %v", err)
}
if resp.StatusCode != 500 || !strings.Contains(body, "not allowed to read certs/privkey.pem") || !strings.Contains(body, "chgrp 998") {
t.Fatalf("full backup with an unreadable key: status %d, want a refusal that names the file and the way out:\n%.800s", resp.StatusCode, body)
}
if err := os.Chmod(keyPath, 0o644); err != nil {
t.Fatal(err)
}
resp, archive, err := sc.panel.postForm("/server/backup", url.Values{})
if err != nil {
t.Fatalf("full backup: %v", err)
}
if ct := resp.Header.Get("Content-Type"); resp.StatusCode != 200 || ct != "application/gzip" {
t.Fatalf("full backup: status %d, Content-Type %q, want an application/gzip download:\n%.600s", resp.StatusCode, ct, archive)
}
// A download that broke off half way is still a 200: the panel streams the
// archive and can only log what went wrong. Read it through once while the
// container that wrote it can still say why.
if _, err := untar(t.TempDir(), []byte(archive)); err != nil {
t.Fatalf("the backup the panel sent (%d bytes) is not a complete archive: %v\n%s", len(archive), err, logTail(h.logs("selfpost"), "backup"))
}
// The installation goes: containers down, then everything the archive is
// supposed to bring back. The fake zone and the sink's directory are the
// outside world and stay.
h.reclaimData()
h.down()
for _, name := range []string{"data", "certs", "docker-compose.yml", ".env"} {
if err := removeAllBestEffort(filepath.Join(h.stageDir, name)); err != nil {
t.Fatalf("remove %s before restore: %v", name, err)
}
}
names, err := untar(h.stageDir, []byte(archive))
if err != nil {
t.Fatalf("unpack the backup into the empty project directory: %v", err)
}
for _, want := range []string{"docker-compose.yml", ".env", "data/manifest.json", "data/selfpost.db", "certs/fullchain.pem", "certs/privkey.pem"} {
if !names[want] {
t.Fatalf("the backup has no %s (it has %d entries)", want, len(names))
}
}
// No backup holds the Postfix queue (guide § Full backup and restore): the
// restored instance has to build an empty one.
for name := range names {
if name == "data/postfix/queue" || strings.HasPrefix(name, "data/postfix/queue/") {
t.Fatalf("the backup holds %s", name)
}
}
if err := h.up(); err != nil {
t.Fatalf("start the restored installation: %v", err)
}
if err := waitForPanelReady(); err != nil {
t.Fatalf("the restored installation did not come up: %v\n%s", err, h.logs("selfpost"))
}
if err := waitForSMTPSReady(60 * time.Second); err != nil {
t.Fatal(err)
}
// The manifest guards the first start only and is removed by it.
if err := waitFor("manifest.json to be removed by the first start", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
out, _ := h.execIn("selfpost", "sh", "-c", "test -e /data/manifest.json && echo present || echo gone")
return strings.TrimSpace(out) == "gone", fmt.Errorf("manifest.json is %s", strings.TrimSpace(out))
}); err != nil {
t.Fatal(err)
}
p, err := newPanelClient()
if err != nil {
t.Fatal(err)
}
if err := p.login(adminUser, adminPassword); err != nil {
t.Fatalf("the administrator cannot sign in after the restore: %v", err)
}
sc.panel = p
_, value, err := p.dkimRecord(sc.domainID)
if err != nil {
t.Fatalf("the domain is not there after the restore: %v", err)
}
if value != sc.dkimValue {
t.Fatalf("the domain came back with another DKIM key:\n before %s\n after %s", sc.dkimValue, value)
}
token := uniqueToken("restored")
res := attemptSend(sendAttempt{
authLogin: sc.appLogin, authPassword: sc.appPassword,
from: "alerts@" + senderDomain, to: recipient,
subject: "e2e " + token, body: "sent by the restored installation",
})
if !res.ok() {
t.Fatalf("the application cannot send after the restore: %v", res.firstErr())
}
raw, err := findSinkMessage(h.stageDir, token, 30*time.Second)
if err != nil {
t.Fatalf("the restored installation did not deliver: %v", err)
}
if err := verifyDKIM(raw, senderDomain); err != nil {
t.Fatalf("mail signed after the restore does not verify against the record published before it: %v", err)
}
}
// untar unpacks a .tar.gz into dir and returns the names it held. Only
// directories and regular files are expected in a backup; anything else, or a
// name that leaves dir, is an error rather than something to skip quietly.
func untar(dir string, archive []byte) (map[string]bool, error) {
zr, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
return nil, err
}
names := map[string]bool{}
tr := tar.NewReader(zr)
for {
hdr, err := tr.Next()
if err == io.EOF {
return names, nil
}
if err != nil {
return nil, err
}
name := filepath.ToSlash(filepath.Clean(hdr.Name))
if name == ".." || strings.HasPrefix(name, "../") || filepath.IsAbs(hdr.Name) {
return nil, fmt.Errorf("entry %q leaves the directory", hdr.Name)
}
target := filepath.Join(dir, filepath.FromSlash(name))
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return nil, err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return nil, err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode().Perm())
if err != nil {
return nil, err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return nil, err
}
if err := f.Close(); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("entry %q has type %q, neither a directory nor a regular file", hdr.Name, hdr.Typeflag)
}
names[name] = true
}
}
// logTail returns the lines of log that contain filter.
func logTail(log, filter string) string {
var b strings.Builder
for _, line := range strings.Split(log, "\n") {
if strings.Contains(line, filter) {
b.WriteString(line + "\n")
}
}
return b.String()
}
+13
View File
@@ -24,6 +24,10 @@ type stack struct {
baseComposeFile string baseComposeFile string
overrideFile string overrideFile string
stageDir string stageDir string
// env is added to compose's own environment. The shipped compose file takes
// the optional features from there (${INBOUND_RELAY_ENABLE:-false} and the
// like), so this is how a later part of the run switches them on.
env []string
} }
func newStack() (*stack, error) { func newStack() (*stack, error) {
@@ -64,6 +68,7 @@ func (s *stack) compose(args ...string) ([]byte, error) {
// looked at (that mapping only reaches the container, not compose's own // looked at (that mapping only reaches the container, not compose's own
// variable interpolation), so it has to be set here too. // variable interpolation), so it has to be set here too.
cmd.Env = append(os.Environ(), "SELFPOST_HOSTNAME="+selfpostHostname) cmd.Env = append(os.Environ(), "SELFPOST_HOSTNAME="+selfpostHostname)
cmd.Env = append(cmd.Env, s.env...)
var out bytes.Buffer var out bytes.Buffer
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &out cmd.Stderr = &out
@@ -102,6 +107,14 @@ func (s *stack) restartSelfpost() error {
return err return err
} }
// recreateSelfpost replaces the selfpost container with one started from the
// same /data and the environment as it is now (see env), leaving the DNS and
// sink sidecars and their state alone.
func (s *stack) recreateSelfpost() error {
_, err := s.compose("up", "-d", "--force-recreate", "--no-deps", "selfpost")
return err
}
func (s *stack) down() { func (s *stack) down() {
_, _ = s.compose("down", "-v", "--remove-orphans") _, _ = s.compose("down", "-v", "--remove-orphans")
} }
+28 -1
View File
@@ -39,6 +39,9 @@ func prepareStage(s *stack) error {
); err != nil { ); err != nil {
return err return err
} }
if err := writeDeployFiles(s); err != nil {
return err
}
corefile, err := os.ReadFile(filepath.Join("dns", "Corefile")) corefile, err := os.ReadFile(filepath.Join("dns", "Corefile"))
if err != nil { if err != nil {
return fmt.Errorf("read Corefile: %w", err) return fmt.Errorf("read Corefile: %w", err)
@@ -49,6 +52,22 @@ func prepareStage(s *stack) error {
return writeZone(s.stageDir, nil) return writeZone(s.stageDir, nil)
} }
// writeDeployFiles puts docker-compose.yml and .env into the stage directory.
// The stage directory is what the container sees as /selfpost-deploy, the
// operator's project directory, and a full backup refuses to run unless both
// files are there to be archived. compose reads neither: the stand names its
// files with -f, and the .env repeats what compose is given anyway.
func writeDeployFiles(s *stack) error {
compose, err := os.ReadFile(s.baseComposeFile)
if err != nil {
return fmt.Errorf("read the shipped compose file: %w", err)
}
if err := os.WriteFile(filepath.Join(s.stageDir, "docker-compose.yml"), compose, 0o644); err != nil {
return err
}
return os.WriteFile(filepath.Join(s.stageDir, ".env"), []byte("SELFPOST_HOSTNAME="+selfpostHostname+"\n"), 0o644)
}
// removeAllBestEffort deletes path; if a previous run left container-UID files // removeAllBestEffort deletes path; if a previous run left container-UID files
// on the bind mount, a root alpine one-shot removes them first. // on the bind mount, a root alpine one-shot removes them first.
func removeAllBestEffort(path string) error { func removeAllBestEffort(path string) error {
@@ -94,5 +113,13 @@ func writeSelfSignedCert(certPath, keyPath string) error {
return err return err
} }
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
return os.WriteFile(keyPath, keyPEM, 0o600) // Readable by everyone, which a real key must never be. On a server the
// operator gives the key to the container's selfpost group (998) so the
// panel can put it into a full backup; the runner of this suite is not
// always root and cannot hand a file to a group it is not in. The key is
// thrown away with the stage directory.
if err := os.WriteFile(keyPath, keyPEM, 0o644); err != nil {
return err
}
return os.Chmod(keyPath, 0o644)
} }