Files
selfpost/internal/web/web.go
T
mixandClaude Opus 5.5 6db3704711 queue-table: Outbound › Queue as the drawn table, Inbound › Queue built
Owner, 2026-10-18: "Фиаско, что очередь не была сделана. Как мы это
допустили? Исправляем!" The mockups out-queue and in-queue (design 629de5e,
guards 8525e8e).

- postfix: postqueue -j gives each message's size and each recipient's
  delay_reason (src/postqueue/showq_json.c, format_json(): "delay_reason"
  only when LEN(why) > 0); QueueListing reads it with the page's 5 s,
  QueueMessages keeps the notifier's 30 s.
- view: one Queue page type for both directions (queue.go), one polled box
  (queue_body.html): a row per recipient, oldest first, cut at 200 with a
  foot; the head "N messages · size", "Refreshes on its own" while empty;
  Last reply is the delay reason, or where the message is (active, hold,
  incoming) before there was one. Inbound has no Details: an inbound
  message has no page of its own.
- handlers: one read of the queue, split by the recipient's domain against
  the inbound domains; an outbound recipient is linked to its send-log row
  (store.SendLogRefsByQueueID, through idx_send_log_queue_id, plan pinned in
  TestSendLogQueriesUseTheirIndexes), matched without regard to case, else
  the message's first row. Both pages are the global role's; the inbound
  one 404s with the relay off. Routes /inbound/queue (+ /fragment), menu,
  touch menu and sibling strip.
- Overview: Queues · Both empty · outbound and inbound with the relay on.
- Help: the queue topic rewritten, an Inbound queue topic (word for word with
  the mockup; domain administrators do not see it, as with the lists).
- TestUnbuiltScreensAreOnTheRoadmap and its rule in development.md: an
  exempted screen must be named in roadmap.md; queue-table leaves the
  roadmap; guide, architecture, panel-redesign.md updated.

Tests: go vet; go test ./internal/web/... ./internal/store ./internal/postfix
./internal/notify pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:17:14 +03:00

413 lines
19 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 (
"context"
"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/notify"
"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
// TimeZone is TZ as the container has it, "" for none (Settings names it).
TimeZone 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
// InboundJournalSocket is the inbound journal milter's, dialled with
// the inbound relay on; it fails open like the journal's.
InboundJournalSocket 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)
a.SetMailer(notify.NewMailer(cfg.Hostname))
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,
TimeZone: cfg.TimeZone,
TLSReloadInterval: cfg.TLSReloadInterval,
SignSocket: cfg.SignSocket,
SignLocalSocket: cfg.SignLocalSocket,
JournalSocket: cfg.JournalSocket,
InboundJournalSocket: cfg.InboundJournalSocket,
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()
}
// Drain waits, until ctx ends, for what the panel still does after its
// answers - the links of password-reset being mailed - so the database is not
// closed under them.
func (s *Server) Drain(ctx context.Context) {
s.auth.Wait(ctx)
}
// 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)
// The links of password-reset, opened from a mail, signed in or not.
mux.HandleFunc("/login/reset", s.auth.HandleReset)
mux.HandleFunc("/login/reset/", s.auth.HandleResetLink)
mux.HandleFunc("/confirm/", s.auth.HandleConfirm)
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/email/confirm", h.HandleAccountConfirmLink)
// 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 queue and 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/queue", h.HandleInboundQueue)
authed.HandleFunc("GET /inbound/queue/fragment", h.HandleInboundQueueBody)
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("POST /server/health/test-message", h.HandleTestMessage)
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)
}
// handleHealth answers the liveness probe - but only to a client that did not
// come through a reverse proxy: one with no X-Forwarded-For, from loopback or
// a private address (the container's own HEALTHCHECK, the host, a monitor on
// the Docker network). Every supported proxy appends X-Forwarded-For, and the
// panel's port is published on loopback only, so the internet gets a 404:
// "unhealthy: postfix" was a free tell (security audit 2026-10-17, I6).
func handleHealth(w http.ResponseWriter, r *http.Request) {
if !localProbe(r) {
http.NotFound(w, r)
return
}
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"))
}
// localProbe is whether a request comes straight from loopback or a private
// address, not through a proxy.
func localProbe(r *http.Request) bool {
if r.Header.Get("X-Forwarded-For") != "" || r.Header.Get("Forwarded") != "" {
return false
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
return ip != nil && (ip.IsLoopback() || ip.IsPrivate())
}
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...)
}