- 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:
@@ -197,7 +197,10 @@ func readEncodedBody(hdr textproto.MIMEHeader, r io.Reader) ([]byte, error) {
|
||||
case "quoted-printable":
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -92,6 +92,24 @@ func (o *OpenDKIM) RemoveKey(domainName string) error {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -267,8 +267,35 @@ func (s *Service) ClearRateLimit(domainID int64) error {
|
||||
// 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
|
||||
// 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 {
|
||||
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.
|
||||
|
||||
+53
-26
@@ -5,6 +5,7 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
@@ -54,51 +55,77 @@ func (s *Service) Add(name string) (store.InboundDomain, error) {
|
||||
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 {
|
||||
_, 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 {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
host = normalizeHost(host)
|
||||
if err := checkHost(host); err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
port, err := parsePort(portRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
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()
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
var others []store.InboundDomain
|
||||
for _, d := range domains {
|
||||
if d.ID == id || d.Port != port || normalizeHost(d.Host) != host || d.TLSMode == tlsMode {
|
||||
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",
|
||||
host, port, d.Name, d.TLSMode, d.TLSMode, d.Name)
|
||||
if !mayChange(d) {
|
||||
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
|
||||
|
||||
@@ -2,7 +2,6 @@ package inbound
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
@@ -14,9 +13,11 @@ type nullMaps struct{}
|
||||
func (nullMaps) RebuildInboundMaps([]postfix.InboundRoute) error { return nil }
|
||||
|
||||
// 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
|
||||
// refused with the first domain's name and mode; the same mode is fine
|
||||
// (audit 2026-10-12, L5).
|
||||
// hop (audit 2026-10-12, L5): the other domains forwarded to it take the mode
|
||||
// saved for one of them, when the one saving may change them; one they may
|
||||
// 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) {
|
||||
st, err := store.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
@@ -24,30 +25,44 @@ func TestOneTLSModePerUpstream(t *testing.T) {
|
||||
}
|
||||
defer st.Close()
|
||||
s := NewService(st, nullMaps{})
|
||||
a, err := s.Add("a.example")
|
||||
if err != nil {
|
||||
a, _ := s.Add("a.example")
|
||||
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)
|
||||
}
|
||||
b, err := s.Add("b.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if n, err := s.SetTransportAs(b.ID, "[10.0.0.8]", "25", "encrypt", all); err != nil || n != 0 {
|
||||
t.Fatalf("the same mode: %d, %v", n, err)
|
||||
}
|
||||
if err := s.SetTransport(a.ID, "10.0.0.8", "25", "encrypt"); err != nil {
|
||||
t.Fatal(err)
|
||||
// Either domain changes the mode, for both.
|
||||
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")
|
||||
var verr *ValidationError
|
||||
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 mode(a.ID) != "may" || mode(b.ID) != "may" {
|
||||
t.Errorf("modes after a's change: %s, %s", mode(a.ID), mode(b.ID))
|
||||
}
|
||||
if err := s.SetTransport(b.ID, "10.0.0.8", "25", "encrypt"); err != nil {
|
||||
t.Errorf("the same mode refused: %v", err)
|
||||
// Another port is another next hop.
|
||||
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 err := s.SetTransport(a.ID, "10.0.0.8", "25", "may"); err != nil {
|
||||
t.Errorf("a domain changing its own mode refused: %v", err)
|
||||
if d, _ := st.GetInboundDomain(c.ID); d.Port != 2525 || mode(a.ID) != "may" {
|
||||
t.Errorf("the refused save changed something: c on %d, a %s", d.Port, mode(a.ID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,3 +644,19 @@ func TestDNSUnknownIsNeverReported(t *testing.T) {
|
||||
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.loginLimiter.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)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -79,6 +79,17 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
logf("panel: full backup: %v", 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("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
|
||||
// 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
|
||||
|
||||
@@ -250,6 +250,8 @@ func firstNonEmpty(ss ...string) string {
|
||||
|
||||
func inboundFlash(r *http.Request) string {
|
||||
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") != "":
|
||||
return "Upstream saved."
|
||||
case r.URL.Query().Get("recipients") != "":
|
||||
@@ -284,12 +286,30 @@ func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request
|
||||
host := r.PostFormValue("host")
|
||||
port := r.PostFormValue("port")
|
||||
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{
|
||||
TransportErr: inboundErrorMessage(err, "upstream", "the upstream"),
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -227,3 +228,37 @@ func TestInboundDeletePageNamesWhatGoesWithTheDomain(t *testing.T) {
|
||||
has(t, "delete page, any recipient", body, `remove its upstream.`)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Errorf("%s: no numbered boxes in the cards layout", name)
|
||||
if want := map[string]int{"health": 8, "kit": 3}[name]; found != want {
|
||||
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 &OutLog{
|
||||
p := &OutLog{
|
||||
Meta: m,
|
||||
Head: Head{Kicker: "Outbound", Title: "Outbound log", Lead: lead},
|
||||
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")},
|
||||
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
|
||||
|
||||
@@ -119,17 +119,26 @@
|
||||
// what the markup said (HTML Living Standard, the input, select and
|
||||
// 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).
|
||||
// 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() {
|
||||
var fields = document.querySelectorAll("input, select, textarea");
|
||||
for (var i = 0; i < fields.length; 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;
|
||||
}
|
||||
if (f.type === "checkbox" || f.type === "radio") {
|
||||
if (f.checked !== f.defaultChecked) {
|
||||
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") {
|
||||
var drawn = -1;
|
||||
for (var j = 0; j < f.options.length; j++) {
|
||||
@@ -298,13 +307,27 @@
|
||||
// issueAjaxRequest(verb, path, resolveTarget(context.source), ...), and
|
||||
// with no source issueAjaxRequest() falls back to the document's body).
|
||||
function triggerPoll(el) {
|
||||
htmx.ajax("GET", el.getAttribute("hx-get"), {
|
||||
var done = htmx.ajax("GET", el.getAttribute("hx-get"), {
|
||||
source: el,
|
||||
target: "#" + el.id,
|
||||
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) {
|
||||
if (!el || !el.id || !el.hasAttribute("data-poll")) {
|
||||
return;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{{template "health_mail" .}}
|
||||
{{template "box_open" .Configuration}}
|
||||
{{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 "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
+4
@@ -61,6 +61,7 @@ const document = {
|
||||
const window = { addEventListener: on("window"), confirm: () => true };
|
||||
const requests = [];
|
||||
const htmx = {
|
||||
config: {},
|
||||
ajax(verb, path, ctx) {
|
||||
requests.push({ verb, path, ctx });
|
||||
},
|
||||
@@ -79,6 +80,9 @@ vm.runInNewContext(fs.readFileSync(process.argv[2], "utf8"), {
|
||||
});
|
||||
|
||||
// --- 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" });
|
||||
inPage = { [box.id]: box };
|
||||
|
||||
|
||||
+11
-1
@@ -30,7 +30,7 @@ const window = {
|
||||
},
|
||||
};
|
||||
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) {
|
||||
@@ -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 selected option", (f) => (f[4].selectedIndex = 0), true);
|
||||
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.
|
||||
fields = drawn();
|
||||
|
||||
Reference in New Issue
Block a user