panel: server status page, per-domain DNS checks, /domains move
Phase 13. Two new packages and one new screen. internal/health owns the shared status vocabulary (ok/warn/error/unknown) and the local checks: supervisord's process table, TLS certificate expiry and the two milter sockets. Each check reports a problem as a status rather than an error, so one broken component costs a line and not the page. internal/dnscheck does the read-only lookups: forward-confirmed reverse DNS for SELFPOST_HOSTNAME, and per-domain DKIM (compared against the key this server actually signs with), SPF and DMARC. Every check is bounded by a timeout and cached, and the resolver sits behind an interface so the tests drive every branch without touching the network. The SPF check is deliberately shallow: it looks for a mechanism literally covering the server's address and does not follow include:/redirect=, so a record that authorises us through an include is reported as "cannot tell" rather than as a failure. /status renders both, with the local checks in an HTMX-polled fragment and the DNS lookups behind a Re-check button, and becomes the panel's landing page: / now redirects there and the domain list lives at /domains. The Reload button moves onto /status, where it reads as what it is — a drift-recovery for the daemons — with text explaining what it regenerates. A template test fails on any remaining href="/" so a stale link cannot silently land on the wrong screen. Also fixes a defect this made visible: the panel could never read the mail queue in the documented deployment. postqueue relies on its setgid-postdrop bit, which the compose file's no-new-privileges disables, so the Queue screen always said "Could not read the mail queue" — including in the released 1.0.0 image. The panel user is now a real member of postdrop, which needs no setgid transition. Verified in a container on the dev server against real DNS: PTR matching (selfpost.mixfed.ru) and not matching (mixfed.ru), DKIM absent and mismatched, SPF absent and via include:, DMARC p=quarantine/p=reject/absent, and a resolver timeout degrading to "unknown" without hanging the page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/health"
|
||||
)
|
||||
|
||||
// spfLookupBudget caps how many DNS lookups one SPF evaluation may spend on
|
||||
// a/mx mechanisms. RFC 7208 allows a receiver ten; staying under the same
|
||||
// ceiling keeps a hostile or careless record from turning a page view into a
|
||||
// long chain of queries.
|
||||
const spfLookupBudget = 10
|
||||
|
||||
// checkSPF reports whether the domain's SPF record authorises this server.
|
||||
//
|
||||
// This is deliberately a shallow check (see docs/implementation-plan.md, phase
|
||||
// 13.B.2): it looks for a mechanism that literally covers the server's address —
|
||||
// ip4:/ip6:, or a/mx resolving to it — and does not recurse into include: or
|
||||
// redirect=, nor evaluate the record the way a receiver would. That is why a
|
||||
// record which does not obviously cover us but does use include: is reported as
|
||||
// a warning ("cannot tell") rather than a failure.
|
||||
func (c *Checker) checkSPF(ctx context.Context, q Query) Result {
|
||||
ips := parseIPs(q.ServerIPs)
|
||||
if len(ips) == 0 {
|
||||
return Result{
|
||||
Status: health.StatusUnknown,
|
||||
Detail: "The server's own address is not known (its hostname does not resolve), so SPF coverage cannot be checked. Fix the hostname/PTR check first.",
|
||||
}
|
||||
}
|
||||
|
||||
txt, found, err := c.lookupTXT(ctx, q.Name)
|
||||
if err != nil {
|
||||
return lookupFailed("the SPF record", err)
|
||||
}
|
||||
|
||||
var records []string
|
||||
for _, rec := range txt {
|
||||
if isSPF(rec) {
|
||||
records = append(records, strings.TrimSpace(rec))
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !found || len(records) == 0:
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("No SPF record is published for %s. Publish a TXT record such as \"v=spf1 ip4:%s -all\" — without it receivers have nothing authorising this server to send as the domain.", q.Name, ips[0]),
|
||||
}
|
||||
case len(records) > 1:
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("More than one SPF record is published for %s. RFC 7208 allows exactly one; receivers treat several as a permanent error and the domain gets no SPF pass at all. Merge them into a single record.", q.Name),
|
||||
Records: records,
|
||||
}
|
||||
}
|
||||
|
||||
matched, unfollowed := c.evaluateSPF(ctx, records[0], q.Name, ips)
|
||||
switch {
|
||||
case matched == "+all" || matched == "all":
|
||||
return Result{
|
||||
Status: health.StatusWarn,
|
||||
Detail: "The SPF record ends with \"+all\", which authorises every server on the internet to send as this domain. Replace it with an explicit ip4:/ip6: or a mechanism plus \"-all\".",
|
||||
Records: records,
|
||||
}
|
||||
case matched != "":
|
||||
return Result{
|
||||
Status: health.StatusOK,
|
||||
Detail: fmt.Sprintf("The SPF record authorises this server through its %q mechanism.", matched),
|
||||
Records: records,
|
||||
}
|
||||
case len(unfollowed) > 0:
|
||||
return Result{
|
||||
Status: health.StatusWarn,
|
||||
Detail: fmt.Sprintf("No mechanism in the SPF record lists %s directly, but the record uses %s, which this check does not follow — the server may still be authorised through it. Verify with an external SPF validator, or add \"ip4:%s\" to be sure.",
|
||||
ips[0], strings.Join(unfollowed, ", "), ips[0]),
|
||||
Records: records,
|
||||
}
|
||||
default:
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("The SPF record does not authorise %s, so mail sent from this server fails SPF. Add \"ip4:%s\" (or an \"a\" mechanism resolving here) to the record.", ips[0], ips[0]),
|
||||
Records: records,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateSPF walks the record's mechanisms, returning the first one that
|
||||
// covers one of the server's addresses, plus the mechanisms this shallow check
|
||||
// cannot resolve (include:/redirect=/exists:/ptr and anything past the lookup
|
||||
// budget) so the caller can say "cannot tell" instead of "fails".
|
||||
func (c *Checker) evaluateSPF(ctx context.Context, record, domainName string, ips []net.IP) (matched string, unfollowed []string) {
|
||||
budget := spfLookupBudget
|
||||
seenUnfollowed := make(map[string]bool)
|
||||
note := func(kind string) {
|
||||
if !seenUnfollowed[kind] {
|
||||
seenUnfollowed[kind] = true
|
||||
unfollowed = append(unfollowed, kind)
|
||||
}
|
||||
}
|
||||
|
||||
terms := strings.Fields(record)
|
||||
if len(terms) > 0 {
|
||||
terms = terms[1:] // drop the v=spf1 version token
|
||||
}
|
||||
for _, term := range terms {
|
||||
qualifier, mech := splitQualifier(term)
|
||||
lower := strings.ToLower(mech)
|
||||
name, hasArg := mechanismArg(mech)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "ip4:"), strings.HasPrefix(lower, "ip6:"):
|
||||
if qualifier != '+' {
|
||||
continue
|
||||
}
|
||||
if coversAny(mech[4:], ips) {
|
||||
return term, unfollowed
|
||||
}
|
||||
|
||||
case lower == "a" || strings.HasPrefix(lower, "a:") || strings.HasPrefix(lower, "a/"):
|
||||
if strings.Contains(mech, "/") { // prefix-length form: not evaluated
|
||||
note("a/<prefix>")
|
||||
continue
|
||||
}
|
||||
target := domainName
|
||||
if hasArg {
|
||||
target = name
|
||||
}
|
||||
if budget <= 0 {
|
||||
note("further lookups")
|
||||
continue
|
||||
}
|
||||
budget--
|
||||
if qualifier == '+' && c.resolvesTo(ctx, target, ips) {
|
||||
return term, unfollowed
|
||||
}
|
||||
|
||||
case lower == "mx" || strings.HasPrefix(lower, "mx:") || strings.HasPrefix(lower, "mx/"):
|
||||
if strings.Contains(mech, "/") {
|
||||
note("mx/<prefix>")
|
||||
continue
|
||||
}
|
||||
target := domainName
|
||||
if hasArg {
|
||||
target = name
|
||||
}
|
||||
if budget <= 0 {
|
||||
note("further lookups")
|
||||
continue
|
||||
}
|
||||
budget--
|
||||
if qualifier == '+' && c.mxResolvesTo(ctx, target, ips, &budget) {
|
||||
return term, unfollowed
|
||||
}
|
||||
|
||||
case strings.HasPrefix(lower, "include:"):
|
||||
note("include:")
|
||||
case strings.HasPrefix(lower, "redirect="):
|
||||
note("redirect=")
|
||||
case strings.HasPrefix(lower, "exists:"):
|
||||
note("exists:")
|
||||
case lower == "ptr" || strings.HasPrefix(lower, "ptr:"):
|
||||
note("ptr")
|
||||
|
||||
case lower == "all":
|
||||
if qualifier == '+' {
|
||||
return "+all", unfollowed
|
||||
}
|
||||
// "-all"/"~all"/"?all" terminates the record: nothing after it is
|
||||
// evaluated by a receiver either.
|
||||
return "", unfollowed
|
||||
}
|
||||
}
|
||||
return "", unfollowed
|
||||
}
|
||||
|
||||
// resolvesTo reports whether name resolves to one of the server's addresses.
|
||||
func (c *Checker) resolvesTo(ctx context.Context, name string, ips []net.IP) bool {
|
||||
addrs, err := c.resolver.LookupIPAddr(ctx, name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range addrs {
|
||||
for _, ip := range ips {
|
||||
if a.IP.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mxResolvesTo reports whether any of name's MX hosts resolves to one of the
|
||||
// server's addresses, spending at most the remaining lookup budget.
|
||||
func (c *Checker) mxResolvesTo(ctx context.Context, name string, ips []net.IP, budget *int) bool {
|
||||
mxs, err := c.resolver.LookupMX(ctx, name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, mx := range mxs {
|
||||
if *budget <= 0 {
|
||||
return false
|
||||
}
|
||||
*budget--
|
||||
if c.resolvesTo(ctx, strings.TrimSuffix(mx.Host, "."), ips) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// coversAny reports whether an ip4:/ip6: value — a bare address or a CIDR —
|
||||
// contains one of the server's addresses.
|
||||
func coversAny(value string, ips []net.IP) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.Contains(value, "/") {
|
||||
_, network, err := net.ParseCIDR(value)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
listed := net.ParseIP(value)
|
||||
if listed == nil {
|
||||
return false
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if listed.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitQualifier peels the optional +/-/~/? qualifier off a mechanism,
|
||||
// defaulting to "+" (pass) as RFC 7208 does.
|
||||
func splitQualifier(term string) (byte, string) {
|
||||
if term == "" {
|
||||
return '+', ""
|
||||
}
|
||||
switch term[0] {
|
||||
case '+', '-', '~', '?':
|
||||
return term[0], term[1:]
|
||||
default:
|
||||
return '+', term
|
||||
}
|
||||
}
|
||||
|
||||
// mechanismArg returns the ":" argument of a mechanism, if it has one.
|
||||
func mechanismArg(mech string) (string, bool) {
|
||||
_, arg, found := strings.Cut(mech, ":")
|
||||
if !found || arg == "" {
|
||||
return "", false
|
||||
}
|
||||
return arg, true
|
||||
}
|
||||
|
||||
// isSPF reports whether a TXT record is an SPF record (the version token must
|
||||
// be the whole first term, so "v=spf10" is not one).
|
||||
func isSPF(record string) bool {
|
||||
rec := strings.TrimSpace(record)
|
||||
if len(rec) < 6 || !strings.EqualFold(rec[:6], "v=spf1") {
|
||||
return false
|
||||
}
|
||||
return len(rec) == 6 || rec[6] == ' ' || rec[6] == '\t'
|
||||
}
|
||||
|
||||
// parseIPs converts the string addresses carried on a Query back into net.IPs,
|
||||
// dropping anything unparsable.
|
||||
func parseIPs(in []string) []net.IP {
|
||||
var ips []net.IP
|
||||
for _, s := range in {
|
||||
if ip := net.ParseIP(s); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
Reference in New Issue
Block a user