6ff353b94a
Phase 14.A, both halves of it in one middleware wrapped around the whole
router — outside requireAuth, so POST /login and POST /setup/{token} are
covered too.
Headers: CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and
HSTS only where the deployment is HTTPS-only (the same CookieSecure condition
that gates the cookie's Secure attribute; on the plain-HTTP dev instance HSTS
would pin the browser to a scheme that instance does not speak). HSTS goes
without includeSubDomains on purpose: the panel may sit at an apex, and
forcing HTTPS on every unrelated subdomain of the operator's domain for a year
is not this project's call. They are emitted here rather than delegated to the
reverse proxy, so the part that is easy to get wrong lives in the service.
Origin check: this is what SameSite=Lax cannot do. SameSite is judged per site
(registrable domain), so a neighbouring host — a CMS, a forgotten staging
subdomain — is same-site and its forged POST arrives with the session cookie
attached. Sec-Fetch-Site and Origin are judged per origin and tell it apart.
For the typical deployment, where the panel is a subdomain of a domain used
for other things, that neighbour is the realistic attacker, not a theoretical
one; POST /domains/import is the case that turns a blind write into working
credentials on someone else's relay.
A request carrying neither header is still let through — the risk accepted in
the plan, since a client that old cannot be checked at all and the strict mode
would not protect it, only break the panel in it.
The check compares Origin's host against r.Host, not the full origin: the
panel speaks plain HTTP behind the proxy and does not know its own external
scheme. That makes it depend on the proxy preserving Host. All four shipped
fragments do, but one that rewrites it would turn every form submission into a
403, so the rejection logs both sides of the comparison — otherwise the
symptom reads as "the panel stopped saving anything".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
9.2 KiB
Go
241 lines
9.2 KiB
Go
// Package web implements the SelfPost control panel's HTTP surface: the
|
|
// one-time administrator setup flow (spec 7.6.1), login/session handling
|
|
// (spec 7.6.5-6) and the authenticated shell the later phases build on.
|
|
package web
|
|
|
|
import (
|
|
"embed"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"codeberg.org/mix/selfpost/internal/app"
|
|
"codeberg.org/mix/selfpost/internal/dnscheck"
|
|
"codeberg.org/mix/selfpost/internal/domain"
|
|
"codeberg.org/mix/selfpost/internal/store"
|
|
)
|
|
|
|
//go:embed templates/*.html static/*
|
|
var assetsFS embed.FS
|
|
|
|
// Config holds the panel's HTTP-facing configuration.
|
|
type Config struct {
|
|
// Hostname is the server's external hostname, used to build the absolute
|
|
// setup link shown in the logs (spec 7.6.1, 8: SELFPOST_HOSTNAME).
|
|
Hostname string
|
|
// CookieSecure sets the Secure attribute on the session cookie. It defaults
|
|
// to true (spec 7.6.6); it exists as a knob only so the panel can be tested
|
|
// over plain HTTP in development, never for production.
|
|
CookieSecure bool
|
|
// SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also
|
|
// runs the 587/STARTTLS submission listener next to the primary 465 one
|
|
// (spec 5). The panel only reports it on the domain page's connection
|
|
// settings; it is a deploy-time flag, not something the panel can verify.
|
|
SubmissionEnabled bool
|
|
// MailLogPath is where Postfix's delivery log lives, read by the mail.log
|
|
// monitoring view (spec 7.2.13). It is the same path the log-tailer role
|
|
// follows in cmd/panel.
|
|
MailLogPath string
|
|
// DataDir and DBPath locate the persistent state a full backup archives
|
|
// (spec 7.5.A); Version is stamped into the backup manifest. They mirror the
|
|
// panel's own configuration.
|
|
DataDir string
|
|
DBPath string
|
|
Version string
|
|
// TrustedProxyCIDRs are the reverse-proxy addresses allowed to supply
|
|
// X-Forwarded-For (env TRUSTED_PROXY_CIDR). A request whose
|
|
// direct peer (RemoteAddr) is not in this list never has its XFF header
|
|
// honoured, so the header can't be spoofed by anyone but a trusted proxy.
|
|
// Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
|
|
TrustedProxyCIDRs []*net.IPNet
|
|
// TLSCertFile is the certificate Postfix serves on 465/587 (spec 8), read
|
|
// read-only by the status page to report how much validity is left.
|
|
TLSCertFile string
|
|
// OpenDKIMSocket and JournalSocket are the two milter sockets Postfix
|
|
// connects to. The status page stats them: the first is required for mail
|
|
// to leave at all (OpenDKIM runs with default_action=tempfail), the second
|
|
// only for the send log (the journal-milter fails open).
|
|
OpenDKIMSocket string
|
|
JournalSocket string
|
|
}
|
|
|
|
// Server is the panel HTTP application.
|
|
type Server struct {
|
|
store *store.Store
|
|
domains *domain.Service
|
|
apps *app.Service
|
|
cfg Config
|
|
tmpl *templates
|
|
sessions *sessionStore
|
|
setup *setupManager
|
|
dns *dnscheck.Checker
|
|
|
|
loginLimiter *rateLimiter
|
|
setupLimiter *rateLimiter
|
|
|
|
trustedProxies []*net.IPNet
|
|
}
|
|
|
|
// New builds the panel server. setupTokenPath is where the current setup token
|
|
// is mirrored on disk (spec 7.6.1); domains is the sending-domain service that
|
|
// owns DKIM keys and the OpenDKIM tables (spec 6); apps owns application SASL
|
|
// accounts and the Postfix sender map (spec 5.1).
|
|
func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
|
tmpl, err := loadTemplates()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &Server{
|
|
store: st,
|
|
domains: domains,
|
|
apps: apps,
|
|
cfg: cfg,
|
|
tmpl: tmpl,
|
|
sessions: newSessionStore(),
|
|
// 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(),
|
|
// 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),
|
|
// Login: throttle brute-force by IP (spec 7.6.5).
|
|
loginLimiter: newRateLimiter(10, 15*time.Minute),
|
|
|
|
trustedProxies: cfg.TrustedProxyCIDRs,
|
|
}
|
|
s.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
|
|
return s, nil
|
|
}
|
|
|
|
// Start performs first-run bootstrapping: if there is no administrator yet, it
|
|
// generates and announces the setup link (spec 7.6.1). Safe to call once at
|
|
// server startup.
|
|
func (s *Server) Start() error {
|
|
return s.setup.bootstrap()
|
|
}
|
|
|
|
// Handler returns the panel's HTTP handler (router).
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// Health check stays unauthenticated for the container/orchestrator.
|
|
mux.HandleFunc("/healthz", handleHealth)
|
|
|
|
// Vendored static assets (HTMX). Served from the embedded FS.
|
|
mux.Handle("/static/", http.FileServer(http.FS(assetsFS)))
|
|
|
|
// One-time administrator setup (spec 7.6.1).
|
|
mux.HandleFunc("/setup/", s.handleSetup)
|
|
|
|
// Authentication.
|
|
mux.HandleFunc("/login", s.handleLogin)
|
|
mux.HandleFunc("/logout", s.handleLogout)
|
|
|
|
// Authenticated panel. Everything not matched by a more specific pattern
|
|
// above falls through to this sub-mux, wrapped once in the auth middleware.
|
|
authed := http.NewServeMux()
|
|
|
|
// The landing page is the server status (phase 13.C): the first thing an
|
|
// administrator should see after logging in is whether the service is
|
|
// healthy, not the domain list. handleLogin still redirects to "/".
|
|
authed.HandleFunc("GET /{$}", redirectToStatus)
|
|
authed.HandleFunc("GET /status", s.handleStatus)
|
|
authed.HandleFunc("GET /status/fragment", s.handleStatusFragment)
|
|
authed.HandleFunc("POST /status/recheck", s.handleStatusRecheck)
|
|
|
|
authed.HandleFunc("GET /domains", s.handleDashboard)
|
|
authed.HandleFunc("POST /domains", s.handleAddDomain)
|
|
authed.HandleFunc("POST /domains/import", s.handleImportDomain)
|
|
authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail)
|
|
authed.HandleFunc("POST /domains/{id}/dns-recheck", s.handleDomainDNSRecheck)
|
|
authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm)
|
|
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
|
|
authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication)
|
|
authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit)
|
|
authed.HandleFunc("POST /domains/{id}/export", s.handleExportDomain)
|
|
authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode)
|
|
authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword)
|
|
authed.HandleFunc("POST /applications/{aid}/ratelimit", s.handleAppRateLimit)
|
|
authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication)
|
|
authed.HandleFunc("POST /reload", s.handleReload)
|
|
|
|
// Administrator's own panel credentials.
|
|
authed.HandleFunc("/account", s.handleAccount)
|
|
|
|
// Backup and migration: the page with both actions (spec 7.5.A-B), and the
|
|
// full-server backup download itself.
|
|
authed.HandleFunc("GET /backup", s.handleBackupPage)
|
|
authed.HandleFunc("POST /backup", s.handleBackup)
|
|
|
|
// 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)
|
|
|
|
mux.Handle("/", s.requireAuth(authed))
|
|
|
|
// Security headers and the origin check wrap everything, including the
|
|
// unauthenticated login and setup routes (phase 14.A).
|
|
return s.secure(mux)
|
|
}
|
|
|
|
// redirectToStatus points the panel root at the status page, so there is one
|
|
// canonical URL for that content instead of two (phase 13.C).
|
|
func redirectToStatus(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/status", http.StatusSeeOther)
|
|
}
|
|
|
|
func handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok\n"))
|
|
}
|
|
|
|
// clientIP extracts the peer IP for rate-limiting. By default it is the
|
|
// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches
|
|
// one of trustedProxies, the last entry of X-Forwarded-For is used instead —
|
|
// that is the address the trusted proxy itself appended, so a client can't
|
|
// forge it by sending its own XFF header. With no trusted
|
|
// proxies configured, behind a reverse proxy this is the proxy's own address,
|
|
// which is an acceptable backstop for a single-admin panel.
|
|
func clientIP(r *http.Request, trustedProxies []*net.IPNet) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
|
|
if len(trustedProxies) > 0 {
|
|
if peer := net.ParseIP(host); peer != nil && ipInAny(peer, trustedProxies) {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
parts := strings.Split(xff, ",")
|
|
if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil {
|
|
return ip.String()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return host
|
|
}
|
|
|
|
func ipInAny(ip net.IP, nets []*net.IPNet) bool {
|
|
for _, n := range nets {
|
|
if n.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// logf is a thin wrapper so handlers log with a consistent prefix.
|
|
func logf(format string, args ...any) {
|
|
log.Printf(format, args...)
|
|
}
|