ff4d6a6d28
Adds application accounts bound to domains: a SASL login/password in sasldb2, a per-application address mode (wildcard @domain or an explicit list), and matching smtpd_sender_login_maps bindings — with create, list, edit-mode, delete and password regeneration (spec 4.1, 5.1, 7.2.5-9). Generated passwords are shown exactly once and never stored in plaintext (7.6.1). - internal/store/applications.go: transactional CRUD; globally unique login; ListBindings (address->login) as the map source; logins-by- domain for pre-cascade SASL cleanup. - internal/app: saslpasswd2 wrapper (password via stdin, login as a whitelisted argv element, no shell — 7.6.3); strong base64url password; address validation that enforces domain ownership before any config write (7.6.2); service orchestrating store + sasldb2 + map with full rollback on partial failure. - internal/postfix: sender_login_maps regenerated as a pure function of the registry (many-to-one logins merged per address), atomic write, injection backstop (7.6.4). - Postfix reload, corrected: `postfix start-fg` forks a separate master, so signalling the supervised process never reaches it. Reload now runs the canonical `postfix reload` via a one-shot supervisord program the unprivileged panel triggers over the group control socket. Verified in mail.log. - domain.Service.Delete purges the domain's SASL accounts, then cascades, then rebuilds the sender map and reloads; manual reload now covers both OpenDKIM and Postfix. - web: application management in the domain page, one-time credential shown inline; postfix joins the selfpost group and entrypoint normalises /data/sasl and /data/postfix (setgid, group-readable) with self-heal. Verified on the dev server: gofmt/vet/test green, image builds, and a container e2e covers the full application lifecycle, domain-delete cascade, restart persistence, and a real postfix reload. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
4.6 KiB
Go
136 lines
4.6 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"
|
|
"time"
|
|
|
|
"codeberg.org/mix/selfpost/internal/app"
|
|
"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
|
|
}
|
|
|
|
// 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
|
|
|
|
loginLimiter *rateLimiter
|
|
setupLimiter *rateLimiter
|
|
}
|
|
|
|
// 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(),
|
|
// 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),
|
|
}
|
|
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()
|
|
authed.HandleFunc("GET /{$}", s.handleDashboard)
|
|
authed.HandleFunc("POST /domains", s.handleAddDomain)
|
|
authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail)
|
|
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 /applications/{aid}/mode", s.handleUpdateAppMode)
|
|
authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword)
|
|
authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication)
|
|
authed.HandleFunc("POST /reload", s.handleReload)
|
|
mux.Handle("/", s.requireAuth(authed))
|
|
|
|
return mux
|
|
}
|
|
|
|
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. It uses the transport peer
|
|
// (RemoteAddr), not client-supplied headers, so it cannot be spoofed; behind a
|
|
// reverse proxy this is the proxy address, which is an acceptable backstop for
|
|
// a single-admin panel.
|
|
func clientIP(r *http.Request) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|
|
|
|
// logf is a thin wrapper so handlers log with a consistent prefix.
|
|
func logf(format string, args ...any) {
|
|
log.Printf(format, args...)
|
|
}
|