release: 0.3.0: PTR fix via public resolvers; monitoring URLs and nav icons

panel: the PTR (reverse DNS) check no longer reports a correctly published
Fixed:
  - panel: the PTR (reverse DNS) check no longer reports a correctly published
Changed:
  - panel: the three monitoring pages now live at URLs that match their nav
  - panel: each entry in the navigation bar now carries an icon beside its label,
  - panel: the navigation bar is laid out as two rows on purpose — the signed-in

Full detail: CHANGELOG.md (0.3.0).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
parent 981b95718e
commit af9f89b628
25 changed files with 412 additions and 84 deletions
+33
View File
@@ -5,6 +5,39 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [0.3.0] - 2026-08-03
### Fixed
- panel: the PTR (reverse DNS) check no longer reports a correctly published
record as wrong. The checks went through the container's own resolver, which
forwards to the host's systemd-resolved — and systemd-resolved answers the
reverse lookup of the machine's own IP from the local hostname instead of
asking public DNS. A server with `203.0.113.10 → selfpost.example.com` in DNS
was told its PTR pointed at the provider-assigned hostname. All four
deliverability checks (PTR, SPF, DKIM, DMARC) now query recursive resolvers
directly, so the panel reports what a receiving mail server actually sees.
Set `SELFPOST_DNS_RESOLVERS` if outbound port 53 is closed or you run your
own recursor; it defaults to 1.1.1.1, 8.8.8.8 and 9.9.9.9.
### Changed
- panel: the three monitoring pages now live at URLs that match their nav
labels — Deliveries at `/deliveries` (was `/sendlog`), Mail queue at
`/mail-queue` (was `/queue`), System log at `/system-log` (was `/logtail`).
Bookmarks to the old paths stop working.
- panel: each entry in the navigation bar now carries an icon beside its label,
so the bar is scannable at a glance instead of a row of similar-length words.
The icons are inline SVG drawn in the entry's own colour — no extra request,
no exemption from the panel's Content-Security-Policy — and are hidden from
screen readers, which still announce the label alone.
- panel: the navigation bar is laid out as two rows on purpose — the signed-in
user, Account and Sign out along the top right, the page entries below. It no
longer fits on one line and used to wrap on its own, which left the session
block sitting left-aligned under the entries as if it were more navigation.
## [0.2.0] - 2026-08-03 ## [0.2.0] - 2026-08-03
- panel: every authenticated page now ends with the running version - panel: every authenticated page now ends with the running version
+1
View File
@@ -38,6 +38,7 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
OpenDKIMSocket: cfg.opendkimSocket, OpenDKIMSocket: cfg.opendkimSocket,
JournalSocket: cfg.journalSocket, JournalSocket: cfg.journalSocket,
SessionIdleDays: cfg.sessionIdleDays, SessionIdleDays: cfg.sessionIdleDays,
DNSResolvers: cfg.dnsResolvers,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
if err != nil { if err != nil {
return err return err
+5
View File
@@ -23,6 +23,7 @@ import (
"codeberg.org/mix/selfpost/internal/backup" "codeberg.org/mix/selfpost/internal/backup"
"codeberg.org/mix/selfpost/internal/buildinfo" "codeberg.org/mix/selfpost/internal/buildinfo"
"codeberg.org/mix/selfpost/internal/dnscheck"
"codeberg.org/mix/selfpost/internal/logtail" "codeberg.org/mix/selfpost/internal/logtail"
"codeberg.org/mix/selfpost/internal/store" "codeberg.org/mix/selfpost/internal/store"
) )
@@ -61,6 +62,7 @@ type config struct {
submissionEnabled bool submissionEnabled bool
trustedProxies []*net.IPNet trustedProxies []*net.IPNet
sessionIdleDays int sessionIdleDays int
dnsResolvers []string
// Read-only inputs to the panel's status page: the certificate Postfix // Read-only inputs to the panel's status page: the certificate Postfix
// serves and the two milter sockets it connects to. The defaults mirror // serves and the two milter sockets it connects to. The defaults mirror
@@ -107,6 +109,9 @@ func loadConfig() config {
// Sliding session idle timeout (spec 7.6.6, plan B.1). Non-positive/invalid // Sliding session idle timeout (spec 7.6.6, plan B.1). Non-positive/invalid
// falls back to the 7-day default inside internal/web. // falls back to the 7-day default inside internal/web.
sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7), sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7),
// Recursive resolvers the deliverability checks query directly. Empty
// means dnscheck's public defaults; a closed network names its own here.
dnsResolvers: dnscheck.ParseResolvers(os.Getenv("SELFPOST_DNS_RESOLVERS")),
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"), tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"), opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
+9
View File
@@ -22,6 +22,15 @@ SEND_LOG_RETENTION_DAYS=90
# monitoring screens does not count as activity, only navigation/actions do. # monitoring screens does not count as activity, only navigation/actions do.
PANEL_SESSION_IDLE_DAYS=7 PANEL_SESSION_IDLE_DAYS=7
# Comma-separated resolvers the panel's deliverability checks (PTR, SPF, DKIM,
# DMARC) query directly. They deliberately skip this host's own resolver: with
# systemd-resolved the reverse lookup of the server's IP answers with the local
# hostname instead of the PTR record published in public DNS, which made the
# panel report a correct PTR as wrong. Leave unset for the public defaults
# (1.1.1.1, 8.8.8.8, 9.9.9.9); set it if outbound port 53 is closed or you run
# your own recursor, e.g. SELFPOST_DNS_RESOLVERS=10.0.0.2:53
# SELFPOST_DNS_RESOLVERS=
# Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to # Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless # supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
# you know the exact address of your reverse proxy — trusting the wrong # you know the exact address of your reverse proxy — trusting the wrong
+4
View File
@@ -41,6 +41,10 @@ services:
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}" SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
# Sliding idle timeout for the panel login session, in days (spec 7.6.6). # Sliding idle timeout for the panel login session, in days (spec 7.6.6).
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}" PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
# report what the internet sees rather than what this host's own stub
# resolver synthesises. Empty uses public defaults.
SELFPOST_DNS_RESOLVERS: "${SELFPOST_DNS_RESOLVERS:-}"
volumes: volumes:
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys, # All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
# sasldb2, sender map, setup token. Back this up (panel button or the # sasldb2, sender map, setup token. Back this up (panel button or the
+2 -2
View File
@@ -77,8 +77,8 @@
1. **Нет раздела «эксплуатация»** — прямое требование ТЗ 11 п. 7 1. **Нет раздела «эксплуатация»** — прямое требование ТЗ 11 п. 7
([specification.md:427](specification.md)). В README нет ни слова про ([specification.md:427](specification.md)). В README нет ни слова про
`/status` (проверки PTR/hostname), `/sendlog` (журнал отправки, фильтры), `/status` (проверки PTR/hostname), `/deliveries` (журнал отправки, фильтры),
`/queue` (очередь Postfix), `/logtail` (хвост `mail.log`), `/reload`, `/mail-queue` (очередь Postfix), `/system-log` (хвост `mail.log`), `/reload`,
`/account`, `/backup` — при том что всё это реализовано `/account`, `/backup` — при том что всё это реализовано
([internal/web/web.go:152](../internal/web/web.go:152)–188). Не описана и ([internal/web/web.go:152](../internal/web/web.go:152)–188). Не описана и
процедура апгрейда (бамп тега → `docker compose up -d`), хотя раздел процедура апгрейда (бамп тега → `docker compose up -d`), хотя раздел
+5 -4
View File
@@ -103,10 +103,11 @@ type cached[T any] struct {
expires time.Time expires time.Time
} }
// New returns a Checker using the process resolver and the package's default // New returns a Checker querying the given recursive resolvers (empty means
// timeout and cache lifetimes. // DefaultResolvers) with the package's default timeout and cache lifetimes.
func New() *Checker { // The lookups deliberately bypass the system resolver — see externalResolver.
return newChecker(net.DefaultResolver, lookupTimeout, serverTTL, domainTTL) func New(resolvers []string) *Checker {
return newChecker(newExternalResolver(resolvers), lookupTimeout, serverTTL, domainTTL)
} }
func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker { func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
+112
View File
@@ -0,0 +1,112 @@
package dnscheck
import (
"context"
"errors"
"net"
"strings"
)
// DefaultResolvers are the recursive resolvers the checks query when the
// deployment does not name its own (SELFPOST_DNS_RESOLVERS). Three independent
// operators, so one being unreachable from the host does not blind the checks.
var DefaultResolvers = []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"}
// externalResolver talks to a fixed list of recursive resolvers directly
// instead of going through the system resolver.
//
// That detour is deliberate: these checks answer "what does a receiving mail
// server see about us?", and the machine's own stub resolver is the one place
// where the answer differs. systemd-resolved — which a Docker container reaches
// through the embedded 127.0.0.11 forwarder — synthesises a PTR record for the
// host's own addresses out of the local hostname, never asking public DNS. A
// server whose reverse DNS was published correctly therefore had its
// provider-assigned hostname reported back to the panel, and the FCrDNS check
// failed a record that was in fact right.
//
// This bypasses the resolvers in /etc/resolv.conf, not /etc/hosts: Go still
// consults the hosts file first, as any local program would.
type externalResolver struct {
servers []*net.Resolver
}
// newExternalResolver builds a resolver over addrs ("host" or "host:port"). An
// empty list falls back to DefaultResolvers.
func newExternalResolver(addrs []string) *externalResolver {
if len(addrs) == 0 {
addrs = DefaultResolvers
}
e := &externalResolver{}
for _, a := range addrs {
addr := withDefaultPort(a)
e.servers = append(e.servers, &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
},
})
}
return e
}
func (e *externalResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
return queryEach(e.servers, func(r *net.Resolver) ([]string, error) { return r.LookupTXT(ctx, name) })
}
func (e *externalResolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
return queryEach(e.servers, func(r *net.Resolver) ([]net.IPAddr, error) { return r.LookupIPAddr(ctx, host) })
}
func (e *externalResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
return queryEach(e.servers, func(r *net.Resolver) ([]string, error) { return r.LookupAddr(ctx, addr) })
}
func (e *externalResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
return queryEach(e.servers, func(r *net.Resolver) ([]*net.MX, error) { return r.LookupMX(ctx, name) })
}
// queryEach asks each resolver in turn and stops at the first one that
// answers. "No such name" is an answer — only a resolver that cannot be
// reached, or that times out, moves the query on to the next one.
func queryEach[T any](servers []*net.Resolver, ask func(*net.Resolver) (T, error)) (T, error) {
var zero T
var lastErr error
for _, r := range servers {
v, err := ask(r)
if err == nil {
return v, nil
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
return zero, err
}
lastErr = err
}
if lastErr == nil {
lastErr = errors.New("no DNS resolver configured")
}
return zero, lastErr
}
// withDefaultPort appends the DNS port to a bare address, so the environment
// variable can name a resolver as plainly as "1.1.1.1".
func withDefaultPort(addr string) string {
if _, _, err := net.SplitHostPort(addr); err == nil {
return addr
}
return net.JoinHostPort(addr, "53")
}
// ParseResolvers reads a comma-separated resolver list, as it arrives from the
// environment. Blank entries are skipped; an empty result means "use
// DefaultResolvers".
func ParseResolvers(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
+100
View File
@@ -0,0 +1,100 @@
package dnscheck
import (
"context"
"errors"
"net"
"testing"
)
func TestWithDefaultPort(t *testing.T) {
cases := map[string]string{
"1.1.1.1": "1.1.1.1:53",
"1.1.1.1:5353": "1.1.1.1:5353",
"dns.example.com": "dns.example.com:53",
"2606:4700:4700::": "[2606:4700:4700::]:53",
"[::1]:5353": "[::1]:5353",
}
for in, want := range cases {
if got := withDefaultPort(in); got != want {
t.Errorf("withDefaultPort(%q) = %q, want %q", in, got, want)
}
}
}
func TestParseResolvers(t *testing.T) {
got := ParseResolvers(" 1.1.1.1 , ,8.8.8.8:53,")
want := []string{"1.1.1.1", "8.8.8.8:53"}
if len(got) != len(want) {
t.Fatalf("got %q, want %q", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %q, want %q", got, want)
}
}
if r := ParseResolvers(""); len(r) != 0 {
t.Errorf("empty string parsed to %q, want nothing", r)
}
}
func TestNewExternalResolverFallsBackToDefaults(t *testing.T) {
if n := len(newExternalResolver(nil).servers); n != len(DefaultResolvers) {
t.Errorf("resolver count = %d, want %d (the defaults)", n, len(DefaultResolvers))
}
}
// The checks must reach the second resolver when the first is unreachable, but
// must not second-guess an authoritative "no such name" — otherwise a domain
// that genuinely lacks a record costs one timeout per configured resolver.
func TestQueryEachTriesTheNextResolverOnlyOnFailure(t *testing.T) {
servers := []*net.Resolver{{}, {}, {}}
asked := 0
got, err := queryEach(servers, func(*net.Resolver) (string, error) {
asked++
if asked < 3 {
return "", &net.DNSError{Err: "timed out", IsTimeout: true}
}
return "answer", nil
})
if err != nil || got != "answer" {
t.Fatalf("got (%q, %v), want (\"answer\", nil)", got, err)
}
if asked != 3 {
t.Errorf("asked %d resolvers, want 3", asked)
}
asked = 0
_, err = queryEach(servers, func(*net.Resolver) (string, error) {
asked++
return "", notFound("absent.example")
})
var dnsErr *net.DNSError
if !errors.As(err, &dnsErr) || !dnsErr.IsNotFound {
t.Fatalf("err = %v, want a not-found DNSError", err)
}
if asked != 1 {
t.Errorf("NXDOMAIN asked %d resolvers, want 1 — it is an answer, not a failure", asked)
}
}
// The reason this package does not use net.DefaultResolver: systemd-resolved
// answers PTR queries for the machine's own addresses out of the local
// hostname, which is not what the rest of the internet sees. The dial hook must
// therefore ignore the address the standard resolver picked from
// /etc/resolv.conf and connect to the configured one.
func TestExternalResolverDialsOnlyTheConfiguredAddress(t *testing.T) {
e := newExternalResolver([]string{"192.0.2.53"})
if len(e.servers) != 1 {
t.Fatalf("servers = %d, want 1", len(e.servers))
}
conn, err := e.servers[0].Dial(context.Background(), "udp", "127.0.0.53:53")
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
if got := conn.RemoteAddr().String(); got != "192.0.2.53:53" {
t.Errorf("connected to %q, want 192.0.2.53:53 (the system resolver won)", got)
}
}
+27 -26
View File
@@ -19,33 +19,34 @@ const (
logTailLines = 200 logTailLines = 200
) )
// handleSendLog renders the send-log monitoring page: server-side filters by // handleDeliveries renders the Deliveries page over the send log: server-side
// domain/application and pagination (spec 7.3.3). The row table itself is the // filters by domain/application and pagination (spec 7.3.3). The row table
// "sendlog_rows" fragment, shared verbatim with handleSendLogRows so the // itself is the "deliveries_rows" fragment, shared verbatim with
// initial page and its HTMX-polled refreshes never diverge. // handleDeliveriesRows so the initial page and its HTMX-polled refreshes never
func (s *Server) handleSendLog(w http.ResponseWriter, r *http.Request) { // diverge.
func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r) data, err := s.sendLogData(r)
if err != nil { if err != nil {
logf("panel: send log: %v", err) logf("panel: send log: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
data["Title"] = "SelfPost — send log" data["Title"] = "SelfPost — deliveries"
data["User"] = currentUser(r) data["User"] = currentUser(r)
data["Active"] = "sendlog" data["Active"] = "deliveries"
s.render(w, http.StatusOK, "sendlog", data) s.render(w, http.StatusOK, "deliveries", data)
} }
// handleSendLogRows serves the HTMX polling fragment for the send-log table // handleDeliveriesRows serves the HTMX polling fragment for the delivery table
// (spec 7.1: fragment endpoints return HTML, not JSON). // (spec 7.1: fragment endpoints return HTML, not JSON).
func (s *Server) handleSendLogRows(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r) data, err := s.sendLogData(r)
if err != nil { if err != nil {
logf("panel: send log rows: %v", err) logf("panel: send log rows: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
s.renderFragment(w, http.StatusOK, "sendlog_rows", data) s.renderFragment(w, http.StatusOK, "deliveries_rows", data)
} }
// sendLogData reads the domain/app filters and page number off the query // sendLogData reads the domain/app filters and page number off the query
@@ -109,22 +110,22 @@ func parsePage(v string) int {
return n return n
} }
// handleQueue renders the mail-queue monitoring page (spec 7.2.11). // handleMailQueue renders the Mail queue page (spec 7.2.11).
func (s *Server) handleQueue(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) {
out, errText := readQueue() out, errText := readQueue()
s.render(w, http.StatusOK, "queue", map[string]any{ s.render(w, http.StatusOK, "mail_queue", map[string]any{
"Title": "SelfPost — mail queue", "Title": "SelfPost — mail queue",
"User": currentUser(r), "User": currentUser(r),
"Active": "queue", "Active": "mail_queue",
"Output": out, "Output": out,
"Error": errText, "Error": errText,
}) })
} }
// handleQueueBody serves the HTMX polling fragment for the queue view. // handleMailQueueBody serves the HTMX polling fragment for the queue view.
func (s *Server) handleQueueBody(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMailQueueBody(w http.ResponseWriter, r *http.Request) {
out, errText := readQueue() out, errText := readQueue()
s.renderFragment(w, http.StatusOK, "queue_body", map[string]any{ s.renderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
"Output": out, "Output": out,
"Error": errText, "Error": errText,
}) })
@@ -142,22 +143,22 @@ func readQueue() (string, string) {
return out, "" return out, ""
} }
// handleLogTail renders the mail.log monitoring page (spec 7.2.13). // handleSystemLog renders the System log page over mail.log (spec 7.2.13).
func (s *Server) handleLogTail(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail() lines, errText := s.readLogTail()
s.render(w, http.StatusOK, "logtail", map[string]any{ s.render(w, http.StatusOK, "system_log", map[string]any{
"Title": "SelfPost — mail log", "Title": "SelfPost — system log",
"User": currentUser(r), "User": currentUser(r),
"Active": "logtail", "Active": "system_log",
"Lines": lines, "Lines": lines,
"Error": errText, "Error": errText,
}) })
} }
// handleLogTailBody serves the HTMX polling fragment for the log-tail view. // handleSystemLogBody serves the HTMX polling fragment for the log-tail view.
func (s *Server) handleLogTailBody(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSystemLogBody(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail() lines, errText := s.readLogTail()
s.renderFragment(w, http.StatusOK, "logtail_body", map[string]any{ s.renderFragment(w, http.StatusOK, "system_log_body", map[string]any{
"Lines": lines, "Lines": lines,
"Error": errText, "Error": errText,
}) })
+2 -1
View File
@@ -15,7 +15,8 @@ const usernameKey ctxKey = 0
// //
// It also extends the sliding session (plan B.1) on activity, defined as // It also extends the sliding session (plan B.1) on activity, defined as
// everything except a GET request carrying HX-Request: the four monitoring // everything except a GET request carrying HX-Request: the four monitoring
// fragments (/status/fragment, /queue/body, /logtail/body, /sendlog/rows) // fragments (/status/fragment, /mail-queue/body, /system-log/body,
// /deliveries/rows)
// poll every 5s regardless of whether anyone is looking at the tab, so // poll every 5s regardless of whether anyone is looking at the tab, so
// counting those as activity would make "N days idle" mean "N days since a // counting those as activity would make "N days idle" mean "N days since a
// browser tab was last open" instead. // browser tab was last open" instead.
+34 -6
View File
@@ -88,20 +88,48 @@ details form { margin-top: 0.6rem; }
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } } @media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
/* Panel navigation: rendered once from the layout, so it is present on every /* Panel navigation: rendered once from the layout, so it is present on every
authenticated page without each content template having to include it. */ authenticated page without each content template having to include it. */
/* Two deliberate rows, not one. The six page entries need about 660px and the
session block another 260px, against the 738px the panel is wide — so the bar
was wrapping on its own, and the session landed left-aligned directly under
the entries, reading as a third row of navigation. Shortening labels would buy
the ~200px back but undo the point of naming each entry after its page. Rows
rather than columns: a two-column grid of entries came out a third taller
(107px against 83px) and no easier to read.
The session sits on top, at the right edge, and the page entries below — the
usual arrangement, and the layout template puts the session first so the
reading and tab order follow what the eye sees rather than being flipped by a
CSS `order`. The cost is that Sign out is the bar's first tab stop; nothing
activates on focus, so that is a reordering, not a hazard. */
.nav { .nav {
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; display: flex; flex-direction: column; align-items: stretch;
gap: 0.4rem 1rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem; gap: 0.5rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
border-bottom: 1px solid #e2e5e9; border-bottom: 1px solid #e2e5e9;
} }
@media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } } @media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } }
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: baseline; } .nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; }
.nav .links a, .nav .links [aria-current] { padding: 0.2rem 0.5rem; border-radius: 6px; } /* Pushed to the far edge so the two rows read as two blocks rather than as one
.nav .links [aria-current] { ragged list that happened to wrap. */
.nav .session { justify-content: flex-end; }
/* Each entry pairs an icon with its label, so the entry itself is a flex row
rather than a run of text — that is also why the bar centres its items
instead of aligning them on the text baseline. Account is included: it is a
page like the others and would otherwise be the one bare word in the bar. */
.nav a, .nav [aria-current] {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.2rem 0.5rem; border-radius: 6px;
}
.nav [aria-current] {
font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb; font-weight: 600; color: #1b1f24; background: #e6ebf5; box-shadow: inset 0 -2px 0 #2563eb;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; } .nav [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
} }
/* Sign out carries an icon too, so it needs the same row layout; its padding
and colours come from the compact button rule further down. */
.nav button { display: inline-flex; align-items: center; gap: 0.4rem; }
/* The icons draw in the entry's own colour, so the active entry's darker text,
a link's blue and Sign out's red all carry through without a rule apiece. */
.nav .icon { width: 1rem; height: 1rem; flex: none; }
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server /* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
status page and the per-domain DNS checks, so a colour means the same thing status page and the per-domain DNS checks, so a colour means the same thing
everywhere. The class suffix is the check's own status value. */ everywhere. The class suffix is the check's own status value. */
+7 -7
View File
@@ -30,19 +30,19 @@ var pageFiles = map[string][]string{
"backup": {"templates/backup.html"}, "backup": {"templates/backup.html"},
"domain_detail": {"templates/domain_detail.html"}, "domain_detail": {"templates/domain_detail.html"},
"domain_delete": {"templates/domain_delete.html"}, "domain_delete": {"templates/domain_delete.html"},
"sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"}, "deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"queue": {"templates/queue.html", "templates/queue_body.html"}, "mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
"logtail": {"templates/logtail.html", "templates/logtail_body.html"}, "system_log": {"templates/system_log.html", "templates/system_log_body.html"},
"status": {"templates/status.html", "templates/status_body.html"}, "status": {"templates/status.html", "templates/status_body.html"},
} }
// fragmentFiles maps a fragment name (also its {{define}} block name) to its // fragmentFiles maps a fragment name (also its {{define}} block name) to its
// template file, for standalone rendering by the HTMX polling endpoints. // template file, for standalone rendering by the HTMX polling endpoints.
var fragmentFiles = map[string]string{ var fragmentFiles = map[string]string{
"sendlog_rows": "templates/sendlog_rows.html", "deliveries_rows": "templates/deliveries_rows.html",
"queue_body": "templates/queue_body.html", "mail_queue_body": "templates/mail_queue_body.html",
"logtail_body": "templates/logtail_body.html", "system_log_body": "templates/system_log_body.html",
"status_body": "templates/status_body.html", "status_body": "templates/status_body.html",
} }
func loadTemplates() (*templates, error) { func loadTemplates() (*templates, error) {
@@ -3,7 +3,7 @@
<div class="card"> <div class="card">
<h2>Filter</h2> <h2>Filter</h2>
<form method="get" action="/sendlog"> <form method="get" action="/deliveries">
<label for="domain">Domain</label> <label for="domain">Domain</label>
<select id="domain" name="domain"> <select id="domain" name="domain">
<option value="">All domains</option> <option value="">All domains</option>
@@ -19,6 +19,6 @@
</div> </div>
<div class="card"> <div class="card">
{{template "sendlog_rows" .}} {{template "deliveries_rows" .}}
</div> </div>
{{end}} {{end}}
@@ -1,5 +1,5 @@
{{define "sendlog_rows"}} {{define "deliveries_rows"}}
<div id="sendlog-rows" hx-get="/sendlog/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}" <div id="deliveries-rows" hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
hx-trigger="every 5s" hx-swap="outerHTML"> hx-trigger="every 5s" hx-swap="outerHTML">
{{if .Rows}} {{if .Rows}}
<table> <table>
@@ -22,8 +22,8 @@
</table> </table>
<p class="muted"> <p class="muted">
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}} Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
{{if .HasPrev}} &middot; <a href="/sendlog?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">&larr; Newer</a>{{end}} {{if .HasPrev}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">&larr; Newer</a>{{end}}
{{if .HasNext}} &middot; <a href="/sendlog?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older &rarr;</a>{{end}} {{if .HasNext}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older &rarr;</a>{{end}}
</p> </p>
{{else}} {{else}}
<p class="muted">No messages logged yet.</p> <p class="muted">No messages logged yet.</p>
+32 -10
View File
@@ -35,20 +35,42 @@
.User and get no nav. */}} .User and get no nav. */}}
{{define "nav"}} {{define "nav"}}
<nav class="nav"> <nav class="nav">
<div class="links"> {{/* The session block is first here because it is drawn first: it is the top
{{if eq .Active "status"}}<span aria-current="page">Status</span>{{else}}<a href="/status">Status</a>{{end}} row of the bar (see .nav in panel.css), and putting it first in the
{{if eq .Active "domains"}}<span aria-current="page">Domains</span>{{else}}<a href="/domains">Domains</a>{{end}} document keeps the tab order matching the visual one. */}}
{{if eq .Active "sendlog"}}<span aria-current="page">Deliveries</span>{{else}}<a href="/sendlog">Deliveries</a>{{end}}
{{if eq .Active "queue"}}<span aria-current="page">Mail queue</span>{{else}}<a href="/queue">Mail queue</a>{{end}}
{{if eq .Active "logtail"}}<span aria-current="page">System log</span>{{else}}<a href="/logtail">System log</a>{{end}}
{{if eq .Active "backup"}}<span aria-current="page">Backup</span>{{else}}<a href="/backup">Backup</a>{{end}}
</div>
<div class="session"> <div class="session">
<span class="muted">{{.User}}</span> <span class="muted">{{.User}}</span>
{{if eq .Active "account"}}<span aria-current="page">Account</span>{{else}}<a href="/account">Account</a>{{end}} {{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
<form class="inline" method="post" action="/logout"> <form class="inline" method="post" action="/logout">
<button type="submit" class="danger">Sign out</button> <button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form> </form>
</div> </div>
<div class="links">
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
</div>
</nav> </nav>
{{end}} {{end}}
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
inherit the link's colour through currentColor, cost no extra request, and
need no exemption from the panel's "default-src 'self'" policy. Each is
aria-hidden because the entry's own text is already the accessible name;
the icon is a landmark for the eye, not a second label. Kept as separate
templates so the nav above stays one readable line per page. */}}
{{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
{{/* A filing cabinet rather than a storage box: the box read as a box, while
two drawers with handles is the picture of an archive. */}}
{{define "icon-backup"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></svg>{{end}}
{{define "icon-account"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></svg>{{end}}
{{/* Sign out is the one icon here that marks an action rather than a page: an
open door with the arrow leading out of it. */}}
{{define "icon-sign-out"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></svg>{{end}}
@@ -3,6 +3,6 @@
<div class="card"> <div class="card">
<h2>Pending messages</h2> <h2>Pending messages</h2>
{{template "queue_body" .}} {{template "mail_queue_body" .}}
</div> </div>
{{end}} {{end}}
@@ -1,5 +1,5 @@
{{define "queue_body"}} {{define "mail_queue_body"}}
<div id="queue-body" hx-get="/queue/body" hx-trigger="every 5s" hx-swap="outerHTML"> <div id="mail-queue-body" hx-get="/mail-queue/body" hx-trigger="every 5s" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}} {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span> <span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
</div> </div>
+1 -1
View File
@@ -31,7 +31,7 @@
<p class="error">{{.QueueError}}</p> <p class="error">{{.QueueError}}</p>
{{else}} {{else}}
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p> <p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
<a class="btn" href="/queue">Full queue</a> <a class="btn" href="/mail-queue">Full queue</a>
{{end}} {{end}}
</div> </div>
@@ -1,8 +1,8 @@
{{define "content"}} {{define "content"}}
<h1>Mail log</h1> <h1>System log</h1>
<div class="card"> <div class="card">
<h2>Recent log entries</h2> <h2>Recent log entries</h2>
{{template "logtail_body" .}} {{template "system_log_body" .}}
</div> </div>
{{end}} {{end}}
@@ -1,5 +1,5 @@
{{define "logtail_body"}} {{define "system_log_body"}}
<div id="logtail-body" hx-get="/logtail/body" hx-trigger="every 5s" hx-swap="outerHTML"> <div id="system-log-body" hx-get="/system-log/body" hx-trigger="every 5s" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}} {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Lines}}{{range .Lines}}{{.}} <span class="code">{{if .Lines}}{{range .Lines}}{{.}}
{{end}}{{else}}No log lines yet.{{end}}</span> {{end}}{{else}}No log lines yet.{{end}}</span>
+7 -5
View File
@@ -99,19 +99,21 @@ func TestNavMarksActivePage(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{ err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin", "User": "admin",
"Active": "queue", "Active": "mail_queue",
}) })
if err != nil { if err != nil {
t.Fatalf("execute nav: %v", err) t.Fatalf("execute nav: %v", err)
} }
out := buf.String() out := buf.String()
if !strings.Contains(out, `<span aria-current="page">Mail queue</span>`) { // The label is checked apart from the opening tag because each entry now
// carries an icon between the two.
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Mail queue</span>`) {
t.Errorf("active page is not marked:\n%s", out) t.Errorf("active page is not marked:\n%s", out)
} }
if strings.Contains(out, `href="/queue"`) { if strings.Contains(out, `href="/mail-queue"`) {
t.Errorf("active page still links to itself:\n%s", out) t.Errorf("active page still links to itself:\n%s", out)
} }
if !strings.Contains(out, `href="/sendlog"`) { if !strings.Contains(out, `href="/deliveries"`) {
t.Errorf("inactive pages are not linked:\n%s", out) t.Errorf("inactive pages are not linked:\n%s", out)
} }
} }
@@ -129,7 +131,7 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
t.Fatalf("execute nav: %v", err) t.Fatalf("execute nav: %v", err)
} }
out := buf.String() out := buf.String()
if !strings.Contains(out, `<span aria-current="page">Status</span>`) { if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Status</span>`) {
t.Errorf("the status page is not marked active:\n%s", out) t.Errorf("the status page is not marked active:\n%s", out)
} }
if !strings.Contains(out, `href="/domains"`) { if !strings.Contains(out, `href="/domains"`) {
+12 -7
View File
@@ -63,6 +63,11 @@ type Config struct {
// session expires (env PANEL_SESSION_IDLE_DAYS, plan B.1). Non-positive // session expires (env PANEL_SESSION_IDLE_DAYS, plan B.1). Non-positive
// falls back to the 7-day default. // falls back to the 7-day default.
SessionIdleDays int SessionIdleDays int
// DNSResolvers are the recursive resolvers the deliverability checks query
// (env SELFPOST_DNS_RESOLVERS). Empty uses dnscheck.DefaultResolvers. The
// checks must not go through the system resolver — see dnscheck's
// externalResolver — so this is how a closed network points them at its own.
DNSResolvers []string
} }
// Server is the panel HTTP application. // Server is the panel HTTP application.
@@ -105,7 +110,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
// Published-DNS checks for the status page and the domain pages. The // Published-DNS checks for the status page and the domain pages. The
// checker caches its own results, so page views do not each pay for a // checker caches its own results, so page views do not each pay for a
// round of lookups (phase 13). // round of lookups (phase 13).
dns: dnscheck.New(), dns: dnscheck.New(cfg.DNSResolvers),
// Setup: a handful of attempts per minute per IP is plenty for a // Setup: a handful of attempts per minute per IP is plenty for a
// legitimate admin and blunts automated probing (spec 7.6.1). // legitimate admin and blunts automated probing (spec 7.6.1).
setupLimiter: newRateLimiter(10, time.Minute), setupLimiter: newRateLimiter(10, time.Minute),
@@ -180,12 +185,12 @@ func (s *Server) Handler() http.Handler {
// Monitoring screens (spec 7.2.11-13): each page and its HTMX polling // Monitoring screens (spec 7.2.11-13): each page and its HTMX polling
// fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON). // fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON).
authed.HandleFunc("GET /sendlog", s.handleSendLog) authed.HandleFunc("GET /deliveries", s.handleDeliveries)
authed.HandleFunc("GET /sendlog/rows", s.handleSendLogRows) authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows)
authed.HandleFunc("GET /queue", s.handleQueue) authed.HandleFunc("GET /mail-queue", s.handleMailQueue)
authed.HandleFunc("GET /queue/body", s.handleQueueBody) authed.HandleFunc("GET /mail-queue/body", s.handleMailQueueBody)
authed.HandleFunc("GET /logtail", s.handleLogTail) authed.HandleFunc("GET /system-log", s.handleSystemLog)
authed.HandleFunc("GET /logtail/body", s.handleLogTailBody) authed.HandleFunc("GET /system-log/body", s.handleSystemLogBody)
mux.Handle("/", s.requireAuth(authed)) mux.Handle("/", s.requireAuth(authed))
+4
View File
@@ -30,6 +30,10 @@ services:
SELFPOST_HOSTNAME: mail.e2e.test SELFPOST_HOSTNAME: mail.e2e.test
# Plain HTTP panel for the test client — no reverse proxy in this stand. # Plain HTTP panel for the test client — no reverse proxy in this stand.
PANEL_COOKIE_SECURE: "false" PANEL_COOKIE_SECURE: "false"
# The panel's deliverability checks query resolvers directly rather than
# the container's own (see internal/dnscheck), so the fake zone has to be
# named here too — `dns:` below only redirects the system resolver.
SELFPOST_DNS_RESOLVERS: 10.77.0.10:53
# High enough that the whole suite's other ~10 sends (positive path, # High enough that the whole suite's other ~10 sends (positive path,
# level-2 test, the various rejected attempts) never trip it by # level-2 test, the various rejected attempts) never trip it by
# accident — only the dedicated level-1 test (negative check 4, which # accident — only the dedicated level-1 test (negative check 4, which
+2 -2
View File
@@ -187,10 +187,10 @@ func (c *panelClient) setRateLimit(path, allowedIP string, maxMessages, windowSe
return nil return nil
} }
// sendLogRows returns the raw /sendlog/rows HTML fragment, filtered to one // sendLogRows returns the raw /deliveries/rows HTML fragment, filtered to one
// domain, for polling a row's status without parsing full HTML into structs. // domain, for polling a row's status without parsing full HTML into structs.
func (c *panelClient) sendLogRows(domain string) (string, error) { func (c *panelClient) sendLogRows(domain string) (string, error) {
_, body, err := c.get("/sendlog/rows?domain=" + url.QueryEscape(domain)) _, body, err := c.get("/deliveries/rows?domain=" + url.QueryEscape(domain))
return body, err return body, err
} }