251344f880
The status page answered "are the components running" but said nothing about the machine underneath them, so a server slowed to a crawl by a busy processor or one about to have Postfix OOM-killed looked entirely healthy until the queue backed up. internal/health/machine.go reads the kernel's counters in /proc: the aggregate processor times and core count from /proc/stat, the load average from /proc/loadavg, memory and swap from /proc/meminfo, and per-interface byte counters from /proc/net/dev. CPU busy time and network throughput are rates, so a MachineSampler holds the previous reading and each call reports the difference — one shared sampler on the Server, since a per-request one would never have anything to subtract. A window longer than a minute only re-baselines: a page opened after the panel sat idle would otherwise average that whole stretch and present it as the current load. Memory is derived from MemAvailable rather than MemFree, because Linux spends every spare page on cache and MemFree would report a permanent emergency. A fully busy processor (>=90%) warns and an exhausted machine (>=97%) errors, both counting towards the page's headline verdict, since either delays or kills the mail path. Throughput has no comparable threshold — what counts as a lot depends on the link — so it is reported and never graded. Loopback is excluded: that traffic is the container talking to itself. Like every other check here, an unreadable counter degrades to "unknown" with an explanation instead of failing the page, so the panel still runs outside Linux for development. The usage bars are <meter> elements. The panel's CSP has no inline-style exemption, so a bar's length has to travel on an attribute; the element also grades its own colour from low/high/optimum, and the percentage is printed beside it for anything that does not render meters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
269 lines
11 KiB
Go
269 lines
11 KiB
Go
// Package web implements the SelfPost control panel's HTTP surface: the
|
|
// one-time administrator setup flow (security.md), login/session handling
|
|
// (security.md) and the authenticated shell the later phases build on.
|
|
package web
|
|
|
|
import (
|
|
"embed"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mixeme/selfpost/internal/app"
|
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
|
"github.com/mixeme/selfpost/internal/domain"
|
|
"github.com/mixeme/selfpost/internal/health"
|
|
"github.com/mixeme/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 (security.md; README § Environment
|
|
// variables for SELFPOST_HOSTNAME).
|
|
Hostname string
|
|
// CookieSecure sets the Secure attribute on the session cookie. It defaults
|
|
// to true (security.md); 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
|
|
// (architecture.md § Mail path). 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 (architecture.md § Panel HTTP surface). 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
|
|
// (architecture.md § Persistence); 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 (README §
|
|
// Environment variables), 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
|
|
// SessionIdleDays is the sliding inactivity window after which a login
|
|
// 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.
|
|
type Server struct {
|
|
store *store.Store
|
|
domains *domain.Service
|
|
apps *app.Service
|
|
cfg Config
|
|
tmpl *templates
|
|
sessions *sessionStore
|
|
setup *setupManager
|
|
dns *dnscheck.Checker
|
|
// machine reads the host's CPU, memory and network counters for the
|
|
// status page. It has to be one shared sampler for the whole server:
|
|
// CPU and throughput are differences between successive readings, so a
|
|
// per-request sampler would never have a previous one to subtract.
|
|
machine health.MachineSampler
|
|
|
|
loginLimiter *rateLimiter
|
|
setupLimiter *rateLimiter
|
|
|
|
trustedProxies []*net.IPNet
|
|
}
|
|
|
|
// New builds the panel server. setupTokenPath is where the current setup token
|
|
// is mirrored on disk (security.md); domains is the sending-domain service
|
|
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
|
|
// apps owns application SASL accounts and the Postfix sender map
|
|
// (architecture.md § Mail path).
|
|
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
|
|
}
|
|
idleDays := cfg.SessionIdleDays
|
|
if idleDays <= 0 {
|
|
idleDays = 7
|
|
}
|
|
s := &Server{
|
|
store: st,
|
|
domains: domains,
|
|
apps: apps,
|
|
cfg: cfg,
|
|
tmpl: tmpl,
|
|
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
|
|
// 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.
|
|
dns: dnscheck.New(cfg.DNSResolvers),
|
|
// Setup: a handful of attempts per minute per IP is plenty for a
|
|
// legitimate admin and blunts automated probing (security.md).
|
|
setupLimiter: newRateLimiter(10, time.Minute),
|
|
// Login: throttle brute-force by IP (security.md).
|
|
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 (security.md). 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 (security.md).
|
|
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: 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 (architecture.md §
|
|
// Persistence-B), and the full-server backup download itself.
|
|
authed.HandleFunc("GET /backup", s.handleBackupPage)
|
|
authed.HandleFunc("POST /backup", s.handleBackup)
|
|
|
|
// Monitoring screens (architecture.md § Panel HTTP surface): each page and
|
|
// its HTMX polling fragment (architecture.md § Panel HTTP surface — the /rows
|
|
// and /body endpoints return HTML, not JSON).
|
|
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))
|
|
|
|
// Security headers and the origin check wrap everything, including the
|
|
// unauthenticated login and setup routes.
|
|
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.
|
|
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")
|
|
if err := health.Liveness(); err != nil {
|
|
http.Error(w, "unhealthy\n", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
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...)
|
|
}
|