Files
selfpost/internal/web/web.go
T
mixandClaude Opus 5.5 a8fd96c752 health-setup-checks: the certificate's name, Postfix's greeting, the proxy
Decided by the owner on 2026-10-13 (in place of preflight), taken up on
2026-10-16; mockups b2dcefc, cc33890, 0feaa55 (his: the greeting on a row
of its own, the name check a tag beside Names).

- The certificate: health.Certificate keeps its names (DNS names, or the
  CN without any) and ForHost holds it to SELFPOST_HOSTNAME as a TLS
  client does - VerifyHostname over the DNS names, the CN only without
  any, as OpenSSL's X509_check_host and so a sending Postfix still take it
  (src/tls/tls_client.c:439, 513). A certificate for another name fails;
  the tag beside Names says matches / not for the hostname.
- The greeting: postconf -x -h smtpd_banner, read once at the panel's
  start like the served certificate's path (main.cf is not rewritten
  after), is what smtpd writes after "220 " (src/smtpd/smtpd.c:5979). A
  first word other than the hostname warns, with what to set. A fact may
  now take the whole row (Fact.Wide, sp-wide) and carry a tag beside its
  label (Fact.LabelTag); both on the kit page.
- The proxy: auth.Module.Seen says the address the panel counts sign-ins
  against, the peer, and whether X-Forwarded-For was read or left unread
  for an untrusted peer; the Configuration card shows "Your address" and,
  in the second case, warns that every visitor shares one sign-in limit
  and names the /32 (or /128) to put in TRUSTED_PROXY_CIDR.

Tests: TestCertificateForHost (CN-only, DNS names, a wildcard, the CN
ignored beside DNS names), TestSeenAddress, TestHealthSaysWhereItSeesTheViewer,
TestHealthGreetingAndCertificateName; e2e reads the stand's right state on
Health (matches, "220 mail.e2e.test ESMTP", a direct client). go vet + go
test (26 packages) and make e2e on the test server for this tree.

Docs: help (word for word with the mockup), guide § Overview and Health,
CHANGELOG [Unreleased] § Added, roadmap row done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:51:42 +03:00

363 lines
17 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 (
"log"
"net"
"net/http"
"strings"
"time"
"github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/legal"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/handlers"
"github.com/mixeme/selfpost/internal/web/view"
)
// 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; guide § 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); DeployRoot is the operator project
// directory (docker-compose.yml, .env, certs/); Version is stamped into
// the backup manifest. They mirror the panel's own configuration.
DataDir string
DBPath string
DeployRoot 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 mounted certificate (guide § Environment variables);
// TLSServedCertFile is Postfix's copy of it, the one served on 465/587,
// and TLSReloadInterval how often that copy is refreshed. Health reads
// the served one and names the mounted one while they differ
// (health.CheckServedCertificate).
TLSCertFile string
TLSServedCertFile string
TLSReloadInterval time.Duration
// SMTPBanner is Postfix's smtpd_banner as postconf expands it, "" when
// it could not be read (health.SMTPBanner).
SMTPBanner string
// SignSocket and SignLocalSocket are the DKIM signer's two milter
// sockets, applications' mail and the panel's own; JournalSocket is the
// journal-milter's. The status page dials them: the signer's are required
// for mail to leave at all (default_action=tempfail), the journal's only
// for the send log (the journal-milter fails open).
SignSocket string
SignLocalSocket string
JournalSocket string
// SenderSocket is the sender check's socket, attached strictly.
SenderSocket 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
// RateLimitMessagesPerIP and RateLimitWindowSeconds are the level-1
// Postfix anvil backstop (env RATE_LIMIT_*), mirrored into the panel for
// display and to cap domain/app level-2 ceilings (guide § Rate limiting).
RateLimitMessagesPerIP int
RateLimitWindowSeconds int
// RetryPolicy is this Postfix's deferred-mail timings, snapshotted once
// when the HTTP role starts. Handlers read the cache; they never call
// postconf (architecture.md).
RetryPolicy postfix.RetryPolicy
// InboundEnabled mirrors INBOUND_RELAY_ENABLE.
InboundEnabled bool
// InboundAntispamMilter mirrors INBOUND_ANTISPAM_MILTER (inet:host:port or
// unix:/path) and InboundAntispamAction INBOUND_ANTISPAM_MILTER_ACTION
// ("accept" or "tempfail", "" when the variable holds anything else): the
// panel only reports them.
InboundAntispamMilter string
InboundAntispamAction string
// DMARCEnabled mirrors DMARC_REPORTS_ENABLE.
DMARCEnabled bool
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start.
SendLogRetentionEnvDefault int
// InboundSpamLogRetentionEnvDefault is INBOUND_SPAM_LOG_RETENTION_DAYS at
// panel start.
InboundSpamLogRetentionEnvDefault int
}
// Server is the panel HTTP application.
type Server struct {
cfg Config
auth *auth.Module
handlers *handlers.Handlers
}
// 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 selector table (architecture.md § DKIM signing);
// 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, inboundSvc *inbound.Service, lists *inbound.Lists, dmarcSvc *dmarc.Service, cfg Config, setupTokenPath string) (*Server, error) {
v, err := view.New(cfg.Version)
if err != nil {
return nil, err
}
v.SetInboundEnabled(cfg.InboundEnabled)
v.SetDMARCEnabled(cfg.DMARCEnabled)
a := auth.New(st, auth.Config{
CookieSecure: cfg.CookieSecure,
Hostname: cfg.Hostname,
SessionIdleDays: cfg.SessionIdleDays,
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
}, v, setupTokenPath)
h := handlers.New(st, domains, apps, inboundSvc, lists, dmarcSvc, handlers.Config{
Hostname: cfg.Hostname,
SubmissionEnabled: cfg.SubmissionEnabled,
MailLogPath: cfg.MailLogPath,
DataDir: cfg.DataDir,
DBPath: cfg.DBPath,
DeployRoot: cfg.DeployRoot,
Version: cfg.Version,
TLSCertFile: cfg.TLSCertFile,
TLSServedCertFile: cfg.TLSServedCertFile,
SMTPBanner: cfg.SMTPBanner,
TLSReloadInterval: cfg.TLSReloadInterval,
SignSocket: cfg.SignSocket,
SignLocalSocket: cfg.SignLocalSocket,
JournalSocket: cfg.JournalSocket,
SenderSocket: cfg.SenderSocket,
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
RetryPolicy: cfg.RetryPolicy,
InboundEnabled: cfg.InboundEnabled,
InboundAntispamMilter: cfg.InboundAntispamMilter,
InboundAntispamAction: cfg.InboundAntispamAction,
DMARCEnabled: cfg.DMARCEnabled,
SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault,
InboundSpamLogRetentionEnvDefault: cfg.InboundSpamLogRetentionEnvDefault,
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
return &Server{cfg: cfg, auth: a, handlers: h}, 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.auth.Bootstrap()
}
// Handler returns the panel's HTTP handler (router).
func (s *Server) Handler() http.Handler {
mux, authed := s.muxes()
mux.Handle("/", s.auth.RequireAuth(authed))
return s.secure(mux)
}
// muxes registers every route: the public ones, and the ones behind a session.
// Both muxes are flat — one pattern per route, nothing mounted as a sub-router —
// and building them only takes method values, it never calls into s.auth or
// s.handlers: the route guard test (guard_routes_test.go) runs this on a bare
// Server and asks each mux which pattern answers a path.
func (s *Server) muxes() (public, authed *http.ServeMux) {
mux := http.NewServeMux()
h := s.handlers
mux.HandleFunc("/healthz", handleHealth)
mux.HandleFunc("/license", handleLicense)
mux.Handle("/static/", view.StaticHandler())
mux.HandleFunc("/setup/", s.auth.HandleSetup)
mux.HandleFunc("/login", s.auth.HandleLogin)
mux.HandleFunc("/logout", s.auth.HandleLogout)
authed = http.NewServeMux()
// A URL reads like the menu: /<group>/<page>, an entity under its list, an
// action under the thing it changes (docs/plans/panel-redesign.md §
// Routes). The pre-2.0 paths are gone, not redirected.
authed.HandleFunc("GET /{$}", redirectHome)
authed.HandleFunc("GET /help", h.HandleHelp)
// Account: every signed-in user's own.
authed.HandleFunc("GET /account", h.HandleAccount)
authed.HandleFunc("POST /account/profile", h.HandleAccountProfile)
authed.HandleFunc("POST /account/password", h.HandleAccountPassword)
// Overview. Its handler checks the role itself: the page is global-only
// but does not live under /server/.
authed.HandleFunc("GET /overview", h.HandleOverview)
authed.HandleFunc("GET /overview/fragment", h.HandleOverviewFragment)
// Outbound: checked per domain in the handlers (lookupDomain,
// lookupApplication), adding and deleting by role.
authed.HandleFunc("GET /outbound/domains", h.HandleDashboard)
authed.HandleFunc("POST /outbound/domains", h.HandleAddDomain)
authed.HandleFunc("GET /outbound/domains/{id}", h.HandleDomainDetail)
authed.HandleFunc("POST /outbound/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck)
authed.HandleFunc("GET /outbound/domains/{id}/delete", h.HandleDeleteConfirm)
authed.HandleFunc("POST /outbound/domains/{id}/delete", h.HandleDeleteDomain)
authed.HandleFunc("GET /outbound/domains/{id}/settings", h.HandleDomainSettings)
authed.HandleFunc("POST /outbound/domains/{id}/settings/reports", h.HandleDomainDMARC)
authed.HandleFunc("POST /outbound/domains/{id}/settings/ratelimit", h.HandleDomainRateLimit)
authed.HandleFunc("POST /outbound/domains/{id}/settings/ratelimit/recalc", h.HandleDomainRateLimitRecalc)
authed.HandleFunc("POST /outbound/domains/{id}/settings/export", h.HandleExportDomain)
// An application lives under its domain, and its form is one POST: sender,
// client IPs and rate limit are saved together. The page that shows a new
// password is the response to its POST and has no GET path.
authed.HandleFunc("GET /outbound/domains/{id}/applications/new", h.HandleApplicationNew)
authed.HandleFunc("POST /outbound/domains/{id}/applications/new", h.HandleApplicationCreate)
authed.HandleFunc("GET /outbound/domains/{id}/applications/{aid}", h.HandleApplicationEdit)
authed.HandleFunc("POST /outbound/domains/{id}/applications/{aid}", h.HandleApplicationSave)
authed.HandleFunc("POST /outbound/domains/{id}/applications/{aid}/password", h.HandleRegenPassword)
authed.HandleFunc("POST /outbound/domains/{id}/applications/{aid}/ratelimit/recalc", h.HandleAppRateLimitRecalc)
authed.HandleFunc("POST /outbound/domains/{id}/applications/{aid}/delete", h.HandleDeleteApplication)
authed.HandleFunc("GET /outbound/log", h.HandleDeliveries)
authed.HandleFunc("GET /outbound/log/fragment", h.HandleDeliveriesRows)
authed.HandleFunc("GET /outbound/log/{id}", h.HandleDelivery)
authed.HandleFunc("GET /outbound/queue", h.HandleMailQueue)
authed.HandleFunc("GET /outbound/queue/fragment", h.HandleMailQueueBody)
if s.cfg.DMARCEnabled {
authed.HandleFunc("GET /outbound/dmarc", h.HandleDMARCList)
authed.HandleFunc("GET /outbound/dmarc/reports/{id}", h.HandleDMARCReport)
authed.HandleFunc("GET /outbound/dmarc/domains/{id}", h.HandleDMARCDomain)
}
// Inbound: checked per inbound domain (requireInboundDomain).
if s.cfg.InboundEnabled {
authed.HandleFunc("GET /inbound/domains", h.HandleInboundList)
authed.HandleFunc("POST /inbound/domains", h.HandleAddInbound)
authed.HandleFunc("GET /inbound/domains/{id}", h.HandleInboundDetail)
authed.HandleFunc("POST /inbound/domains/{id}/dns-recheck", h.HandleInboundDNSRecheck)
authed.HandleFunc("POST /inbound/domains/{id}/upstream", h.HandleInboundTransport)
authed.HandleFunc("POST /inbound/domains/{id}/recipients", h.HandleInboundRecipients)
authed.HandleFunc("GET /inbound/domains/{id}/delete", h.HandleInboundDeleteConfirm)
authed.HandleFunc("POST /inbound/domains/{id}/delete", h.HandleInboundDelete)
// The log is for everyone who reaches an inbound domain (scoped to
// theirs); the lists are the instance's, so the handlers keep the
// global role's check (requireInboundGlobal) and answer 404 to the rest.
authed.HandleFunc("GET /inbound/log", h.HandleInboundLog)
authed.HandleFunc("GET /inbound/filter-lists", h.HandleFilterLists)
authed.HandleFunc("POST /inbound/filter-lists", h.HandleFilterListAdd)
authed.HandleFunc("POST /inbound/filter-lists/{id}/delete", h.HandleFilterListDelete)
}
// Server: everything under /server/ is for the global role, so the subtree
// is guarded once, here, by server() — a route added to this group cannot
// forget the check. The handlers keep their own requireGlobal as well.
server := func(pattern string, handler http.HandlerFunc) {
if _, path, _ := strings.Cut(pattern, " "); !strings.HasPrefix(path, "/server/") {
panic("web: server() registers routes under /server/ only: " + pattern)
}
authed.HandleFunc(pattern, globalOnly(handler))
}
server("GET /server/health", h.HandleHealth)
server("GET /server/health/fragment", h.HandleHealthFragment)
server("POST /server/health/recheck", h.HandleHealthRecheck)
server("POST /server/health/reload", h.HandleReload)
server("GET /server/log", h.HandleSystemLog)
server("GET /server/log/fragment", h.HandleSystemLogBody)
server("GET /server/backup", h.HandleBackupPage)
server("POST /server/backup", h.HandleBackup)
server("POST /server/backup/import", h.HandleImportDomain)
server("GET /server/users", h.HandleUsers)
server("GET /server/users/new", h.HandleUserNew)
server("POST /server/users/new", h.HandleUserNew)
server("GET /server/users/{uid}", h.HandleUserEdit)
server("POST /server/users/{uid}", h.HandleUserEdit)
server("GET /server/users/{uid}/delete", h.HandleUserDeleteConfirm)
server("POST /server/users/{uid}/delete", h.HandleUserDelete)
server("GET /server/settings", h.HandleServerSettings)
server("POST /server/settings", h.HandleServerSettings)
server("POST /server/settings/dns-recheck", h.HandleSettingsDNSRecheck)
server("GET /server/components", h.HandleComponents)
return mux, authed
}
// globalOnly answers 404 to anyone but the global role before the wrapped
// handler runs — the same answer a missing page gets, so the panel does not
// confirm to a domain administrator that the Server pages exist.
func globalOnly(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p, ok := auth.PrincipalFromRequest(r)
if !ok || !p.IsGlobal() {
http.NotFound(w, r)
return
}
next(w, r)
}
}
func redirectHome(w http.ResponseWriter, r *http.Request) {
p, ok := auth.PrincipalFromRequest(r)
if ok && !p.IsGlobal() {
// A domain administrator lands on what they reach: their sending
// domains, or the inbound ones when that is all they are assigned.
if !p.HasOutbound() && p.HasInbound() {
http.Redirect(w, r, "/inbound/domains", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/outbound/domains", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/overview", 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"))
}
func handleLicense(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(legal.License)
}
func logf(format string, args ...any) {
log.Printf(format, args...)
}