Files
selfpost/internal/web/web.go
T
mixandClaude Opus 5.5 610023ef6a panel-redesign: Inbound domains, domain and delete on the kit; Spam filter box
Stage 2, step 5. Written by Sonnet, reviewed by Opus.

  - Three pages named after their mockups: in-domains, in-domain,
    in-domain-delete, with typed page data (view/inbound.go), fixtures
    and tests. The engine pages inbound, inbound_domain and
    inbound_delete are gone with their templates and are off
    legacy_pages.txt.
  - The Spam filter box (plan, Who sees what). The panel now reads
    INBOUND_ANTISPAM_MILTER and INBOUND_ANTISPAM_MILTER_ACTION, with the
    semantics of build/postfix-config.sh (on only with the inbound relay
    on and a milter set; accept | tempfail). It reports them, nothing
    more:
      on   Applies to / Filter (the address, global role only) / If the
           filter is down (accepted unfiltered | deferred)
      off  one sentence; the variable is named for the global role only
    The list shows "Spam filter on|off" in its head.
  - Handlers pass typed data; routes, POST targets, field names,
    validation and authorization (global: all inbound domains; domain
    role: the assigned ones, another tenant's id is a 404; add and
    delete are global-only) are unchanged. A test saves the upstream and
    recipients forms untouched and finds the domain unchanged.

Left out of the mockups' Spam filter box and menu - no source before
inbound-antispam-panel (2.1): the engine name, the quarantine row, the
"refused at the door, see the log" row, the lists row, "Log for this
domain". Also "Checked N min ago" on the MX box: the check carries no
time.

Not done: the filter's socket on Health and Overview. internal/health
checks a unix socket by stat; the documented form of the filter's
address is inet:host:port and needs a dial that does not exist yet.

Differences from the mockups, for the owner:
  - refusals and confirmations are the kit flash above the boxes;
  - the MX tag is coloured by the check in every state; an MX that
    points elsewhere shows "In DNS now";
  - the lead of the domain page follows what is saved ("Not accepting
    mail yet: no upstream is saved");
  - the delete page lists only what the domain has;
  - /help#inbound opens Help at the top until the Help step.

Evidence
  - docs/assets/panel-redesign/evidence/in-domains.png, in-domain.png,
    in-domain-delete.png: 1440 px wide, headless Edge, real templates
    served locally with their fixtures (filter on) - NOT from the
    container (no Docker on this machine).
  - panel/audit.js on the three pages at 1440 and 1280: 352 texts
    checked, problems: [].
  - That the panel process sees the two variables inside the container
    is read from supervisord.conf and entrypoint.sh, not run.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:39:20 +03:00

334 lines
15 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"
"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 certificate Postfix serves on 465/587 (guide §
// 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
// 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
}
// 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 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, inboundSvc *inbound.Service, 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, 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,
OpenDKIMSocket: cfg.OpenDKIMSocket,
JournalSocket: cfg.JournalSocket,
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
RetryPolicy: cfg.RetryPolicy,
InboundEnabled: cfg.InboundEnabled,
InboundAntispamMilter: cfg.InboundAntispamMilter,
InboundAntispamAction: cfg.InboundAntispamAction,
DMARCEnabled: cfg.DMARCEnabled,
SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault,
}, 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)
authed.HandleFunc("POST /account/dmarc", h.HandleAccountDMARC)
// 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)
}
// 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("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...)
}