Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e69d139487 | ||
|
|
3f0d15c9e3 | ||
|
|
bbd2d83364 | ||
|
|
c5588ebba1 | ||
|
|
f6838e4696 | ||
|
|
36db4223f2 | ||
|
|
80748e6c94 | ||
|
|
c5086510f0 | ||
|
|
5b1a868ccf | ||
|
|
c36bd60022 | ||
|
|
baa053d250 | ||
|
|
ce41a8cf34 | ||
|
|
783bca806a | ||
|
|
088c8a5986 |
@@ -209,6 +209,38 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
application forms (the script applied only the first matching rule).
|
application forms (the script applied only the first matching rule).
|
||||||
- The DMARC page of a domain no longer answers 500 once the domain has a
|
- The DMARC page of a domain no longer answers 500 once the domain has a
|
||||||
reported source.
|
reported source.
|
||||||
|
- **The panel can read the mail queue.** The container's entrypoint left the
|
||||||
|
queue's `public/` and `maildrop/` directories in group `postfix` on every
|
||||||
|
start; Postfix keeps them in `postdrop`, the group `postqueue` works
|
||||||
|
through, so the panel's `postqueue -p` was refused ("Connect to the Postfix
|
||||||
|
showq service: Permission denied"), the Queue card said "Could not be read"
|
||||||
|
and the queue page had no listing. Present since the persistent queue of
|
||||||
|
1.3.0; mail flow was not affected. The e2e suite opens the queue page now.
|
||||||
|
- **Port 25 answers again with DMARC report ingest on.** The generated
|
||||||
|
`master.cf` gave the port-25 service `-o smtpd_recipient_restrictions=
|
||||||
|
check_recipient_access texthash:…,…` with a space in the value; Postfix
|
||||||
|
cuts a service line at whitespace, so `smtpd` was started with a stray
|
||||||
|
argument and died with "fatal: unexpected command-line argument" on every
|
||||||
|
connection - no inbound relay and no DMARC reports were accepted while
|
||||||
|
`DMARC_REPORTS_ENABLE=true`, although `postfix check` and the suite were
|
||||||
|
silent. The list is written with commas only, the generator refuses a
|
||||||
|
value with whitespace, and the e2e check asks Postfix what it reads.
|
||||||
|
Present since DMARC ingest was fitted into the 1.9.1 fix of the same
|
||||||
|
stanza.
|
||||||
|
- **DMARC reports are accepted with the inbound relay on.** With both
|
||||||
|
features enabled every report address was refused as "User unknown in
|
||||||
|
relay recipient table": the relay's unlisted-recipient check runs after the
|
||||||
|
restriction list, and the report addresses - on the server's own name, a
|
||||||
|
relay domain - were not in `relay_recipient_maps`. Their table is a
|
||||||
|
recipient table now. Hidden until now by the port-25 failure above.
|
||||||
|
- Overview's Processes card counts the programs that are meant to stay up
|
||||||
|
("6 of 6 running"); the idle one-shot `postfix-reload` was counted as
|
||||||
|
running. The Milter sockets card no longer clips its second line with three
|
||||||
|
sockets, and a DKIM value is shown whole instead of scrolling inside its
|
||||||
|
field.
|
||||||
|
- Server › Health shows the idle reload helper (`postfix-reload`) as
|
||||||
|
**on demand**, with when it last ran, instead of `stopped` beside the
|
||||||
|
running programs (owner, 2026-10-10).
|
||||||
- The confirmation before deleting a user names the domains that follow
|
- The confirmation before deleting a user names the domains that follow
|
||||||
that user's default report address and will be left without one; it used
|
that user's default report address and will be left without one; it used
|
||||||
to say the domains were not affected (replaced before release — see the
|
to say the domains were not affected (replaced before release — see the
|
||||||
|
|||||||
@@ -157,5 +157,16 @@ if [ ! -d /data/postfix/queue/active ]; then
|
|||||||
postfix set-permissions
|
postfix set-permissions
|
||||||
fi
|
fi
|
||||||
chown -R postfix:postfix /data/postfix/queue
|
chown -R postfix:postfix /data/postfix/queue
|
||||||
|
# The recursive chown above would leave public/ and maildrop/ in group postfix.
|
||||||
|
# Postfix keeps those two in its setgid group: postqueue and postdrop are setgid
|
||||||
|
# postdrop and reach the showq socket and the maildrop directory through that
|
||||||
|
# group alone. With the wrong group `postqueue -p` run by the panel answers
|
||||||
|
# "Connect to the Postfix showq service: Permission denied" and the panel cannot
|
||||||
|
# read the queue (the Queue card and the Outbound queue page).
|
||||||
|
chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
|
||||||
|
# It would also leave the queue directory itself and pid/ owned by postfix.
|
||||||
|
# Postfix keeps those two owned by root (master, which runs as root, is the only
|
||||||
|
# writer) and logs "warning: not owned by root" for each at every start.
|
||||||
|
chown root /data/postfix/queue /data/postfix/queue/pid
|
||||||
|
|
||||||
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||||
|
|||||||
+31
-5
@@ -13,12 +13,25 @@
|
|||||||
# a fixed literal or a container environment variable.
|
# a fixed literal or a container environment variable.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
# postconf -P refuses master.cf -o values that contain whitespace; inbound port
|
# Sets one -o option of the smtp/inet (port 25) service by patching its stanza.
|
||||||
# 25 needs restriction lists and milter specs that include spaces. Patch the
|
#
|
||||||
# smtp/inet service stanza directly instead.
|
# A master.cf -o value must not contain whitespace: master splits the line on
|
||||||
|
# it, so "-o name=check_recipient_access texthash:/x,reject" reaches smtpd as
|
||||||
|
# the option "name=check_recipient_access" plus a stray argument, and smtpd dies
|
||||||
|
# at start with "fatal: unexpected command-line argument" - port 25 then
|
||||||
|
# answers nobody. (postconf -P refuses such values for the same reason.) Postfix
|
||||||
|
# splits a restriction list on commas as well as on whitespace, so every value
|
||||||
|
# passed here is written with commas only; a value with whitespace is a bug in
|
||||||
|
# this script and stops the container.
|
||||||
master_cf_set_smtp_inet_o() {
|
master_cf_set_smtp_inet_o() {
|
||||||
param="$1"
|
param="$1"
|
||||||
value="$2"
|
value="$2"
|
||||||
|
case "$value" in
|
||||||
|
*[[:space:]]*)
|
||||||
|
echo "FATAL: master.cf -o ${param} would contain whitespace: ${value}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
awk -v p="$param" -v v="$value" '
|
awk -v p="$param" -v v="$value" '
|
||||||
/^smtp[ \t]+inet[ \t]/ && !done {
|
/^smtp[ \t]+inet[ \t]/ && !done {
|
||||||
print
|
print
|
||||||
@@ -284,15 +297,28 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
|||||||
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||||
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
|
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
|
||||||
fi
|
fi
|
||||||
RECIPIENT_RESTRICTIONS="check_recipient_access texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
|
# Comma, not a space, between check_recipient_access and its table: see
|
||||||
|
# master_cf_set_smtp_inet_o.
|
||||||
|
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
|
||||||
fi
|
fi
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"relay_domains=${RELAY_DOMAINS_SETTING}" \
|
"relay_domains=${RELAY_DOMAINS_SETTING}" \
|
||||||
"transport_maps=${TRANSPORT_SETTING}" \
|
"transport_maps=${TRANSPORT_SETTING}" \
|
||||||
"smtpd_reject_unlisted_recipient=yes"
|
"smtpd_reject_unlisted_recipient=yes"
|
||||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||||
|
# With the relay on, Postfix refuses every recipient of a relay domain
|
||||||
|
# that relay_recipient_maps does not list (smtpd_reject_unlisted_recipient
|
||||||
|
# above) - and it does so after the restriction list, whatever
|
||||||
|
# check_recipient_access answered. The DMARC report addresses live on a
|
||||||
|
# relay domain too (the server's own name), so their table is a recipient
|
||||||
|
# table as well; without it they are "User unknown in relay recipient
|
||||||
|
# table" and no report is ever accepted while both features are on.
|
||||||
|
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
|
||||||
|
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||||
|
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
|
||||||
|
fi
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \
|
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
|
||||||
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
|
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
|
||||||
else
|
else
|
||||||
postconf -e \
|
postconf -e \
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
<div class="box" id="import">
|
<div class="box" id="import">
|
||||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
|
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
|
||||||
<div class="sp-box-body">
|
<div class="sp-box-body">
|
||||||
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html#export">domain's settings</a>.</p>
|
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html">domain's settings</a>.</p>
|
||||||
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
|
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
|
||||||
<div class="field"><label class="label">Domain export file</label>
|
<div class="field"><label class="label">Domain export file</label>
|
||||||
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
|
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
<dl class="sp-facts">
|
<dl class="sp-facts">
|
||||||
<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">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 or 90 days</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div>
|
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div>
|
||||||
<div class="sp-box-body">
|
<div class="sp-box-body">
|
||||||
<p><a href="out-domain-settings.html#export">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p>
|
<p><a href="out-domain-settings.html">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,9 +94,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p class="menu-label">Rarely changed</p>
|
<p class="menu-label">Rarely changed</p>
|
||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
<li><a href="out-domain-settings.html#reports"><i class="ti ti-mail-forward"></i>Report address</a></li>
|
<li><a href="out-domain-settings.html"><i class="ti ti-mail-forward"></i>Report address</a></li>
|
||||||
<li><a href="out-domain-settings.html#limit"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li>
|
<li><a href="out-domain-settings.html"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li>
|
||||||
<li><a href="out-domain-settings.html#export"><i class="ti ti-file-export"></i>Export</a></li>
|
<li><a href="out-domain-settings.html"><i class="ti ti-file-export"></i>Export</a></li>
|
||||||
<li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
|
<li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sp-record">
|
<div class="sp-record">
|
||||||
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html#reports">example.org@dmarc.mail.example.org</a></small></div>
|
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html">example.org@dmarc.mail.example.org</a></small></div>
|
||||||
<div class="sp-row">
|
<div class="sp-row">
|
||||||
<div class="field"><label class="label">Host / name</label>
|
<div class="field"><label class="label">Host / name</label>
|
||||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
<div class="sp-box-body">
|
<div class="sp-box-body">
|
||||||
<dl class="sp-facts">
|
<dl class="sp-facts">
|
||||||
<div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
|
<div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
|
||||||
<div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html#limit">domain</a> and <a href="out-app.html">application</a>.</dd></div>
|
<div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html">domain</a> and <a href="out-app.html">application</a>.</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<div class="box" id="import">
|
<div class="box" id="import">
|
||||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
|
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
|
||||||
<div class="sp-box-body">
|
<div class="sp-box-body">
|
||||||
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html#export">domain's settings</a>.</p>
|
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html">domain's settings</a>.</p>
|
||||||
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
|
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
|
||||||
<div class="field"><label class="label">Domain export file</label>
|
<div class="field"><label class="label">Domain export file</label>
|
||||||
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
|
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<dl class="sp-facts">
|
<dl class="sp-facts">
|
||||||
<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">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 or 90 days</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div>
|
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div>
|
||||||
<div class="sp-box-body">
|
<div class="sp-box-body">
|
||||||
<p><a href="out-domain-settings.html#export">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p>
|
<p><a href="out-domain-settings.html">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,9 +28,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p class="menu-label">Rarely changed</p>
|
<p class="menu-label">Rarely changed</p>
|
||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
<li><a href="out-domain-settings.html#reports"><i class="ti ti-mail-forward"></i>Report address</a></li>
|
<li><a href="out-domain-settings.html"><i class="ti ti-mail-forward"></i>Report address</a></li>
|
||||||
<li><a href="out-domain-settings.html#limit"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li>
|
<li><a href="out-domain-settings.html"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li>
|
||||||
<li><a href="out-domain-settings.html#export"><i class="ti ti-file-export"></i>Export</a></li>
|
<li><a href="out-domain-settings.html"><i class="ti ti-file-export"></i>Export</a></li>
|
||||||
<li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
|
<li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sp-record">
|
<div class="sp-record">
|
||||||
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html#reports">example.org@dmarc.mail.example.org</a></small></div>
|
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html">example.org@dmarc.mail.example.org</a></small></div>
|
||||||
<div class="sp-row">
|
<div class="sp-row">
|
||||||
<div class="field"><label class="label">Host / name</label>
|
<div class="field"><label class="label">Host / name</label>
|
||||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<div class="sp-box-body">
|
<div class="sp-box-body">
|
||||||
<dl class="sp-facts">
|
<dl class="sp-facts">
|
||||||
<div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
|
<div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
|
||||||
<div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html#limit">domain</a> and <a href="out-app.html">application</a>.</dd></div>
|
<div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html">domain</a> and <a href="out-app.html">application</a>.</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ domain-admin access in v1.
|
|||||||
|
|
||||||
Mirror send-log retention:
|
Mirror send-log retention:
|
||||||
|
|
||||||
- Settings key `inbound_spam_log_retention_days` on `/settings` (global admin).
|
- Settings key `inbound_spam_log_retention_days` on `/server/settings` (global admin).
|
||||||
- Default **90** days; validation **7–365**; seed from env
|
- Default **90** days; validation **7–365**; seed from env
|
||||||
`INBOUND_SPAM_LOG_RETENTION_DAYS` when the setting is missing.
|
`INBOUND_SPAM_LOG_RETENTION_DAYS` when the setting is missing.
|
||||||
- Prune every **6 hours** (same cadence as send-log retention).
|
- Prune every **6 hours** (same cadence as send-log retention).
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
A dedicated `/preflight` page (global-admin only) that runs infrastructure-level
|
A dedicated `/server/preflight` page (global-admin only) that runs infrastructure-level
|
||||||
checks and helps the administrator confirm the instance is fully configured and
|
checks and helps the administrator confirm the instance is fully configured and
|
||||||
ready to deliver mail. Each failed check shows an actionable recommendation
|
ready to deliver mail. Each failed check shows an actionable recommendation
|
||||||
(what to do or where to look).
|
(what to do or where to look).
|
||||||
@@ -17,7 +17,7 @@ ready to deliver mail. Each failed check shows an actionable recommendation
|
|||||||
|
|
||||||
[preflight](../assets/panel-redesign/panel/preflight.html), under the design
|
[preflight](../assets/panel-redesign/panel/preflight.html), under the design
|
||||||
contract of [panel-redesign.md](panel-redesign.md): **Server › Preflight**,
|
contract of [panel-redesign.md](panel-redesign.md): **Server › Preflight**,
|
||||||
path `/server/preflight` once the routes are renamed.
|
path `/server/preflight`.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ path `/server/preflight` once the routes are renamed.
|
|||||||
|
|
||||||
## Done when
|
## Done when
|
||||||
|
|
||||||
An administrator can open `/preflight`, see a traffic-light summary of all
|
An administrator can open `/server/preflight`, see a traffic-light summary of all
|
||||||
infrastructure prerequisites, read specific fix instructions for any failures,
|
infrastructure prerequisites, read specific fix instructions for any failures,
|
||||||
and send a test email to verify end-to-end flow.
|
and send a test email to verify end-to-end flow.
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ status is **agreed**. See [development.md](../development.md) § Plan checklists
|
|||||||
— **Sonnet**
|
— **Sonnet**
|
||||||
- [ ] Handler `handlers_preflight.go` (assemble all checks, actionable
|
- [ ] Handler `handlers_preflight.go` (assemble all checks, actionable
|
||||||
recommendations) — **Sonnet**
|
recommendations) — **Sonnet**
|
||||||
- [ ] Test-email handler (`POST /preflight/test-email`, `net/smtp` to
|
- [ ] Test-email handler (`POST /server/preflight/test-email`, `net/smtp` to
|
||||||
localhost:25) — **Sonnet**
|
localhost:25) — **Sonnet**
|
||||||
- [ ] Template `preflight.html` (traffic-light cards + test-email form) —
|
- [ ] Template `preflight.html` (traffic-light cards + test-email form) —
|
||||||
**Sonnet**
|
**Sonnet**
|
||||||
|
|||||||
+1
-1
@@ -279,7 +279,7 @@ is in the table below; the rest of it shipped in
|
|||||||
| Edge case | [dnscheck/resolver.go](../internal/dnscheck/resolver.go) fans out four parallel queries where a fallback chain would do — change it or write down why not |
|
| Edge case | [dnscheck/resolver.go](../internal/dnscheck/resolver.go) fans out four parallel queries where a fallback chain would do — change it or write down why not |
|
||||||
| UI | ~~`.pair` (DMARC templates) and `.split` (every other page) are two names for one layout~~ — struck: retired by [panel-redesign](#panel-redesign); both stylesheets are gone and every page is laid out by the component kit |
|
| UI | ~~`.pair` (DMARC templates) and `.split` (every other page) are two names for one layout~~ — struck: retired by [panel-redesign](#panel-redesign); both stylesheets are gone and every page is laid out by the component kit |
|
||||||
| UI | ~~Two `back_link` elements in a row in `dmarc_domain.html`~~ — struck: retired by [panel-redesign](#panel-redesign); the page is rebuilt on the kit with one breadcrumb in its head |
|
| UI | ~~Two `back_link` elements in a row in `dmarc_domain.html`~~ — struck: retired by [panel-redesign](#panel-redesign); the page is rebuilt on the kit with one breadcrumb in its head |
|
||||||
| UI | `initShowWhen` is not re-run after an htmx swap — safe today (no form arrives that way), fragile if one ever does |
|
| UI | ~~`initShowWhen` is not re-run after an htmx swap~~ — struck: the show/hide rules listen on the document and are applied again on `htmx:load` |
|
||||||
|
|
||||||
**Boundary:** no feature work and no behaviour change an operator would notice,
|
**Boundary:** no feature work and no behaviour change an operator would notice,
|
||||||
apart from the backup re-auth prompt. Anything here that grows past an hour
|
apart from the backup re-auth prompt. Anything here that grows past an hour
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ type Process struct {
|
|||||||
State string // supervisord's own state word, e.g. RUNNING
|
State string // supervisord's own state word, e.g. RUNNING
|
||||||
Detail string // the rest of the line: pid/uptime, or exit information
|
Detail string // the rest of the line: pid/uptime, or exit information
|
||||||
Status Status
|
Status Status
|
||||||
|
// OneShot marks a program that is started on demand and exits at once
|
||||||
|
// (oneShotPrograms): idle is its healthy state, and it is not one of the
|
||||||
|
// programs that are expected to be running.
|
||||||
|
OneShot bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Processes returns the state of every supervised program (architecture.md §
|
// Processes returns the state of every supervised program (architecture.md §
|
||||||
@@ -71,10 +75,11 @@ func parseProcesses(out string) []Process {
|
|||||||
}
|
}
|
||||||
name, state := fields[0], fields[1]
|
name, state := fields[0], fields[1]
|
||||||
procs = append(procs, Process{
|
procs = append(procs, Process{
|
||||||
Name: name,
|
Name: name,
|
||||||
State: state,
|
State: state,
|
||||||
Detail: strings.Join(fields[2:], " "),
|
Detail: strings.Join(fields[2:], " "),
|
||||||
Status: processStatus(name, state),
|
Status: processStatus(name, state),
|
||||||
|
OneShot: oneShotPrograms[name],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return procs
|
return procs
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func TestNewDomainHasNoReports(t *testing.T) {
|
|||||||
|
|
||||||
// The domain the stand started with has none either.
|
// The domain the stand started with has none either.
|
||||||
has(t, "domain page", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"),
|
has(t, "domain page", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"),
|
||||||
`Report address: <a href="/outbound/domains/1/settings#reports">no reports</a>`)
|
`Report address: <a href="/outbound/domains/1/settings">no reports</a>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each of the three choices is saved, comes back selected, and is what the
|
// Each of the three choices is saved, comes back selected, and is what the
|
||||||
@@ -130,7 +130,7 @@ func TestDomainReportAddressThreeChoices(t *testing.T) {
|
|||||||
has(t, "settings after hosted", settings(), `<option value="hosted" selected>`)
|
has(t, "settings after hosted", settings(), `<option value="hosted" selected>`)
|
||||||
lacks(t, "settings after hosted", settings(), `value="`+hostedForExample+`"`) // the typed field stays empty
|
lacks(t, "settings after hosted", settings(), `value="`+hostedForExample+`"`) // the typed field stays empty
|
||||||
has(t, "domain page after hosted", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"),
|
has(t, "domain page after hosted", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"),
|
||||||
`Report address: <a href="/outbound/domains/1/settings#reports">`+hostedEscaped+`</a>`,
|
`Report address: <a href="/outbound/domains/1/settings">`+hostedEscaped+`</a>`,
|
||||||
`rua=mailto:`+hostedEscaped)
|
`rua=mailto:`+hostedEscaped)
|
||||||
// The ingest allow-list was rebuilt for the new address.
|
// The ingest allow-list was rebuilt for the new address.
|
||||||
if last := maps.got[len(maps.got)-1]; !contains(last.Recipients, hostedForExample) {
|
if last := maps.got[len(maps.got)-1]; !contains(last.Recipients, hostedForExample) {
|
||||||
@@ -145,7 +145,7 @@ func TestDomainReportAddressThreeChoices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
has(t, "settings after custom", settings(), `<option value="custom" selected>`, `value="reports@hub.example"`)
|
has(t, "settings after custom", settings(), `<option value="custom" selected>`, `value="reports@hub.example"`)
|
||||||
has(t, "domain page after custom", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"),
|
has(t, "domain page after custom", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"),
|
||||||
`Report address: <a href="/outbound/domains/1/settings#reports">reports@hub.example</a>`, `rua=mailto:reports@hub.example`,
|
`Report address: <a href="/outbound/domains/1/settings">reports@hub.example</a>`, `rua=mailto:reports@hub.example`,
|
||||||
`<h3>Report authorization</h3>`) // the address is on another domain than the senders
|
`<h3>Report authorization</h3>`) // the address is on another domain than the senders
|
||||||
if last := maps.got[len(maps.got)-1]; contains(last.Recipients, hostedForExample) || contains(last.Recipients, "reports@hub.example") {
|
if last := maps.got[len(maps.got)-1]; contains(last.Recipients, hostedForExample) || contains(last.Recipients, "reports@hub.example") {
|
||||||
t.Errorf("the allow-list after choosing another host: %v", last.Recipients)
|
t.Errorf("the allow-list after choosing another host: %v", last.Recipients)
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
card := socketCard(t, h)
|
card := socketCard(t, h)
|
||||||
if card.Value != "3 of 3 answering" || card.Sub != "OpenDKIM, send-log, Spam filter" || card.Level != "" {
|
if card.Value != "3 of 3 answering" || card.Sub != "incl. the spam filter" || card.Level != "" {
|
||||||
t.Errorf("card = %+v", card)
|
t.Errorf("card = %+v", card)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func TestDomainPageShowsRecordsApplicationsAndConnection(t *testing.T) {
|
|||||||
`<td class="sp-mono sp-nowrap"><strong>prod-server</strong></td>`, `alerts@<wbr>example.org`, `200 / h`, `2 IPs`,
|
`<td class="sp-mono sp-nowrap"><strong>prod-server</strong></td>`, `alerts@<wbr>example.org`, `200 / h`, `2 IPs`,
|
||||||
`href="/outbound/domains/1/applications/`+idStr(a.ID)+`">Edit</a>`,
|
`href="/outbound/domains/1/applications/`+idStr(a.ID)+`">Edit</a>`,
|
||||||
`mail.example.org`, `STARTTLS (submission)`, `action="/outbound/domains/1/dns-recheck"`,
|
`mail.example.org`, `STARTTLS (submission)`, `action="/outbound/domains/1/dns-recheck"`,
|
||||||
`href="/outbound/log?domain=example.org"`, `href="/outbound/domains/1/settings#reports"`, `href="/outbound/domains/1/delete"`)
|
`href="/outbound/log?domain=example.org"`, `href="/outbound/domains/1/settings"`, `href="/outbound/domains/1/delete"`)
|
||||||
|
|
||||||
// The flash the redirects carry.
|
// The flash the redirects carry.
|
||||||
rec := send(s.h.HandleDomainDetail, &globalPrincipal, "GET", "/outbound/domains/1?appsaved=1", s.paths, nil)
|
rec := send(s.h.HandleDomainDetail, &globalPrincipal, "GET", "/outbound/domains/1?appsaved=1", s.paths, nil)
|
||||||
@@ -339,7 +339,7 @@ func TestDeleteConfirmNamesTheApplicationsThatGoWithTheDomain(t *testing.T) {
|
|||||||
t.Fatalf("delete confirmation = %d:\n%s", rec.Code, body)
|
t.Fatalf("delete confirmation = %d:\n%s", rec.Code, body)
|
||||||
}
|
}
|
||||||
has(t, "delete", body, `<h1 class="title is-3">Delete example.org</h1>`, `permanently delete its DKIM signing key;`,
|
has(t, "delete", body, `<h1 class="title is-3">Delete example.org</h1>`, `permanently delete its DKIM signing key;`,
|
||||||
`<form method="post" action="/outbound/domains/1/delete">`, `Delete example.org</button>`, `href="/outbound/domains/1/settings#export"`)
|
`<form method="post" action="/outbound/domains/1/delete">`, `Delete example.org</button>`, `href="/outbound/domains/1/settings"`)
|
||||||
lacks(t, "delete", body, `credentials`)
|
lacks(t, "delete", body, `credentials`)
|
||||||
|
|
||||||
for _, login := range []string{"prod-server", "alerts"} {
|
for _, login := range []string{"prod-server", "alerts"} {
|
||||||
|
|||||||
@@ -150,9 +150,7 @@ func healthPage(m view.Meta, c map[string]any, flash string) *view.Health {
|
|||||||
page.Processes.End = view.Verdict(string(c["ProcessStatus"].(health.Status)))
|
page.Processes.End = view.Verdict(string(c["ProcessStatus"].(health.Status)))
|
||||||
page.ProcessError = c["ProcessError"].(bool)
|
page.ProcessError = c["ProcessError"].(bool)
|
||||||
for _, p := range c["Processes"].([]health.Process) {
|
for _, p := range c["Processes"].([]health.Process) {
|
||||||
page.ProcessRows = append(page.ProcessRows, view.ProcessRow{
|
page.ProcessRows = append(page.ProcessRows, processRow(p))
|
||||||
Name: p.Name, State: view.Tag{Status: string(p.Status), Label: strings.ToLower(p.State)}, Detail: p.Detail,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cert := c["Cert"].(health.Certificate)
|
cert := c["Cert"].(health.Certificate)
|
||||||
@@ -194,6 +192,24 @@ func healthPage(m view.Meta, c map[string]any, flash string) *view.Health {
|
|||||||
return page
|
return page
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processRow is one line of the Processes table. A one-shot helper that sits
|
||||||
|
// idle is not "stopped" the way a daemon is: it runs when asked and exits, so
|
||||||
|
// its row says "on demand" in a neutral tag and when it last ran, instead of a
|
||||||
|
// word that reads as a fault beside the running programs.
|
||||||
|
func processRow(p health.Process) view.ProcessRow {
|
||||||
|
if p.OneShot && p.Status == health.StatusOK && p.State != "RUNNING" {
|
||||||
|
detail := "Runs when the configuration is reloaded"
|
||||||
|
switch {
|
||||||
|
case p.Detail == "" || strings.EqualFold(p.Detail, "Not started"):
|
||||||
|
detail += "; not run since the container started"
|
||||||
|
default:
|
||||||
|
detail += "; last run: " + p.Detail
|
||||||
|
}
|
||||||
|
return view.ProcessRow{Name: p.Name, State: view.Tag{Status: "off", Label: "on demand"}, Detail: detail}
|
||||||
|
}
|
||||||
|
return view.ProcessRow{Name: p.Name, State: view.Tag{Status: string(p.Status), Label: strings.ToLower(p.State)}, Detail: p.Detail}
|
||||||
|
}
|
||||||
|
|
||||||
// machineRows is the Machine table: processor and memory as bars with their
|
// machineRows is the Machine table: processor and memory as bars with their
|
||||||
// figure, the network as rates, and for each the sentence the sampler gave. A
|
// figure, the network as rates, and for each the sentence the sampler gave. A
|
||||||
// reading that could not be taken keeps its row, without a figure.
|
// reading that could not be taken keeps its row, without a figure.
|
||||||
@@ -245,15 +261,22 @@ func healthCards(c map[string]any) []view.HealthCard {
|
|||||||
ram = "RAM " + machine.Memory.PctText()
|
ram = "RAM " + machine.Memory.PctText()
|
||||||
}
|
}
|
||||||
|
|
||||||
running, notRunning := 0, []string{}
|
// The count is of the programs that are meant to stay up. A one-shot helper
|
||||||
|
// (postfix-reload) idles between runs: it is not "running" and not missing,
|
||||||
|
// so it is left out of both numbers and named only when it is in trouble.
|
||||||
|
running, expected, notRunning := 0, 0, []string{}
|
||||||
for _, p := range procs {
|
for _, p := range procs {
|
||||||
if p.Status == health.StatusOK {
|
if !p.OneShot {
|
||||||
running++
|
expected++
|
||||||
} else {
|
if p.Status == health.StatusOK {
|
||||||
|
running++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Status != health.StatusOK {
|
||||||
notRunning = append(notRunning, p.Name)
|
notRunning = append(notRunning, p.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
procValue, procSub := fmt.Sprintf("%d of %d running", running, len(procs)), "all programs"
|
procValue, procSub := fmt.Sprintf("%d of %d running", running, expected), "all programs"
|
||||||
if len(notRunning) > 0 {
|
if len(notRunning) > 0 {
|
||||||
procSub = strings.Join(notRunning, ", ")
|
procSub = strings.Join(notRunning, ", ")
|
||||||
}
|
}
|
||||||
@@ -271,13 +294,28 @@ func healthCards(c map[string]any) []view.HealthCard {
|
|||||||
queueValue = "Could not be read"
|
queueValue = "Could not be read"
|
||||||
}
|
}
|
||||||
|
|
||||||
answering, socketNames := 0, make([]string, 0, len(sockets))
|
// The line under the count has to fit a card: the sockets that do not
|
||||||
|
// answer when some do not; otherwise their names, or - with the inbound spam
|
||||||
|
// filter among them, when three names no longer fit - that it is included.
|
||||||
|
answering, socketNames, silent, withFilter := 0, make([]string, 0, len(sockets)), []string{}, false
|
||||||
for _, s := range sockets {
|
for _, s := range sockets {
|
||||||
if s.Present {
|
if s.Present {
|
||||||
answering++
|
answering++
|
||||||
|
} else {
|
||||||
|
silent = append(silent, s.Name)
|
||||||
|
}
|
||||||
|
if s.Note != "" {
|
||||||
|
withFilter = true
|
||||||
}
|
}
|
||||||
socketNames = append(socketNames, s.Name)
|
socketNames = append(socketNames, s.Name)
|
||||||
}
|
}
|
||||||
|
socketSub := strings.Join(socketNames, ", ")
|
||||||
|
switch {
|
||||||
|
case len(silent) > 0:
|
||||||
|
socketSub = strings.Join(silent, ", ")
|
||||||
|
case withFilter:
|
||||||
|
socketSub = "incl. the spam filter"
|
||||||
|
}
|
||||||
|
|
||||||
ptrValue := "Forward = reverse"
|
ptrValue := "Forward = reverse"
|
||||||
if ptr.Status != health.StatusOK {
|
if ptr.Status != health.StatusOK {
|
||||||
@@ -293,7 +331,7 @@ func healthCards(c map[string]any) []view.HealthCard {
|
|||||||
card("TLS certificate", "ti-certificate", "/server/health#certificate", cert.Status, certValue, cert.Subject),
|
card("TLS certificate", "ti-certificate", "/server/health#certificate", cert.Status, certValue, cert.Subject),
|
||||||
card("Queue", "ti-stack-2", "/outbound/queue", c["QueueStatus"].(health.Status), queueValue, "outbound"),
|
card("Queue", "ti-stack-2", "/outbound/queue", c["QueueStatus"].(health.Status), queueValue, "outbound"),
|
||||||
card("Milter sockets", "ti-plug-connected", "/server/health#sockets", c["SocketStatus"].(health.Status),
|
card("Milter sockets", "ti-plug-connected", "/server/health#sockets", c["SocketStatus"].(health.Status),
|
||||||
fmt.Sprintf("%d of %d answering", answering, len(sockets)), strings.Join(socketNames, ", ")),
|
fmt.Sprintf("%d of %d answering", answering, len(sockets)), socketSub),
|
||||||
card("Reverse DNS", "ti-arrows-exchange", "/server/health#hostname", ptr.Status, ptrValue, c["Hostname"].(string)),
|
card("Reverse DNS", "ti-arrows-exchange", "/server/health#hostname", ptr.Status, ptrValue, c["Hostname"].(string)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
@@ -41,7 +42,7 @@ func TestHealthCards(t *testing.T) {
|
|||||||
{"Processes", "fail", "1 of 2 running", "postfix", "/server/health#processes"},
|
{"Processes", "fail", "1 of 2 running", "postfix", "/server/health#processes"},
|
||||||
{"TLS certificate", "warn", "Expires in 12 days", "mail.example.org", "/server/health#certificate"},
|
{"TLS certificate", "warn", "Expires in 12 days", "mail.example.org", "/server/health#certificate"},
|
||||||
{"Queue", "", "Empty", "outbound", "/outbound/queue"},
|
{"Queue", "", "Empty", "outbound", "/outbound/queue"},
|
||||||
{"Milter sockets", "warn", "1 of 2 answering", "OpenDKIM, send-log", "/server/health#sockets"},
|
{"Milter sockets", "warn", "1 of 2 answering", "send-log", "/server/health#sockets"},
|
||||||
{"Reverse DNS", "", "Forward = reverse", "mail.example.org", "/server/health#hostname"},
|
{"Reverse DNS", "", "Forward = reverse", "mail.example.org", "/server/health#hostname"},
|
||||||
}
|
}
|
||||||
if len(cards) != len(want) {
|
if len(cards) != len(want) {
|
||||||
@@ -80,3 +81,49 @@ func TestQueueCardValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Processes card counts the programs that are meant to stay up. The one-shot
|
||||||
|
// reload helper idles between runs: it is neither running nor missing.
|
||||||
|
func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) {
|
||||||
|
c := checksFixture()
|
||||||
|
c["Processes"] = []health.Process{
|
||||||
|
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
|
||||||
|
{Name: "postfix", State: "RUNNING", Status: health.StatusOK},
|
||||||
|
{Name: "postfix-reload", State: "STOPPED", Status: health.StatusOK, OneShot: true},
|
||||||
|
}
|
||||||
|
c["ProcessStatus"] = health.StatusOK
|
||||||
|
card := healthCards(c)[1]
|
||||||
|
if card.Value != "2 of 2 running" || card.Sub != "all programs" {
|
||||||
|
t.Errorf("card = %q / %q, want 2 of 2 running / all programs", card.Value, card.Sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
c["Processes"] = []health.Process{
|
||||||
|
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
|
||||||
|
{Name: "postfix-reload", State: "FATAL", Status: health.StatusError, OneShot: true},
|
||||||
|
}
|
||||||
|
card = healthCards(c)[1]
|
||||||
|
if card.Value != "1 of 1 running" || card.Sub != "postfix-reload" {
|
||||||
|
t.Errorf("card = %q / %q, want 1 of 1 running / postfix-reload", card.Value, card.Sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An idle one-shot helper is "on demand", not "stopped"; a daemon keeps its
|
||||||
|
// state word, and a one-shot in trouble keeps its own.
|
||||||
|
func TestProcessRowOfAnIdleOneShotSaysOnDemand(t *testing.T) {
|
||||||
|
idle := processRow(health.Process{Name: "postfix-reload", State: "STOPPED", Detail: "Not started", Status: health.StatusOK, OneShot: true})
|
||||||
|
if idle.State.Label != "on demand" || idle.State.Status != "off" || !strings.Contains(idle.Detail, "not run since the container started") {
|
||||||
|
t.Errorf("idle one-shot = %+v", idle)
|
||||||
|
}
|
||||||
|
ran := processRow(health.Process{Name: "postfix-reload", State: "EXITED", Detail: "Oct 10 08:42 PM", Status: health.StatusOK, OneShot: true})
|
||||||
|
if ran.State.Label != "on demand" || !strings.HasSuffix(ran.Detail, "last run: Oct 10 08:42 PM") {
|
||||||
|
t.Errorf("one-shot that ran = %+v", ran)
|
||||||
|
}
|
||||||
|
bad := processRow(health.Process{Name: "postfix-reload", State: "FATAL", Detail: "Exited too quickly", Status: health.StatusError, OneShot: true})
|
||||||
|
if bad.State.Label != "fatal" || bad.State.Status != "error" || bad.Detail != "Exited too quickly" {
|
||||||
|
t.Errorf("one-shot in trouble = %+v", bad)
|
||||||
|
}
|
||||||
|
daemon := processRow(health.Process{Name: "postfix", State: "STOPPED", Detail: "Oct 10 08:42 PM", Status: health.StatusError})
|
||||||
|
if daemon.State.Label != "stopped" || daemon.State.Status != "error" {
|
||||||
|
t.Errorf("stopped daemon = %+v", daemon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
|
|||||||
}
|
}
|
||||||
last := Fact{Label: "Last report", Value: Plain("None yet")}
|
last := Fact{Label: "Last report", Value: Plain("None yet")}
|
||||||
if in.Last.IsZero() {
|
if in.Last.IsZero() {
|
||||||
last.Note = Rich("Point ", Code("rua="), " at the hosted address below and publish MX for ", Mono(in.Host), " on this server.")
|
last.Note = Rich("Set a domain to SelfPost hosted in its Domain settings, publish the DMARC record it then shows, and publish MX for ", Mono(in.Host), " pointing at this server.")
|
||||||
} else {
|
} else {
|
||||||
last.Value = Plain(in.Last.UTC().Format("2006-01-02 15:04") + " UTC")
|
last.Value = Plain(in.Last.UTC().Format("2006-01-02 15:04") + " UTC")
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
|
|||||||
IngestFacts: []Fact{
|
IngestFacts: []Fact{
|
||||||
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},
|
{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 or " + plural(int64(in.RetentionDays), "day", "days"))},
|
||||||
},
|
},
|
||||||
Recent: Box{No: "02", Title: "Recent reports"},
|
Recent: Box{No: "02", Title: "Recent reports"},
|
||||||
|
|||||||
@@ -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">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 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>`,
|
||||||
)
|
)
|
||||||
@@ -24,7 +24,7 @@ func TestDMARCHubWithoutReports(t *testing.T) {
|
|||||||
p := NewDMARCHub(admin(), IngestInput{Hosted: "dmarc@mail.example.org", Host: "mail.example.org", RetentionMax: 500, RetentionDays: 90})
|
p := NewDMARCHub(admin(), IngestInput{Hosted: "dmarc@mail.example.org", Host: "mail.example.org", RetentionMax: 500, RetentionDays: 90})
|
||||||
out := renderSignedIn(t, "dmarc", p.WithReports(nil))
|
out := renderSignedIn(t, "dmarc", p.WithReports(nil))
|
||||||
pageHas(t, "DMARC reports", out, `<span class="tag is-warning is-light">quiet</span>`, `<dd>None yet</dd>`,
|
pageHas(t, "DMARC reports", out, `<span class="tag is-warning is-light">quiet</span>`, `<dd>None yet</dd>`,
|
||||||
`publish MX for <span class="sp-mono">mail.example.org</span> on this server`, `No reports yet.`, `0 kept · 0 parse failures`)
|
`publish MX for <span class="sp-mono">mail.example.org</span> pointing at this server`, `No reports yet.`, `0 kept · 0 parse failures`)
|
||||||
pageLacks(t, "DMARC reports", out, `<table`, `UTC</dd>`)
|
pageLacks(t, "DMARC reports", out, `<table`, `UTC</dd>`)
|
||||||
|
|
||||||
one := NewDMARCHub(admin(), IngestInput{OK: true, Last: time.Now(), KeptThisWeek: 1, ParseFailures: 1})
|
one := NewDMARCHub(admin(), IngestInput{OK: true, Last: time.Now(), KeptThisWeek: 1, ParseFailures: 1})
|
||||||
|
|||||||
@@ -145,11 +145,26 @@ func applyCheck(r *Record, c DNSCheck, standing Text, mismatch Text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dkimRows is the height of the field that shows a DKIM value: enough lines for
|
||||||
|
// the whole key, so the field does not scroll inside itself. A 2048-bit key is
|
||||||
|
// about 410 characters; a line of the field holds some 80 of them at the
|
||||||
|
// narrowest width the page is drawn for.
|
||||||
|
func dkimRows(value string) int {
|
||||||
|
rows := (len(value)+79)/80 + 1
|
||||||
|
if rows < 3 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
if rows > 12 {
|
||||||
|
return 12
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
// DKIMRecord is the DKIM record to publish: the key is not a secret, and the
|
// DKIMRecord is the DKIM record to publish: the key is not a secret, and the
|
||||||
// selector says which one it is.
|
// selector says which one it is.
|
||||||
func DKIMRecord(selector, host, value string, c DNSCheck) Record {
|
func DKIMRecord(selector, host, value string, c DNSCheck) Record {
|
||||||
r := Record{Name: "DKIM", Note: Rich("Not a secret · selector ", Code(selector)),
|
r := Record{Name: "DKIM", Note: Rich("Not a secret · selector ", Code(selector)),
|
||||||
Host: host, Type: "TXT", Value: value, ValueRows: 3}
|
Host: host, Type: "TXT", Value: value, ValueRows: dkimRows(value)}
|
||||||
applyCheck(&r, c, nil, nil)
|
applyCheck(&r, c, nil, nil)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -181,7 +196,7 @@ type DMARCRecordInput struct {
|
|||||||
// delivery, so the standing note says how to tighten it.
|
// delivery, so the standing note says how to tighten it.
|
||||||
func DMARCRecord(in DMARCRecordInput) Record {
|
func DMARCRecord(in DMARCRecordInput) Record {
|
||||||
r := Record{Name: "DMARC", Host: in.Host, Type: "TXT", Value: in.Value,
|
r := Record{Name: "DMARC", Host: in.Host, Type: "TXT", Value: in.Value,
|
||||||
Note: Rich("Report address: ", Link(in.SettingsHref+"#reports", in.Source))}
|
Note: Rich("Report address: ", Link(in.SettingsHref, in.Source))}
|
||||||
reports := Rich("reports")
|
reports := Rich("reports")
|
||||||
if in.ReportsHref != "" {
|
if in.ReportsHref != "" {
|
||||||
reports = Rich(Link(in.ReportsHref, "reports"))
|
reports = Rich(Link(in.ReportsHref, "reports"))
|
||||||
@@ -482,9 +497,9 @@ func (p *OutDomain) Menu() SideMenu {
|
|||||||
}
|
}
|
||||||
settings := DomainHref(p.id) + "/settings"
|
settings := DomainHref(p.id) + "/settings"
|
||||||
rare := MenuGroup{Label: "Rarely changed", Items: []MenuItem{
|
rare := MenuGroup{Label: "Rarely changed", Items: []MenuItem{
|
||||||
{Label: "Report address", Icon: "ti-mail-forward", Href: settings + "#reports"},
|
{Label: "Report address", Icon: "ti-mail-forward", Href: settings},
|
||||||
{Label: "Rate limit", Icon: "ti-gauge", Href: settings + "#limit", Tag: p.limitTag},
|
{Label: "Rate limit", Icon: "ti-gauge", Href: settings, Tag: p.limitTag},
|
||||||
{Label: "Export", Icon: "ti-file-export", Href: settings + "#export"},
|
{Label: "Export", Icon: "ti-file-export", Href: settings},
|
||||||
}}
|
}}
|
||||||
if p.canDelete {
|
if p.canDelete {
|
||||||
rare.Items = append(rare.Items, MenuItem{Label: "Delete domain", Icon: "ti-trash", Href: DomainHref(p.id) + "/delete", Danger: true})
|
rare.Items = append(rare.Items, MenuItem{Label: "Delete domain", Icon: "ti-trash", Href: DomainHref(p.id) + "/delete", Danger: true})
|
||||||
@@ -733,7 +748,7 @@ func NewOutDomainDelete(m Meta, id int64, name string, logins []string) *OutDoma
|
|||||||
Confirm: "Delete " + name,
|
Confirm: "Delete " + name,
|
||||||
KeepHref: href,
|
KeepHref: href,
|
||||||
Moving: Box{Icon: "ti-file-export", Title: "Moving it instead?"},
|
Moving: Box{Icon: "ti-file-export", Title: "Moving it instead?"},
|
||||||
MovingText: Rich(Link(href+"/settings#export", "Export the domain"),
|
MovingText: Rich(Link(href+"/settings", "Export the domain"),
|
||||||
" first. The file carries the key and the application passwords, so the new instance needs no DNS change."),
|
" first. The file carries the key and the application passwords, so the new instance needs no DNS change."),
|
||||||
}
|
}
|
||||||
if n := appsPhrase(len(logins)); n != "" {
|
if n := appsPhrase(len(logins)); n != "" {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func TestOutDomainRecordsSayWhatDNSHolds(t *testing.T) {
|
|||||||
`sp2026._domainkey.example.org`, `<textarea class="textarea" rows="3" readonly>`,
|
`sp2026._domainkey.example.org`, `<textarea class="textarea" rows="3" readonly>`,
|
||||||
`<h3>SPF</h3>`, `<span class="tag is-danger is-light">mismatch</span>`, `v=spf1 ip4:203.0.113.25 ~all`,
|
`<h3>SPF</h3>`, `<span class="tag is-danger is-light">mismatch</span>`, `v=spf1 ip4:203.0.113.25 ~all`,
|
||||||
`In DNS now`, `v=spf1 include:_spf.google.com ~all`, `Shallow check: <code>include:</code> is not followed.`,
|
`In DNS now`, `v=spf1 include:_spf.google.com ~all`, `Shallow check: <code>include:</code> is not followed.`,
|
||||||
`<h3>DMARC</h3>`, `Report address: <a href="/outbound/domains/1/settings#reports">example.org@dmarc.mail.example.org</a>`,
|
`<h3>DMARC</h3>`, `Report address: <a href="/outbound/domains/1/settings">example.org@dmarc.mail.example.org</a>`,
|
||||||
`<code>p=none</code> does not affect delivery.`, `<a href="/outbound/dmarc/domains/1">reports</a>`,
|
`<code>p=none</code> does not affect delivery.`, `<a href="/outbound/dmarc/domains/1">reports</a>`,
|
||||||
)
|
)
|
||||||
if got := strings.Count(out, `In DNS now`); got != 1 {
|
if got := strings.Count(out, `In DNS now`); got != 1 {
|
||||||
@@ -180,7 +180,7 @@ func TestOutDomainMenuFollowsTheViewer(t *testing.T) {
|
|||||||
out := renderSignedIn(t, "out-domain", outDomainFixture())
|
out := renderSignedIn(t, "out-domain", outDomainFixture())
|
||||||
pageHas(t, "Domain", out,
|
pageHas(t, "Domain", out,
|
||||||
`href="#dns"`, `href="#apps"`, `href="#connection"`, `href="/outbound/log?domain=example.org"`, `href="/outbound/dmarc/domains/1"`,
|
`href="#dns"`, `href="#apps"`, `href="#connection"`, `href="/outbound/log?domain=example.org"`, `href="/outbound/dmarc/domains/1"`,
|
||||||
`href="/outbound/domains/1/settings#reports"`, `href="/outbound/domains/1/settings#limit"`, `href="/outbound/domains/1/settings#export"`,
|
`href="/outbound/domains/1/settings"`, `href="/outbound/domains/1/settings"`, `href="/outbound/domains/1/settings"`,
|
||||||
`href="/outbound/domains/1/delete"`, `id="dns"`, `id="apps"`, `id="connection"`,
|
`href="/outbound/domains/1/delete"`, `id="dns"`, `id="apps"`, `id="connection"`,
|
||||||
`class="sp-help" href="/help#dns"`, `class="sp-help" href="/help#apps"`,
|
`class="sp-help" href="/help#dns"`, `class="sp-help" href="/help#apps"`,
|
||||||
)
|
)
|
||||||
@@ -437,7 +437,7 @@ func TestOutDomainDeleteNamesWhatGoesWithIt(t *testing.T) {
|
|||||||
`reload OpenDKIM so the domain is no longer signed.`, `Inbound domains are not touched.`,
|
`reload OpenDKIM so the domain is no longer signed.`, `Inbound domains are not touched.`,
|
||||||
`<form method="post" action="/outbound/domains/1/delete">`, `class="button is-danger"`, `Delete example.org</button>`,
|
`<form method="post" action="/outbound/domains/1/delete">`, `class="button is-danger"`, `Delete example.org</button>`,
|
||||||
`href="/outbound/domains/1">Keep it</a>`,
|
`href="/outbound/domains/1">Keep it</a>`,
|
||||||
`<h2>Moving it instead?</h2>`, `<a href="/outbound/domains/1/settings#export">Export the domain</a>`,
|
`<h2>Moving it instead?</h2>`, `<a href="/outbound/domains/1/settings">Export the domain</a>`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// No applications: nothing to say about them.
|
// No applications: nothing to say about them.
|
||||||
@@ -508,3 +508,14 @@ func TestActionConfirmIsOnlyAnAttribute(t *testing.T) {
|
|||||||
t.Errorf("Confirm changes more than the attribute:\n%s\n%s", plain, asked)
|
t.Errorf("Confirm changes more than the attribute:\n%s\n%s", plain, asked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The DKIM value is shown whole: the field grows with the key, so it does not
|
||||||
|
// scroll inside itself.
|
||||||
|
func TestDKIMFieldIsAsTallAsItsValue(t *testing.T) {
|
||||||
|
short := DKIMRecord("sp", "sp._domainkey.example.org", "v=DKIM1; p=AAA", DNSCheck{})
|
||||||
|
long := DKIMRecord("sp", "sp._domainkey.example.org", "v=DKIM1; h=sha256; k=rsa; p="+strings.Repeat("A", 392), DNSCheck{})
|
||||||
|
huge := DKIMRecord("sp", "sp._domainkey.example.org", strings.Repeat("A", 5000), DNSCheck{})
|
||||||
|
if short.ValueRows != 3 || long.ValueRows != 7 || huge.ValueRows != 12 {
|
||||||
|
t.Errorf("rows = %d, %d, %d; want 3, 7, 12", short.ValueRows, long.ValueRows, huge.ValueRows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,19 +153,28 @@
|
|||||||
|
|
||||||
function initShowWhen(root) {
|
function initShowWhen(root) {
|
||||||
showWhenRules.forEach(function (rule) {
|
showWhenRules.forEach(function (rule) {
|
||||||
root.querySelectorAll(rule.match).forEach(function (control) {
|
root.querySelectorAll(rule.match).forEach(syncShowWhen);
|
||||||
syncShowWhen(control);
|
|
||||||
control.addEventListener("change", function () {
|
|
||||||
syncShowWhen(control);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One listener for the document rather than one per control: a form that
|
||||||
|
// arrives in an htmx swap is covered without being wired up again.
|
||||||
|
document.addEventListener("change", function (ev) {
|
||||||
|
if (ev.target && ev.target.matches) {
|
||||||
|
syncShowWhen(ev.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
initShowWhen(document);
|
initShowWhen(document);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// htmx:load fires for every element htmx puts into the page, so swapped-in
|
||||||
|
// markup starts with its blocks shown or hidden the way a full page does.
|
||||||
|
document.addEventListener("htmx:load", function (ev) {
|
||||||
|
initShowWhen(ev.detail.elt);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Adaptive monitoring polling ---------------------------------------
|
// --- Adaptive monitoring polling ---------------------------------------
|
||||||
// The four monitoring fragments carry data-poll and hx-trigger="load" for
|
// The four monitoring fragments carry data-poll and hx-trigger="load" for
|
||||||
// the first fetch only. panel.js schedules the rest: 5 s while the operator
|
// the first fetch only. panel.js schedules the rest: 5 s while the operator
|
||||||
|
|||||||
@@ -160,6 +160,25 @@ func TestE2E(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The panel runs postqueue as its own user. That works only while the queue's
|
||||||
|
// public/ directory belongs to Postfix's setgid group, which the entrypoint
|
||||||
|
// has to restore after its recursive chown of the queue tree.
|
||||||
|
run("queue_readable_by_the_panel", func(t *testing.T) {
|
||||||
|
resp, body, err := sc.panel.get("/outbound/queue")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("GET /outbound/queue = %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "Could not read the mail queue") {
|
||||||
|
t.Fatal("the panel could not run postqueue -p (queue directory permissions)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Mail queue is empty") {
|
||||||
|
t.Fatalf("the queue page does not show postqueue's listing of an empty queue:\n%s", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
run("add_domain_and_publish_dkim", func(t *testing.T) {
|
run("add_domain_and_publish_dkim", func(t *testing.T) {
|
||||||
id, err := sc.panel.addDomain(senderDomain)
|
id, err := sc.panel.addDomain(senderDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ type port25Case struct {
|
|||||||
name string
|
name string
|
||||||
env []string
|
env []string
|
||||||
check []optionCheck
|
check []optionCheck
|
||||||
|
// main are main.cf parameters the combination must end up with, read with
|
||||||
|
// postconf -h.
|
||||||
|
main []optionCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
var port25Cases = []port25Case{
|
var port25Cases = []port25Case{
|
||||||
@@ -47,7 +50,7 @@ var port25Cases = []port25Case{
|
|||||||
"DMARC_REPORTS_ENABLE=true",
|
"DMARC_REPORTS_ENABLE=true",
|
||||||
},
|
},
|
||||||
check: []optionCheck{
|
check: []optionCheck{
|
||||||
{param: "smtpd_recipient_restrictions", contains: "check_recipient_access texthash:/data/postfix/dmarc_recipients,reject", wantExact: true},
|
{param: "smtpd_recipient_restrictions", contains: "check_recipient_access,texthash:/data/postfix/dmarc_recipients,reject", wantExact: true},
|
||||||
{param: "smtpd_milters", contains: "", wantExact: true},
|
{param: "smtpd_milters", contains: "", wantExact: true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -58,9 +61,16 @@ var port25Cases = []port25Case{
|
|||||||
"DMARC_REPORTS_ENABLE=true",
|
"DMARC_REPORTS_ENABLE=true",
|
||||||
},
|
},
|
||||||
check: []optionCheck{
|
check: []optionCheck{
|
||||||
{param: "smtpd_recipient_restrictions", contains: "check_recipient_access texthash:/data/postfix/dmarc_recipients,reject_unauth_destination,reject_unlisted_recipient", wantExact: true},
|
{param: "smtpd_recipient_restrictions", contains: "check_recipient_access,texthash:/data/postfix/dmarc_recipients,reject_unauth_destination,reject_unlisted_recipient", wantExact: true},
|
||||||
{param: "smtpd_milters", contains: "", wantExact: true},
|
{param: "smtpd_milters", contains: "", wantExact: true},
|
||||||
},
|
},
|
||||||
|
// Both on: the report addresses sit on a relay domain, and the relay's
|
||||||
|
// unlisted-recipient check runs after the restriction list. Unless their
|
||||||
|
// table is a recipient table too, every report is refused as "User
|
||||||
|
// unknown in relay recipient table".
|
||||||
|
main: []optionCheck{
|
||||||
|
{param: "relay_recipient_maps", contains: "texthash:/data/postfix/relay_recipients texthash:/data/postfix/dmarc_recipients", wantExact: true},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "inbound relay with antispam milter",
|
name: "inbound relay with antispam milter",
|
||||||
@@ -116,6 +126,27 @@ func checkPort25ConfigCombinations(s *stack) error {
|
|||||||
case !c.wantExact && !strings.Contains(got, c.contains):
|
case !c.wantExact && !strings.Contains(got, c.contains):
|
||||||
return fmt.Errorf("%s: -o %s = %q, want it to contain %q", tc.name, c.param, got, c.contains)
|
return fmt.Errorf("%s: -o %s = %q, want it to contain %q", tc.name, c.param, got, c.contains)
|
||||||
}
|
}
|
||||||
|
// What the file says is not yet what Postfix reads: master splits
|
||||||
|
// a service line on whitespace, so a value with a space in it is
|
||||||
|
// cut at the space and the rest becomes a stray smtpd argument
|
||||||
|
// ("fatal: unexpected command-line argument", port 25 dead while
|
||||||
|
// `postfix check` stays silent). Ask Postfix itself.
|
||||||
|
seen, err := s.execIn("selfpost", "postconf", "-Ph", "smtp/inet/"+c.param)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: postconf -P smtp/inet/%s: %v\n%s", tc.name, c.param, err, seen)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(seen) != got {
|
||||||
|
return fmt.Errorf("%s: master.cf has -o %s = %q but Postfix reads %q - the value contains whitespace", tc.name, c.param, got, strings.TrimSpace(seen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range tc.main {
|
||||||
|
got, err := s.execIn("selfpost", "postconf", "-h", c.param)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: postconf -h %s: %v\n%s", tc.name, c.param, err, got)
|
||||||
|
}
|
||||||
|
if got = strings.TrimSpace(got); got != c.contains {
|
||||||
|
return fmt.Errorf("%s: %s = %q, want %q", tc.name, c.param, got, c.contains)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user