Compare commits
14
Commits
403ce93990
...
d65d632505
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d65d632505 | ||
|
|
dd0fefdcb7 | ||
|
|
75ebbf6a57 | ||
|
|
ede3783734 | ||
|
|
d70371a50c | ||
|
|
21e961cb29 | ||
|
|
82f212ab39 | ||
|
|
b55b33ae92 | ||
|
|
8c9a71e6a0 | ||
|
|
646ec1e149 | ||
|
|
8496b289ec | ||
|
|
4dd46ea355 | ||
|
|
505260e227 | ||
|
|
09d9625f32 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}"
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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+<domain>@mail.example.org</dd></div>
|
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+<domain>@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>
|
||||||
|
|||||||
@@ -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+<domain>@mail.example.org</dd></div>
|
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+<domain>@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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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+<domain>@mail.example.org`, `500 reports or 90 days`,
|
`1 kept · 0 parse failures`, `Hosted addresses`, `dmarc-reports+<domain>@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`)
|
||||||
|
|||||||
@@ -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.")},
|
||||||
|
|||||||
@@ -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+<domain>@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+<domain>@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>`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 ",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user