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]
## [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
- 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,
JournalSocket: cfg.journalSocket,
SessionIdleDays: cfg.sessionIdleDays,
DNSResolvers: cfg.dnsResolvers,
}, cfg.setupTokenPath)
if err != nil {
return err
+5
View File
@@ -23,6 +23,7 @@ import (
"codeberg.org/mix/selfpost/internal/backup"
"codeberg.org/mix/selfpost/internal/buildinfo"
"codeberg.org/mix/selfpost/internal/dnscheck"
"codeberg.org/mix/selfpost/internal/logtail"
"codeberg.org/mix/selfpost/internal/store"
)
@@ -61,6 +62,7 @@ type config struct {
submissionEnabled bool
trustedProxies []*net.IPNet
sessionIdleDays int
dnsResolvers []string
// Read-only inputs to the panel's status page: the certificate Postfix
// 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
// falls back to the 7-day default inside internal/web.
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"),
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.
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
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
# 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}"
# Sliding idle timeout for the panel login session, in days (spec 7.6.6).
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:
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
# sasldb2, sender map, setup token. Back this up (panel button or the
+2 -2
View File
@@ -77,8 +77,8 @@
1. **Нет раздела «эксплуатация»** — прямое требование ТЗ 11 п. 7
([specification.md:427](specification.md)). В README нет ни слова про
`/status` (проверки PTR/hostname), `/sendlog` (журнал отправки, фильтры),
`/queue` (очередь Postfix), `/logtail` (хвост `mail.log`), `/reload`,
`/status` (проверки PTR/hostname), `/deliveries` (журнал отправки, фильтры),
`/mail-queue` (очередь Postfix), `/system-log` (хвост `mail.log`), `/reload`,
`/account`, `/backup` — при том что всё это реализовано
([internal/web/web.go:152](../internal/web/web.go:152)–188). Не описана и
процедура апгрейда (бамп тега → `docker compose up -d`), хотя раздел
+5 -4
View File
@@ -103,10 +103,11 @@ type cached[T any] struct {
expires time.Time
}
// New returns a Checker using the process resolver and the package's default
// timeout and cache lifetimes.
func New() *Checker {
return newChecker(net.DefaultResolver, lookupTimeout, serverTTL, domainTTL)
// New returns a Checker querying the given recursive resolvers (empty means
// DefaultResolvers) with the package's default timeout and cache lifetimes.
// The lookups deliberately bypass the system resolver — see externalResolver.
func New(resolvers []string) *Checker {
return newChecker(newExternalResolver(resolvers), lookupTimeout, serverTTL, domainTTL)
}
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
)
// handleSendLog renders the send-log monitoring page: server-side filters by
// domain/application and pagination (spec 7.3.3). The row table itself is the
// "sendlog_rows" fragment, shared verbatim with handleSendLogRows so the
// initial page and its HTMX-polled refreshes never diverge.
func (s *Server) handleSendLog(w http.ResponseWriter, r *http.Request) {
// handleDeliveries renders the Deliveries page over the send log: server-side
// filters by domain/application and pagination (spec 7.3.3). The row table
// itself is the "deliveries_rows" fragment, shared verbatim with
// handleDeliveriesRows so the initial page and its HTMX-polled refreshes never
// diverge.
func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
if err != nil {
logf("panel: send log: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data["Title"] = "SelfPost — send log"
data["Title"] = "SelfPost — deliveries"
data["User"] = currentUser(r)
data["Active"] = "sendlog"
s.render(w, http.StatusOK, "sendlog", data)
data["Active"] = "deliveries"
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).
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)
if err != nil {
logf("panel: send log rows: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
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
@@ -109,22 +110,22 @@ func parsePage(v string) int {
return n
}
// handleQueue renders the mail-queue monitoring page (spec 7.2.11).
func (s *Server) handleQueue(w http.ResponseWriter, r *http.Request) {
// handleMailQueue renders the Mail queue page (spec 7.2.11).
func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) {
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",
"User": currentUser(r),
"Active": "queue",
"Active": "mail_queue",
"Output": out,
"Error": errText,
})
}
// handleQueueBody serves the HTMX polling fragment for the queue view.
func (s *Server) handleQueueBody(w http.ResponseWriter, r *http.Request) {
// handleMailQueueBody serves the HTMX polling fragment for the queue view.
func (s *Server) handleMailQueueBody(w http.ResponseWriter, r *http.Request) {
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,
"Error": errText,
})
@@ -142,22 +143,22 @@ func readQueue() (string, string) {
return out, ""
}
// handleLogTail renders the mail.log monitoring page (spec 7.2.13).
func (s *Server) handleLogTail(w http.ResponseWriter, r *http.Request) {
// handleSystemLog renders the System log page over mail.log (spec 7.2.13).
func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail()
s.render(w, http.StatusOK, "logtail", map[string]any{
"Title": "SelfPost — mail log",
s.render(w, http.StatusOK, "system_log", map[string]any{
"Title": "SelfPost — system log",
"User": currentUser(r),
"Active": "logtail",
"Active": "system_log",
"Lines": lines,
"Error": errText,
})
}
// handleLogTailBody serves the HTMX polling fragment for the log-tail view.
func (s *Server) handleLogTailBody(w http.ResponseWriter, r *http.Request) {
// handleSystemLogBody serves the HTMX polling fragment for the log-tail view.
func (s *Server) handleSystemLogBody(w http.ResponseWriter, r *http.Request) {
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,
"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
// 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
// counting those as activity would make "N days idle" mean "N days since a
// 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; } }
/* Panel navigation: rendered once from the layout, so it is present on every
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 {
display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline;
gap: 0.4rem 1rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
display: flex; flex-direction: column; align-items: stretch;
gap: 0.5rem; margin-bottom: 1.2rem; padding-bottom: 0.6rem;
border-bottom: 1px solid #e2e5e9;
}
@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 a, .nav .links [aria-current] { padding: 0.2rem 0.5rem; border-radius: 6px; }
.nav .links [aria-current] {
.nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; }
/* Pushed to the far edge so the two rows read as two blocks rather than as one
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;
}
@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 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. */
+6 -6
View File
@@ -30,18 +30,18 @@ var pageFiles = map[string][]string{
"backup": {"templates/backup.html"},
"domain_detail": {"templates/domain_detail.html"},
"domain_delete": {"templates/domain_delete.html"},
"sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"},
"queue": {"templates/queue.html", "templates/queue_body.html"},
"logtail": {"templates/logtail.html", "templates/logtail_body.html"},
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
"status": {"templates/status.html", "templates/status_body.html"},
}
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
// template file, for standalone rendering by the HTMX polling endpoints.
var fragmentFiles = map[string]string{
"sendlog_rows": "templates/sendlog_rows.html",
"queue_body": "templates/queue_body.html",
"logtail_body": "templates/logtail_body.html",
"deliveries_rows": "templates/deliveries_rows.html",
"mail_queue_body": "templates/mail_queue_body.html",
"system_log_body": "templates/system_log_body.html",
"status_body": "templates/status_body.html",
}
@@ -3,7 +3,7 @@
<div class="card">
<h2>Filter</h2>
<form method="get" action="/sendlog">
<form method="get" action="/deliveries">
<label for="domain">Domain</label>
<select id="domain" name="domain">
<option value="">All domains</option>
@@ -19,6 +19,6 @@
</div>
<div class="card">
{{template "sendlog_rows" .}}
{{template "deliveries_rows" .}}
</div>
{{end}}
@@ -1,5 +1,5 @@
{{define "sendlog_rows"}}
<div id="sendlog-rows" hx-get="/sendlog/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
{{define "deliveries_rows"}}
<div id="deliveries-rows" hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
hx-trigger="every 5s" hx-swap="outerHTML">
{{if .Rows}}
<table>
@@ -22,8 +22,8 @@
</table>
<p class="muted">
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
{{if .HasPrev}} &middot; <a href="/sendlog?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 .HasPrev}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">&larr; Newer</a>{{end}}
{{if .HasNext}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older &rarr;</a>{{end}}
</p>
{{else}}
<p class="muted">No messages logged yet.</p>
+32 -10
View File
@@ -35,20 +35,42 @@
.User and get no nav. */}}
{{define "nav"}}
<nav class="nav">
<div class="links">
{{if eq .Active "status"}}<span aria-current="page">Status</span>{{else}}<a href="/status">Status</a>{{end}}
{{if eq .Active "domains"}}<span aria-current="page">Domains</span>{{else}}<a href="/domains">Domains</a>{{end}}
{{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>
{{/* The session block is first here because it is drawn first: it is the top
row of the bar (see .nav in panel.css), and putting it first in the
document keeps the tab order matching the visual one. */}}
<div class="session">
<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">
<button type="submit" class="danger">Sign out</button>
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form>
</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>
{{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">
<h2>Pending messages</h2>
{{template "queue_body" .}}
{{template "mail_queue_body" .}}
</div>
{{end}}
@@ -1,5 +1,5 @@
{{define "queue_body"}}
<div id="queue-body" hx-get="/queue/body" hx-trigger="every 5s" hx-swap="outerHTML">
{{define "mail_queue_body"}}
<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}}
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
</div>
+1 -1
View File
@@ -31,7 +31,7 @@
<p class="error">{{.QueueError}}</p>
{{else}}
<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}}
</div>
@@ -1,8 +1,8 @@
{{define "content"}}
<h1>Mail log</h1>
<h1>System log</h1>
<div class="card">
<h2>Recent log entries</h2>
{{template "logtail_body" .}}
{{template "system_log_body" .}}
</div>
{{end}}
@@ -1,5 +1,5 @@
{{define "logtail_body"}}
<div id="logtail-body" hx-get="/logtail/body" hx-trigger="every 5s" hx-swap="outerHTML">
{{define "system_log_body"}}
<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}}
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
{{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
err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "queue",
"Active": "mail_queue",
})
if err != nil {
t.Fatalf("execute nav: %v", err)
}
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)
}
if strings.Contains(out, `href="/queue"`) {
if strings.Contains(out, `href="/mail-queue"`) {
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)
}
}
@@ -129,7 +131,7 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
t.Fatalf("execute nav: %v", err)
}
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)
}
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
// falls back to the 7-day default.
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.
@@ -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
// checker caches its own results, so page views do not each pay for a
// 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
// legitimate admin and blunts automated probing (spec 7.6.1).
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
// fragment (spec 7.1 — the /rows and /body endpoints return HTML, not JSON).
authed.HandleFunc("GET /sendlog", s.handleSendLog)
authed.HandleFunc("GET /sendlog/rows", s.handleSendLogRows)
authed.HandleFunc("GET /queue", s.handleQueue)
authed.HandleFunc("GET /queue/body", s.handleQueueBody)
authed.HandleFunc("GET /logtail", s.handleLogTail)
authed.HandleFunc("GET /logtail/body", s.handleLogTailBody)
authed.HandleFunc("GET /deliveries", s.handleDeliveries)
authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows)
authed.HandleFunc("GET /mail-queue", s.handleMailQueue)
authed.HandleFunc("GET /mail-queue/body", s.handleMailQueueBody)
authed.HandleFunc("GET /system-log", s.handleSystemLog)
authed.HandleFunc("GET /system-log/body", s.handleSystemLogBody)
mux.Handle("/", s.requireAuth(authed))
+4
View File
@@ -30,6 +30,10 @@ services:
SELFPOST_HOSTNAME: mail.e2e.test
# Plain HTTP panel for the test client — no reverse proxy in this stand.
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,
# level-2 test, the various rejected attempts) never trip it by
# 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
}
// 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.
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
}