- Two inbound domains on one upstream could never change its TLS mode,
each save refused for the other (medium): SetTransportAs changes the
mode for every domain on the upstream when the one saving may change
them all; a domain user who may not is refused with the mode, not the
other tenant's domain name (security F1: the first version named it).
- A deletion's leftover DKIM key is removed by Resync (Reload
configuration, a restore's first start) for names no domain has.
- The backup's write deadline moves before Check.
- hasUnsavedEdit skips password and file fields and compares a multiple
select's options; htmx.config.timeout 30 s, so a refresh never answered
ends and is tried again; a failed refresh's promise is handled.
- An empty filtered Outbound log says no message matches the filters.
- The dummy bcrypt hash is made at start; a DMARC part over 8 MiB is
refused for its size like the other two limits.
- Health's Configuration box says what Reload writes (design first,
32dfe4a).
- Docs: CHANGELOG, guide § Inbound, security.md (the shared upstream's
mode, the profile form's "username taken"), architecture.md (owed to an
address), the plan's notes corrected.
Tests: the shared upstream (service, handler), the leftover key, all eight
Health boxes ordered, the subject from before migration 0011, the new JS
cases.
Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 636 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+15
-7
@@ -61,12 +61,13 @@ Migration `0011` (a table, nothing existing changes).
|
|||||||
- **Recalculate rate limit dropped what was typed on the page.** The button
|
- **Recalculate rate limit dropped what was typed on the page.** The button
|
||||||
leaves the page; when a field holds an edit not saved yet, it now asks
|
leaves the page; when a field holds an edit not saved yet, it now asks
|
||||||
first (audit 2026-10-12, L19).
|
first (audit 2026-10-12, L19).
|
||||||
- **Deleting a domain could leave its applications able to send.** When
|
- **A domain deletion that failed half way said "internal error".** When
|
||||||
OpenDKIM's tables failed to rebuild after the domain's row was gone, the
|
OpenDKIM's tables failed to rebuild after the domain's row was gone, the
|
||||||
sender map was not rebuilt either and kept the deleted applications'
|
sender map was not rebuilt either and kept the deleted applications'
|
||||||
logins, and the page said "internal error". Every rebuild now runs, and
|
logins (their accounts were already gone), and the DKIM key stayed. Every
|
||||||
the page says the domain is deleted and that Reload configuration
|
rebuild now runs, the page says the domain is deleted and that Reload
|
||||||
finishes the rest (audit 2026-10-12, L17).
|
configuration finishes the rest, and Reload configuration removes the
|
||||||
|
keys of domains that no longer exist (audit 2026-10-12, L17).
|
||||||
- **A notification whose copy failed for one user was never sent to them**
|
- **A notification whose copy failed for one user was never sent to them**
|
||||||
once another user's copy had gone out, and a copy held back by the hourly
|
once another user's copy had gone out, and a copy held back by the hourly
|
||||||
ceiling was dropped. A notification is now owed to each recipient: the
|
ceiling was dropped. A notification is now owed to each recipient: the
|
||||||
@@ -87,9 +88,16 @@ Migration `0011` (a table, nothing existing changes).
|
|||||||
failed as damaged; it is now refused for its size, and `mail.log` says so.
|
failed as damaged; it is now refused for its size, and `mail.log` says so.
|
||||||
- **Two inbound domains to one upstream with different TLS modes** wrote two
|
- **Two inbound domains to one upstream with different TLS modes** wrote two
|
||||||
policy lines for one next hop, and Postfix used the first; the form's
|
policy lines for one next hop, and Postfix used the first; the form's
|
||||||
choice for the other domain did nothing. Saving the second is refused,
|
choice for the other domain did nothing. An upstream now has one TLS mode:
|
||||||
naming the domain whose mode the upstream has, and the policy map holds
|
saving it for one domain changes it for the others forwarded there, when
|
||||||
each next hop once (audit 2026-10-12, L5).
|
the one saving manages them all; a domain administrator who does not is
|
||||||
|
refused, told the mode in use and not the other domain. The policy map
|
||||||
|
holds each next hop once (audit 2026-10-12, L5).
|
||||||
|
- **A monitoring refresh that never got an answer stopped the refreshes.**
|
||||||
|
Requests the panel's pages make in the background now give up after 30
|
||||||
|
seconds and are tried again.
|
||||||
|
- **An empty filtered Outbound log said "No messages logged yet."** It says
|
||||||
|
that no message matches the filters.
|
||||||
|
|
||||||
## [2.5.3] - 2026-10-15
|
## [2.5.3] - 2026-10-15
|
||||||
|
|
||||||
|
|||||||
@@ -401,7 +401,10 @@ is sent; the operator's view is guide § Notifications.
|
|||||||
who are still recipients. A user who comes to hear of a subject only while
|
who are still recipients. A user who comes to hear of a subject only while
|
||||||
it is open is not mailed about it, and its ending goes to those who were
|
it is open is not mailed about it, and its ending goes to those who were
|
||||||
told. A subject opened before `2.5.4` has no such record, and its ending
|
told. A subject opened before `2.5.4` has no such record, and its ending
|
||||||
goes to every recipient, as it did.
|
goes to every recipient, as it did. It is owed to an address: a user who
|
||||||
|
changes their e-mail while a subject is open is a new address, told
|
||||||
|
neither its follow-up nor its end - accepted, a subject lasts hours to
|
||||||
|
days.
|
||||||
- **The notifier's own mail is not a subject.** A notification waiting for a
|
- **The notifier's own mail is not a subject.** A notification waiting for a
|
||||||
mailbox that does not answer, or its bounce coming back to
|
mailbox that does not answer, or its bounce coming back to
|
||||||
`postmaster@<hostname>` - which receives no mail - is left out of the
|
`postmaster@<hostname>` - which receives no mail - is left out of the
|
||||||
|
|||||||
@@ -1200,7 +1200,8 @@ command whose leftover is the owner's alone.
|
|||||||
would have been cut at 60 s, so `HandleBackup` moves its write deadline
|
would have been cut at 60 s, so `HandleBackup` moves its write deadline
|
||||||
to two hours through `http.ResponseController` (no wrapper of the
|
to two hours through `http.ResponseController` (no wrapper of the
|
||||||
ResponseWriter in the chain; e2e checks the log has no failure to move
|
ResponseWriter in the chain; e2e checks the log has no failure to move
|
||||||
it). A test pins the four values.
|
it). A test checks the four are set and long enough for a form and a
|
||||||
|
DNS page.
|
||||||
- [x] **Polling recovery**: schedule from `htmx:afterRequest` on the box (or pass
|
- [x] **Polling recovery**: schedule from `htmx:afterRequest` on the box (or pass
|
||||||
`source`), handle `sendError`/`timeout` with back-off; `RequireAuth`
|
`source`), handle `sendError`/`timeout` with back-off; `RequireAuth`
|
||||||
answers an `HX-Request` with `HX-Redirect: /login` instead of a 303. A
|
answers an `HX-Request` with `HX-Redirect: /login` instead of a 303. A
|
||||||
@@ -1261,7 +1262,9 @@ command whose leftover is the owner's alone.
|
|||||||
poll, the pages, the rows' links and the message's way back;
|
poll, the pages, the rows' links and the message's way back;
|
||||||
`SendLogFilter.Status`, `Since`, `Until` (whole UTC days, as the
|
`SendLogFilter.Status`, `Since`, `Until` (whole UTC days, as the
|
||||||
Inbound log's). A status not in the list is no narrowing. Plans: a
|
Inbound log's). A status not in the list is no narrowing. Plans: a
|
||||||
domain user's filtered page still goes by `idx_send_log_domain_id`; the
|
domain user's filtered page goes by `idx_send_log_domain_id` (with a day
|
||||||
|
filter by `idx_send_log_domain_id_created_at` and a sort - not pinned by
|
||||||
|
the plan test); the
|
||||||
global role's status-only count walks the table (a page view or a 5 s
|
global role's status-only count walks the table (a page view or a 5 s
|
||||||
poll while the log is open on a status - tens of thousands of rows at
|
poll while the log is open on a status - tens of thousands of rows at
|
||||||
the retention limit, milliseconds), accepted.
|
the retention limit, milliseconds), accepted.
|
||||||
@@ -1388,6 +1391,31 @@ command whose leftover is the owner's alone.
|
|||||||
saved before keep the first by domain name, as Postfix did). Tests in
|
saved before keep the first by domain name, as Postfix did). Tests in
|
||||||
inbound and postfix (`TestRenderInboundMapsOneTLSLinePerNextHop`).
|
inbound and postfix (`TestRenderInboundMapsOneTLSLinePerNextHop`).
|
||||||
|
|
||||||
|
*Changed by the reviews of stage 4 (2026-10-15; technical and security):*
|
||||||
|
**two domains on one upstream could never change its TLS mode** - each
|
||||||
|
save was refused for the other (medium); `inbound.Service.SetTransportAs`
|
||||||
|
now changes the mode for every domain on the upstream when the one saving
|
||||||
|
may change them all, and refuses a domain user who may not with the mode,
|
||||||
|
not the other tenant's name (the security review's F1: the first version's
|
||||||
|
refusal named it). The deletion's leftover key is removed by Resync (Reload
|
||||||
|
configuration, a restore's start) for names no domain has. The backup's
|
||||||
|
write deadline moves before Check. `hasUnsavedEdit` skips password and file
|
||||||
|
fields (a password manager filling the export password made Recalculate
|
||||||
|
ask) and compares a multiple select's options. `htmx.config.timeout` 30 s,
|
||||||
|
so a refresh that is never answered ends and is tried again; the rejected
|
||||||
|
promise of a failed refresh is handled. A filtered empty Outbound log says
|
||||||
|
so. The dummy hash is made at start. A DMARC part over 8 MiB is refused for
|
||||||
|
its size like the others. The Configuration box on Health says what Reload
|
||||||
|
writes (`design:` first). Tests: the shared upstream (service and handler),
|
||||||
|
the leftover key, all eight Health boxes ordered, the legacy subject.
|
||||||
|
Accepted, with the reason: a notification is owed to an address
|
||||||
|
(architecture.md § Notifications); SERVFAIL from every resolver is Unknown
|
||||||
|
- it is not an answer about the record; Reload repeats the restore's list
|
||||||
|
of steps by hand (`HandleReload` and `mailStack.Resync`, both commented); a
|
||||||
|
failed `SetNotifyAddressees` is logged and the subject stays said, the next
|
||||||
|
follow-up finds nobody owed; the profile form's "username taken" is
|
||||||
|
documented in security.md.
|
||||||
|
|
||||||
### Stage 5 — image and Postfix hardening (M17–M19, M21, L30–L32) → `2.6.0`
|
### Stage 5 — image and Postfix hardening (M17–M19, M21, L30–L32) → `2.6.0`
|
||||||
|
|
||||||
- [ ] **Supervisor socket**: a group of its own (`supervisor`), panel only;
|
- [ ] **Supervisor socket**: a group of its own (`supervisor`), panel only;
|
||||||
|
|||||||
+4
-3
@@ -1016,9 +1016,10 @@ reports](#dmarc-reports).) Outbound 465/587 is unchanged.
|
|||||||
**Panel** (`/inbound/domains`): the global role adds and deletes domains. On a
|
**Panel** (`/inbound/domains`): the global role adds and deletes domains. On a
|
||||||
domain's page, set the upstream host/port and TLS to that hop (opportunistic /
|
domain's page, set the upstream host/port and TLS to that hop (opportunistic /
|
||||||
required / off), and choose recipients. An upstream has one TLS mode: Postfix
|
required / off), and choose recipients. An upstream has one TLS mode: Postfix
|
||||||
keeps the TLS policy per next hop, not per domain, so a second domain sent to
|
keeps the TLS policy per next hop, not per domain, so the mode saved for one
|
||||||
the same host and port with another mode is refused, naming the domain that
|
domain is the mode of every domain forwarded to the same host and port, and
|
||||||
already uses it. Recipients are an allow-list, or any address at that
|
they are changed with it; a *domain* user who does not manage all of them is
|
||||||
|
refused, and told the mode in use. Recipients are an allow-list, or any address at that
|
||||||
domain. Inbound domains are assigned to a user with the *domain* role
|
domain. Inbound domains are assigned to a user with the *domain* role
|
||||||
separately from outbound ones (see [Users](#users)); such a user sees and
|
separately from outbound ones (see [Users](#users)); such a user sees and
|
||||||
changes the upstream and recipients of the inbound domains assigned to them,
|
changes the upstream and recipients of the inbound domains assigned to them,
|
||||||
|
|||||||
+9
-2
@@ -610,7 +610,12 @@ deferred item from the roadmap.
|
|||||||
panel itself makes no request to the upstream. Trigger to revisit: `domain`
|
panel itself makes no request to the upstream. Trigger to revisit: `domain`
|
||||||
users who are not trusted operators — the same condition as for CSRF tokens
|
users who are not trusted operators — the same condition as for CSRF tokens
|
||||||
— at which point an allow-list of upstream networks, or restricting the
|
— at which point an allow-list of upstream networks, or restricting the
|
||||||
field to the global role, is the fix.
|
field to the global role, is the fix. Since `2.5.4` an upstream has one TLS
|
||||||
|
mode for every inbound domain forwarded to it (Postfix keys the policy by
|
||||||
|
the next hop): a `domain` user saving one that another tenant's domain
|
||||||
|
uses with another mode is refused and told that mode - not the domain -
|
||||||
|
so the form tells whether some domain forwards to a given host and port.
|
||||||
|
Accepted with the rest of this item.
|
||||||
- **Account's profile (username, e-mail) is saved without the password.**
|
- **Account's profile (username, e-mail) is saved without the password.**
|
||||||
Saving the profile does not ask for the current password, as changing the
|
Saving the profile does not ask for the current password, as changing the
|
||||||
password does. Since 2.3.0 the e-mail is where notifications go, so a stolen
|
password does. Since 2.3.0 the e-mail is where notifications go, so a stolen
|
||||||
@@ -619,7 +624,9 @@ deferred item from the roadmap.
|
|||||||
certificate expires, sender addresses of old queued mail - and the user
|
certificate expires, sender addresses of old queued mail - and the user
|
||||||
stops hearing it. Nothing in a notification gives access. A `domain` user
|
stops hearing it. Nothing in a notification gives access. A `domain` user
|
||||||
can likewise point signed mail of this server at a third party's address,
|
can likewise point signed mail of this server at a third party's address,
|
||||||
a dozen messages a day per assigned domain at most. It
|
a dozen messages a day per assigned domain at most. The profile form also
|
||||||
|
says when a username is taken, so a signed-in user can tell which
|
||||||
|
usernames exist; signing in does not (`2.5.4`). It
|
||||||
becomes a requirement to ask for the current password when changing
|
becomes a requirement to ask for the current password when changing
|
||||||
`users.email` as soon as [password-reset](roadmap.md#password-reset) mails a
|
`users.email` as soon as [password-reset](roadmap.md#password-reset) mails a
|
||||||
link to it — from then on whoever can change the address could take the
|
link to it — from then on whoever can change the address could take the
|
||||||
|
|||||||
@@ -197,7 +197,10 @@ func readEncodedBody(hdr textproto.MIMEHeader, r io.Reader) ([]byte, error) {
|
|||||||
case "quoted-printable":
|
case "quoted-printable":
|
||||||
body = quotedprintable.NewReader(r)
|
body = quotedprintable.NewReader(r)
|
||||||
}
|
}
|
||||||
data, err := io.ReadAll(io.LimitReader(body, 8<<20))
|
data, err := readAtMost(body, maxAggregateXML)
|
||||||
|
if errors.Is(err, errTooLarge) {
|
||||||
|
return nil, fmt.Errorf("the report part is larger than %d MiB", maxAggregateXML>>20)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read part body: %w", err)
|
return nil, fmt.Errorf("read part body: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,23 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
|
|||||||
if _, err := os.Stat(keyDir); err != nil {
|
if _, err := os.Stat(keyDir); err != nil {
|
||||||
t.Errorf("the key went while the tables may still name it: %v", err)
|
t.Errorf("the key went while the tables may still name it: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload configuration finishes the deletion: the tables rebuilt, the
|
||||||
|
// key of a domain that is gone goes too; the others' stay.
|
||||||
|
odk.reload = func() error { return nil }
|
||||||
|
keep, err := s.Add("kept.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.Resync(); err != nil {
|
||||||
|
t.Fatalf("Resync: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(keyDir); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("the deleted domain's key is still there after Resync: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(odk.keysDir, keep.Name)); err != nil {
|
||||||
|
t.Errorf("Resync removed a domain's key: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sender map failing is the same: deleted, said so; the tables were
|
// The sender map failing is the same: deleted, said so; the tables were
|
||||||
|
|||||||
@@ -92,6 +92,24 @@ func (o *OpenDKIM) RemoveKey(domainName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KeyDomains lists the names that have a key directory.
|
||||||
|
func (o *OpenDKIM) KeyDomains() ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(o.keysDir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list key dirs: %w", err)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
out = append(out, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in
|
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in
|
||||||
// a domain export so the receiving instance signs with the same key and the
|
// a domain export so the receiving instance signs with the same key and the
|
||||||
// DNS TXT record never has to change (architecture.md § Persistence). It
|
// DNS TXT record never has to change (architecture.md § Persistence). It
|
||||||
|
|||||||
@@ -267,8 +267,35 @@ func (s *Service) ClearRateLimit(domainID int64) error {
|
|||||||
// OpenDKIM. It backs the manual reload button (architecture.md § Panel HTTP
|
// OpenDKIM. It backs the manual reload button (architecture.md § Panel HTTP
|
||||||
// surface) and doubles as a recovery path if the tables ever drift from the
|
// surface) and doubles as a recovery path if the tables ever drift from the
|
||||||
// database.
|
// database.
|
||||||
|
//
|
||||||
|
// It also removes the key directories of names no domain has and that are
|
||||||
|
// not the server's own: what a deletion left when the tables could not be
|
||||||
|
// rebuilt (Delete keeps the key then). The "Reload configuration" the page
|
||||||
|
// points to after such a deletion finishes it here (review of audit stage 4).
|
||||||
func (s *Service) Resync() error {
|
func (s *Service) Resync() error {
|
||||||
return s.resync()
|
if err := s.resync(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
domains, err := s.store.ListDomains()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keep := map[string]bool{s.serverName: true}
|
||||||
|
for _, d := range domains {
|
||||||
|
keep[d.Name] = true
|
||||||
|
}
|
||||||
|
names, err := s.odk.KeyDomains()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
if !keep[name] {
|
||||||
|
if err := s.odk.RemoveKey(name); err != nil {
|
||||||
|
return fmt.Errorf("remove the key of %s, a domain that is gone: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resync rebuilds KeyTable/SigningTable from the current domain set and reloads.
|
// resync rebuilds KeyTable/SigningTable from the current domain set and reloads.
|
||||||
|
|||||||
+53
-26
@@ -5,6 +5,7 @@
|
|||||||
package inbound
|
package inbound
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/postfix"
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
@@ -54,51 +55,77 @@ func (s *Service) Add(name string) (store.InboundDomain, error) {
|
|||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTransport validates and saves the upstream, then rebuilds the maps.
|
// SetTransport validates and saves the upstream, then rebuilds the maps; the
|
||||||
|
// other domains forwarded to the same upstream take its TLS mode with it
|
||||||
|
// (SetTransportAs, with every domain changeable).
|
||||||
func (s *Service) SetTransport(id int64, host, portRaw, tlsMode string) error {
|
func (s *Service) SetTransport(id int64, host, portRaw, tlsMode string) error {
|
||||||
|
_, err := s.SetTransportAs(id, host, portRaw, tlsMode, func(store.InboundDomain) bool { return true })
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpstreamShared refuses an upstream another inbound domain forwards to with
|
||||||
|
// another TLS mode, when the one saving may not change that domain. Its text
|
||||||
|
// is for the log; the panel tells a domain user the mode, not the domain,
|
||||||
|
// which is another tenant's (security review of audit stage 4).
|
||||||
|
type UpstreamShared struct {
|
||||||
|
Upstream string
|
||||||
|
Mode string
|
||||||
|
Domain string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *UpstreamShared) Error() string {
|
||||||
|
return fmt.Sprintf("%s is the upstream of %s too, with TLS mode %s", e.Upstream, e.Domain, e.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTransportAs validates and saves the upstream of domain id, then rebuilds
|
||||||
|
// the maps. An upstream has one TLS mode: Postfix's TLS policy is keyed by the
|
||||||
|
// next hop, not by the domain (smtp_tls_policy_maps; Postfix 3.7.11,
|
||||||
|
// README_FILES/TLS_README: the key is the verbatim next-hop), so two domains
|
||||||
|
// to one [host]:port share one mode - the map held both keys and the first by
|
||||||
|
// domain name won with a warning (audit 2026-10-12, L5). The other domains
|
||||||
|
// forwarded to the upstream with another mode take the new one, when
|
||||||
|
// mayChange allows each; one it does not is an *UpstreamShared and nothing is
|
||||||
|
// saved. Before, the save was refused, and two domains sharing an upstream
|
||||||
|
// could never change its mode (review of audit stage 4). changed is how many
|
||||||
|
// other domains took the mode.
|
||||||
|
func (s *Service) SetTransportAs(id int64, host, portRaw, tlsMode string, mayChange func(store.InboundDomain) bool) (changed int, err error) {
|
||||||
if _, err := s.store.GetInboundDomain(id); err != nil {
|
if _, err := s.store.GetInboundDomain(id); err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
host = normalizeHost(host)
|
host = normalizeHost(host)
|
||||||
if err := checkHost(host); err != nil {
|
if err := checkHost(host); err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
port, err := parsePort(portRaw)
|
port, err := parsePort(portRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
if err := checkTLSMode(tlsMode); err != nil {
|
if err := checkTLSMode(tlsMode); err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
if err := s.checkOneTLSModePerUpstream(id, host, port, tlsMode); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.store.UpdateInboundTransport(id, host, port, tlsMode); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.Resync()
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkOneTLSModePerUpstream refuses an upstream another inbound domain
|
|
||||||
// already forwards to with another TLS mode. Postfix's TLS policy is keyed by
|
|
||||||
// the next hop, not by the domain (smtp_tls_policy_maps; Postfix 3.7.11,
|
|
||||||
// README_FILES/TLS_README: the key is the verbatim next-hop), so two domains
|
|
||||||
// to one [host]:port share one mode: the map held both keys, and the first by
|
|
||||||
// domain name won with a warning - the form's choice for the second domain
|
|
||||||
// was not what Postfix did (audit 2026-10-12, L5).
|
|
||||||
func (s *Service) checkOneTLSModePerUpstream(id int64, host string, port int, tlsMode string) error {
|
|
||||||
domains, err := s.store.ListInboundDomains()
|
domains, err := s.store.ListInboundDomains()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
var others []store.InboundDomain
|
||||||
for _, d := range domains {
|
for _, d := range domains {
|
||||||
if d.ID == id || d.Port != port || normalizeHost(d.Host) != host || d.TLSMode == tlsMode {
|
if d.ID == id || d.Port != port || normalizeHost(d.Host) != host || d.TLSMode == tlsMode {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return invalid("[%s]:%d is already the upstream of %s, with TLS mode %s: an upstream has one TLS mode - choose %s here, or change it on %s first",
|
if !mayChange(d) {
|
||||||
host, port, d.Name, d.TLSMode, d.TLSMode, d.Name)
|
return 0, &UpstreamShared{Upstream: fmt.Sprintf("[%s]:%d", host, port), Mode: d.TLSMode, Domain: d.Name}
|
||||||
|
}
|
||||||
|
others = append(others, d)
|
||||||
}
|
}
|
||||||
return nil
|
if err := s.store.UpdateInboundTransport(id, host, port, tlsMode); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, d := range others {
|
||||||
|
if err := s.store.UpdateInboundTransport(d.ID, d.Host, d.Port, tlsMode); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(others), s.Resync()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRecipients validates the mode and, in list mode, every address, then
|
// SetRecipients validates the mode and, in list mode, every address, then
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package inbound
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/postfix"
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
@@ -14,9 +13,11 @@ type nullMaps struct{}
|
|||||||
func (nullMaps) RebuildInboundMaps([]postfix.InboundRoute) error { return nil }
|
func (nullMaps) RebuildInboundMaps([]postfix.InboundRoute) error { return nil }
|
||||||
|
|
||||||
// An upstream has one TLS mode, since Postfix keys the policy by the next
|
// An upstream has one TLS mode, since Postfix keys the policy by the next
|
||||||
// hop: a second domain saved to the same [host]:port with another mode is
|
// hop (audit 2026-10-12, L5): the other domains forwarded to it take the mode
|
||||||
// refused with the first domain's name and mode; the same mode is fine
|
// saved for one of them, when the one saving may change them; one they may
|
||||||
// (audit 2026-10-12, L5).
|
// not is refused, and nothing is saved. Two domains on one upstream can
|
||||||
|
// change its mode (review of stage 4: they could not, each refused for the
|
||||||
|
// other).
|
||||||
func TestOneTLSModePerUpstream(t *testing.T) {
|
func TestOneTLSModePerUpstream(t *testing.T) {
|
||||||
st, err := store.Open(t.TempDir() + "/test.db")
|
st, err := store.Open(t.TempDir() + "/test.db")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -24,30 +25,44 @@ func TestOneTLSModePerUpstream(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
s := NewService(st, nullMaps{})
|
s := NewService(st, nullMaps{})
|
||||||
a, err := s.Add("a.example")
|
a, _ := s.Add("a.example")
|
||||||
if err != nil {
|
b, _ := s.Add("b.example")
|
||||||
|
c, _ := s.Add("c.example")
|
||||||
|
mode := func(id int64) string {
|
||||||
|
d, err := st.GetInboundDomain(id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return d.TLSMode
|
||||||
|
}
|
||||||
|
all := func(store.InboundDomain) bool { return true }
|
||||||
|
|
||||||
|
if _, err := s.SetTransportAs(a.ID, "10.0.0.8", "25", "encrypt", all); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
b, err := s.Add("b.example")
|
if n, err := s.SetTransportAs(b.ID, "[10.0.0.8]", "25", "encrypt", all); err != nil || n != 0 {
|
||||||
if err != nil {
|
t.Fatalf("the same mode: %d, %v", n, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
if err := s.SetTransport(a.ID, "10.0.0.8", "25", "encrypt"); err != nil {
|
// Either domain changes the mode, for both.
|
||||||
t.Fatal(err)
|
if n, err := s.SetTransportAs(a.ID, "10.0.0.8", "25", "may", all); err != nil || n != 1 {
|
||||||
|
t.Fatalf("a changes the shared mode: %d, %v", n, err)
|
||||||
}
|
}
|
||||||
err = s.SetTransport(b.ID, "[10.0.0.8]", "25", "may")
|
if mode(a.ID) != "may" || mode(b.ID) != "may" {
|
||||||
var verr *ValidationError
|
t.Errorf("modes after a's change: %s, %s", mode(a.ID), mode(b.ID))
|
||||||
if !errors.As(err, &verr) || !strings.Contains(err.Error(), "[10.0.0.8]:25 is already the upstream of a.example, with TLS mode encrypt") {
|
|
||||||
t.Fatalf("a second mode for one upstream = %v", err)
|
|
||||||
}
|
}
|
||||||
if err := s.SetTransport(b.ID, "10.0.0.8", "25", "encrypt"); err != nil {
|
// Another port is another next hop.
|
||||||
t.Errorf("the same mode refused: %v", err)
|
if n, err := s.SetTransportAs(c.ID, "10.0.0.8", "2525", "none", all); err != nil || n != 0 {
|
||||||
|
t.Errorf("another port: %d, %v", n, err)
|
||||||
}
|
}
|
||||||
if err := s.SetTransport(b.ID, "10.0.0.8", "2525", "may"); err != nil {
|
|
||||||
t.Errorf("another port refused: %v", err)
|
// One that may not change b is refused, with b's mode, and saves nothing.
|
||||||
|
notB := func(d store.InboundDomain) bool { return d.ID != b.ID }
|
||||||
|
_, err = s.SetTransportAs(c.ID, "10.0.0.8", "25", "encrypt", notB)
|
||||||
|
var shared *UpstreamShared
|
||||||
|
if !errors.As(err, &shared) || shared.Mode != "may" || shared.Domain != "b.example" || shared.Upstream != "[10.0.0.8]:25" {
|
||||||
|
t.Fatalf("a mode for another's upstream = %v", err)
|
||||||
}
|
}
|
||||||
// The domain itself may change its own mode.
|
if d, _ := st.GetInboundDomain(c.ID); d.Port != 2525 || mode(a.ID) != "may" {
|
||||||
if err := s.SetTransport(a.ID, "10.0.0.8", "25", "may"); err != nil {
|
t.Errorf("the refused save changed something: c on %d, a %s", d.Port, mode(a.ID))
|
||||||
t.Errorf("a domain changing its own mode refused: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -644,3 +644,19 @@ func TestDNSUnknownIsNeverReported(t *testing.T) {
|
|||||||
t.Errorf("the message speaks of SPF, which was not known:\n%s", body)
|
t.Errorf("the message speaks of SPF, which was not known:\n%s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A subject opened before 2.5.4 has no record of who was told: no follow-up
|
||||||
|
// is sent, and its end goes to everyone who is to hear of it, as it did.
|
||||||
|
func TestASubjectFromBeforeTheTableEndsForEveryone(t *testing.T) {
|
||||||
|
b := newBench(t)
|
||||||
|
since := b.now.Add(-3 * time.Hour)
|
||||||
|
if err := b.st.SetNotifyState(store.NotifyState{Key: "dns:example.com", Phase: phaseOpen, Since: since, SentAt: since}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b.dns["example.com"] = badDKIM()
|
||||||
|
want(t, "still wrong", b.hour())
|
||||||
|
b.dns["example.com"] = good()
|
||||||
|
want(t, "first good check", b.hour())
|
||||||
|
cleared := "[SelfPost] example.com: DNS records match again"
|
||||||
|
want(t, "over", b.hour(), "mine@example.org|"+cleared, "root@example.org|"+cleared)
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
|
|||||||
m.setupLimiter.startSweeper()
|
m.setupLimiter.startSweeper()
|
||||||
m.loginLimiter.startSweeper()
|
m.loginLimiter.startSweeper()
|
||||||
m.passwordLimiter.startSweeper()
|
m.passwordLimiter.startSweeper()
|
||||||
|
// Made now rather than at the first unknown name, which would otherwise
|
||||||
|
// pay for two bcrypt runs (review of audit stage 4).
|
||||||
|
go noSuchUserHash()
|
||||||
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
|
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
logf("panel: full backup: %s", msg)
|
logf("panel: full backup: %s", msg)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
// The server's write deadline is for pages (cmd/panel newHTTPServer); an
|
||||||
|
// archive streams for as long as it takes, and the read-through before it
|
||||||
|
// (Check) is as long as the archive: the deadline moves first, so a
|
||||||
|
// refusal after a long Check can still be written (review of stage 4).
|
||||||
|
// Two hours carry a gigabyte at a little over 1 Mbit/s, and still end a
|
||||||
|
// client that keeps the connection and stops reading, which TCP alone
|
||||||
|
// would not (net/http, Go 1.26: ResponseController.SetWriteDeadline).
|
||||||
|
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(2 * time.Hour)); err != nil {
|
||||||
|
logf("panel: full backup: lift the write deadline: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
@@ -107,15 +118,6 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
// The server's write deadline is for pages (cmd/panel newHTTPServer); an
|
|
||||||
// archive streams for as long as it takes. Two hours carry a gigabyte at
|
|
||||||
// a little over 1 Mbit/s, and still end a client that keeps the
|
|
||||||
// connection and stops reading, which TCP alone would not (net/http, Go
|
|
||||||
// 1.26: ResponseController.SetWriteDeadline).
|
|
||||||
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(2 * time.Hour)); err != nil {
|
|
||||||
logf("panel: full backup: lift the write deadline: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything below streams: past this point headers (and possibly some
|
// Everything below streams: past this point headers (and possibly some
|
||||||
// bytes) are already on the wire, so a failure cannot switch to a clean
|
// bytes) are already on the wire, so a failure cannot switch to a clean
|
||||||
// error page. Log it and let the truncated download fail loudly on the
|
// error page. Log it and let the truncated download fail loudly on the
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ func firstNonEmpty(ss ...string) string {
|
|||||||
|
|
||||||
func inboundFlash(r *http.Request) string {
|
func inboundFlash(r *http.Request) string {
|
||||||
switch {
|
switch {
|
||||||
|
case r.URL.Query().Get("shared") != "":
|
||||||
|
return "Upstream saved. Its TLS mode is one for every domain forwarded to it, and the other domains on it were changed too."
|
||||||
case r.URL.Query().Get("saved") != "":
|
case r.URL.Query().Get("saved") != "":
|
||||||
return "Upstream saved."
|
return "Upstream saved."
|
||||||
case r.URL.Query().Get("recipients") != "":
|
case r.URL.Query().Get("recipients") != "":
|
||||||
@@ -284,12 +286,30 @@ func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request
|
|||||||
host := r.PostFormValue("host")
|
host := r.PostFormValue("host")
|
||||||
port := r.PostFormValue("port")
|
port := r.PostFormValue("port")
|
||||||
tlsMode := r.PostFormValue("tls_mode")
|
tlsMode := r.PostFormValue("tls_mode")
|
||||||
if err := h.inbound.SetTransport(d.ID, host, port, tlsMode); err != nil {
|
p, _ := h.principal(r)
|
||||||
|
changed, err := h.inbound.SetTransportAs(d.ID, host, port, tlsMode, func(o store.InboundDomain) bool {
|
||||||
|
return p.CanAccessInboundDomain(o.ID)
|
||||||
|
})
|
||||||
|
var shared *inbound.UpstreamShared
|
||||||
|
if errors.As(err, &shared) {
|
||||||
|
// The other domain is another tenant's: its mode is said, its name
|
||||||
|
// is not (security review of audit stage 4).
|
||||||
|
logf("panel: inbound: save upstream of %s: %v", d.Name, err)
|
||||||
|
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{
|
||||||
|
TransportErr: "This upstream is also used, with TLS mode " + shared.Mode + ", by an inbound domain you do not manage, and an upstream has one TLS mode: choose " + shared.Mode + ", or ask the administrator to change it for both.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{
|
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{
|
||||||
TransportErr: inboundErrorMessage(err, "upstream", "the upstream"),
|
TransportErr: inboundErrorMessage(err, "upstream", "the upstream"),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if changed > 0 {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?saved=1&shared=%d", d.ID, changed), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?saved=1", d.ID), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?saved=1", d.ID), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -227,3 +228,37 @@ func TestInboundDeletePageNamesWhatGoesWithTheDomain(t *testing.T) {
|
|||||||
has(t, "delete page, any recipient", body, `remove its upstream.`)
|
has(t, "delete page, any recipient", body, `remove its upstream.`)
|
||||||
lacks(t, "delete page, any recipient", body, `recipient`)
|
lacks(t, "delete page, any recipient", body, `recipient`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A domain user saving an upstream another tenant's inbound domain shares,
|
||||||
|
// with another TLS mode, is told the mode and not the domain; the global
|
||||||
|
// role changes the mode for both (security and technical reviews of audit
|
||||||
|
// stage 4).
|
||||||
|
func TestSharedUpstreamTLSModeForADomainUser(t *testing.T) {
|
||||||
|
h, st := inboundHandlers(t)
|
||||||
|
mine, _ := h.inbound.Add("mine.example")
|
||||||
|
theirs, _ := h.inbound.Add("theirs.example")
|
||||||
|
if err := h.inbound.SetTransport(theirs.ID, "10.0.0.8", "25", "encrypt"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p := userWith(t, st, "tenant", store.RoleDomain, store.Reach{InboundDomainIDs: []int64{mine.ID}})
|
||||||
|
form := url.Values{"host": {"10.0.0.8"}, "port": {"25"}, "tls_mode": {"may"}}
|
||||||
|
rec := inboundCall(h, http.MethodPost, fmt.Sprintf("/inbound/domains/%d/upstream", mine.ID), form, p)
|
||||||
|
body := rec.Body.String()
|
||||||
|
if rec.Code != http.StatusBadRequest || !strings.Contains(body, "with TLS mode encrypt, by an inbound domain you do not manage") {
|
||||||
|
t.Fatalf("a domain user on another's upstream = %d:\n%s", rec.Code, body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "theirs.example") {
|
||||||
|
t.Error("the refusal names the other tenant's domain")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.inbound.SetTransport(mine.ID, "10.0.0.8", "25", "encrypt"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec = inboundCall(h, http.MethodPost, fmt.Sprintf("/inbound/domains/%d/upstream", mine.ID), form, globalPrincipal)
|
||||||
|
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "shared=1") {
|
||||||
|
t.Fatalf("the global role changing a shared mode = %d %q", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
if d, _ := st.GetInboundDomain(theirs.ID); d.TLSMode != "may" {
|
||||||
|
t.Errorf("the other domain kept %s", d.TLSMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -268,9 +268,12 @@ func TestOutLogFiltersByStatusAndDay(t *testing.T) {
|
|||||||
t.Errorf("today shows %d rows, want 4", got)
|
t.Errorf("today shows %d rows, want 4", got)
|
||||||
}
|
}
|
||||||
for _, q := range []string{"from=" + tomorrow, "to=" + yesterday} {
|
for _, q := range []string{"from=" + tomorrow, "to=" + yesterday} {
|
||||||
if body := getBody(t, h.HandleDeliveries, "/outbound/log?"+q); strings.Contains(body, `">Details</a>`) {
|
body := getBody(t, h.HandleDeliveries, "/outbound/log?"+q)
|
||||||
|
if strings.Contains(body, `">Details</a>`) {
|
||||||
t.Errorf("%s shows rows from today", q)
|
t.Errorf("%s shows rows from today", q)
|
||||||
}
|
}
|
||||||
|
// Filtered to nothing is said so, not "no messages logged yet".
|
||||||
|
has(t, q, body, "No messages match these filters.")
|
||||||
}
|
}
|
||||||
body := getBody(t, h.HandleDeliveries, "/outbound/log?status=rejected&from="+today)
|
body := getBody(t, h.HandleDeliveries, "/outbound/log?status=rejected&from="+today)
|
||||||
has(t, "filtered", body, `name="from" value="`+today+`"`,
|
has(t, "filtered", body, `name="from" value="`+today+`"`,
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ func TestCardsBoxesCarryTheirNumberAsTheirOrder(t *testing.T) {
|
|||||||
t.Errorf("%s: panel.css has no rule for data-order %s", name, order)
|
t.Errorf("%s: panel.css has no rule for data-order %s", name, order)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if found == 0 {
|
if want := map[string]int{"health": 8, "kit": 3}[name]; found != want {
|
||||||
t.Errorf("%s: no numbered boxes in the cards layout", name)
|
t.Errorf("%s: %d numbered boxes found in the cards layout, want %d", name, found, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func NewOutLog(m Meta, retentionDays int, canSettings bool, domains, apps []stri
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
return &OutLog{
|
p := &OutLog{
|
||||||
Meta: m,
|
Meta: m,
|
||||||
Head: Head{Kicker: "Outbound", Title: "Outbound log", Lead: lead},
|
Head: Head{Kicker: "Outbound", Title: "Outbound log", Lead: lead},
|
||||||
Messages: Box{No: "01", Title: "Messages", Help: topicLink(HelpLog, "What the statuses mean"), Filter: &Filter{
|
Messages: Box{No: "01", Title: "Messages", Help: topicLink(HelpLog, "What the statuses mean"), Filter: &Filter{
|
||||||
@@ -137,6 +137,12 @@ func NewOutLog(m Meta, retentionDays int, canSettings bool, domains, apps []stri
|
|||||||
Foot: Foot{ID: "out-log-foot", End: Plain("New rows appear on their own")},
|
Foot: Foot{ID: "out-log-foot", End: Plain("New rows appear on their own")},
|
||||||
filter: f,
|
filter: f,
|
||||||
}
|
}
|
||||||
|
// Filtered to nothing is not an empty log (review of audit stage 4),
|
||||||
|
// as on the Inbound log.
|
||||||
|
if f != (OutLogFilter{}) {
|
||||||
|
p.Empty.Text = Plain("No messages match these filters.")
|
||||||
|
}
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetailHref is the address of one message's page, carrying the filters and the
|
// DetailHref is the address of one message's page, carrying the filters and the
|
||||||
|
|||||||
@@ -119,17 +119,26 @@
|
|||||||
// what the markup said (HTML Living Standard, the input, select and
|
// what the markup said (HTML Living Standard, the input, select and
|
||||||
// textarea elements). A select none of whose options says selected was
|
// textarea elements). A select none of whose options says selected was
|
||||||
// drawn with its first option, or none when it is a list (size or multiple).
|
// drawn with its first option, or none when it is a list (size or multiple).
|
||||||
|
// Password and file fields are not edits of the page: a password manager
|
||||||
|
// fills the first, nothing draws the second (review of stage 4).
|
||||||
function hasUnsavedEdit() {
|
function hasUnsavedEdit() {
|
||||||
var fields = document.querySelectorAll("input, select, textarea");
|
var fields = document.querySelectorAll("input, select, textarea");
|
||||||
for (var i = 0; i < fields.length; i++) {
|
for (var i = 0; i < fields.length; i++) {
|
||||||
var f = fields[i];
|
var f = fields[i];
|
||||||
if (f.disabled || f.type === "hidden" || f.type === "submit" || f.type === "button" || f.type === "reset") {
|
if (f.disabled || f.type === "hidden" || f.type === "submit" || f.type === "button" ||
|
||||||
|
f.type === "reset" || f.type === "password" || f.type === "file") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (f.type === "checkbox" || f.type === "radio") {
|
if (f.type === "checkbox" || f.type === "radio") {
|
||||||
if (f.checked !== f.defaultChecked) {
|
if (f.checked !== f.defaultChecked) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
} else if (f.tagName === "SELECT" && f.multiple) {
|
||||||
|
for (var m = 0; m < f.options.length; m++) {
|
||||||
|
if (f.options[m].selected !== f.options[m].defaultSelected) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (f.tagName === "SELECT") {
|
} else if (f.tagName === "SELECT") {
|
||||||
var drawn = -1;
|
var drawn = -1;
|
||||||
for (var j = 0; j < f.options.length; j++) {
|
for (var j = 0; j < f.options.length; j++) {
|
||||||
@@ -298,13 +307,27 @@
|
|||||||
// issueAjaxRequest(verb, path, resolveTarget(context.source), ...), and
|
// issueAjaxRequest(verb, path, resolveTarget(context.source), ...), and
|
||||||
// with no source issueAjaxRequest() falls back to the document's body).
|
// with no source issueAjaxRequest() falls back to the document's body).
|
||||||
function triggerPoll(el) {
|
function triggerPoll(el) {
|
||||||
htmx.ajax("GET", el.getAttribute("hx-get"), {
|
var done = htmx.ajax("GET", el.getAttribute("hx-get"), {
|
||||||
source: el,
|
source: el,
|
||||||
target: "#" + el.id,
|
target: "#" + el.id,
|
||||||
swap: el.getAttribute("hx-swap") || "outerHTML"
|
swap: el.getAttribute("hx-swap") || "outerHTML"
|
||||||
});
|
});
|
||||||
|
// A failed refresh is handled by htmx:afterRequest below; the promise
|
||||||
|
// htmx.ajax returns is rejected too, and is not to be left unhandled.
|
||||||
|
if (done && done.catch) {
|
||||||
|
done.catch(function () {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A request that never ends - a panel stopped mid-answer, a proxy holding
|
||||||
|
// it - would end no refresh, and no next one would be scheduled: htmx's
|
||||||
|
// default timeout is none (htmx 2.0.4, src/htmx.js: config.timeout 0, and
|
||||||
|
// issueAjaxRequest() takes requestConfig.timeout from it when neither the
|
||||||
|
// call nor hx-request says one; xhr.timeout = requestConfig.timeout). Thirty seconds is twice the
|
||||||
|
// slowest page the panel draws, one that asks DNS; then htmx:timeout,
|
||||||
|
// and htmx:afterRequest with it.
|
||||||
|
htmx.config.timeout = 30000;
|
||||||
|
|
||||||
function schedulePoll(el) {
|
function schedulePoll(el) {
|
||||||
if (!el || !el.id || !el.hasAttribute("data-poll")) {
|
if (!el || !el.id || !el.hasAttribute("data-poll")) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{{template "health_mail" .}}
|
{{template "health_mail" .}}
|
||||||
{{template "box_open" .Configuration}}
|
{{template "box_open" .Configuration}}
|
||||||
{{template "body_open"}}
|
{{template "body_open"}}
|
||||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||||
{{template "action" .Reload}}
|
{{template "action" .Reload}}
|
||||||
{{template "body_close"}}
|
{{template "body_close"}}
|
||||||
{{template "box_close"}}
|
{{template "box_close"}}
|
||||||
|
|||||||
+4
@@ -61,6 +61,7 @@ const document = {
|
|||||||
const window = { addEventListener: on("window"), confirm: () => true };
|
const window = { addEventListener: on("window"), confirm: () => true };
|
||||||
const requests = [];
|
const requests = [];
|
||||||
const htmx = {
|
const htmx = {
|
||||||
|
config: {},
|
||||||
ajax(verb, path, ctx) {
|
ajax(verb, path, ctx) {
|
||||||
requests.push({ verb, path, ctx });
|
requests.push({ verb, path, ctx });
|
||||||
},
|
},
|
||||||
@@ -79,6 +80,9 @@ vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- the cases --------------------------------------------------------------
|
// --- the cases --------------------------------------------------------------
|
||||||
|
// A request that never ends is ended: htmx's default is no timeout.
|
||||||
|
check(htmx.config.timeout === 30000, "htmx.config.timeout is " + htmx.config.timeout + ", want 30000");
|
||||||
|
|
||||||
const box = element("overview-stats", { "data-poll": "", "hx-get": "/overview/stats", "hx-trigger": "load" });
|
const box = element("overview-stats", { "data-poll": "", "hx-get": "/overview/stats", "hx-trigger": "load" });
|
||||||
inPage = { [box.id]: box };
|
inPage = { [box.id]: box };
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -30,7 +30,7 @@ const window = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), {
|
vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), {
|
||||||
document, window, htmx: { ajax() {} }, setTimeout, clearTimeout, Date, Object, Math, console,
|
document, window, htmx: { config: {}, ajax() {} }, setTimeout, clearTimeout, Date, Object, Math, console,
|
||||||
});
|
});
|
||||||
|
|
||||||
function input(type, value, defaultValue) {
|
function input(type, value, defaultValue) {
|
||||||
@@ -85,6 +85,16 @@ scenario("a box unticked", (f) => (f[2].checked = false), true);
|
|||||||
scenario("a select moved off its first option", (f) => (f[3].selectedIndex = 1), true);
|
scenario("a select moved off its first option", (f) => (f[3].selectedIndex = 1), true);
|
||||||
scenario("a select moved off its selected option", (f) => (f[4].selectedIndex = 0), true);
|
scenario("a select moved off its selected option", (f) => (f[4].selectedIndex = 0), true);
|
||||||
scenario("a disabled field differs", (f) => { f[0].value = "150"; f[0].disabled = true; }, false);
|
scenario("a disabled field differs", (f) => { f[0].value = "150"; f[0].disabled = true; }, false);
|
||||||
|
// A password manager filling a password field, a file chosen: not edits.
|
||||||
|
scenario("a password filled in", (f) => f.push(input("password", "secret", "")), false);
|
||||||
|
scenario("a file chosen", (f) => f.push(input("file", "x.json", "")), false);
|
||||||
|
// A list that takes several: any option off what was drawn.
|
||||||
|
scenario("a multiple select changed", (f) => f.push(Object.assign(select(-1, [false, false]), {
|
||||||
|
multiple: true, options: [{ selected: true, defaultSelected: false }, { selected: false, defaultSelected: false }],
|
||||||
|
})), true);
|
||||||
|
scenario("a multiple select as drawn", (f) => f.push(Object.assign(select(-1, [false, false]), {
|
||||||
|
multiple: true, options: [{ selected: true, defaultSelected: true }, { selected: false, defaultSelected: false }],
|
||||||
|
})), false);
|
||||||
|
|
||||||
// Answered yes: the submission goes on.
|
// Answered yes: the submission goes on.
|
||||||
fields = drawn();
|
fields = drawn();
|
||||||
|
|||||||
Reference in New Issue
Block a user