release: 1.1.0: Domain-admin role and internal/web package split

The first MINOR after 1.0.0: send-only DMARC guidance in the panel, AGPL packaging on every page, and an internal split of `internal/web` ahead of domain-admin work. Upgrading runs one SQLite migration (empty defaults; existing DNS guidance is unchanged until you set a report address).
Changed:
  - `internal/web` split into subpackages (`web/view`, `web/auth`, `web/validate`,
Added:
  - panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
  - AGPL packaging hygiene: [NOTICE](NOTICE) names the copyright holder and the
  - [docs/roadmap.md](docs/roadmap.md) — candidate item **visual-style** (panel

Full detail: CHANGELOG.md (1.1.0).
This commit is contained in:
mix
2026-09-22 15:24:52 +03:00
parent 75207f0d1e
commit f62fe23826
93 changed files with 3256 additions and 1770 deletions
+122
View File
@@ -0,0 +1,122 @@
// Package auth implements the panel's login sessions, one-time setup flow,
// and authentication middleware.
package auth
import (
"log"
"net"
"net/http"
"strings"
"time"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/view"
)
// Config holds auth-specific panel configuration.
type Config struct {
CookieSecure bool
Hostname string
SessionIdleDays int
TrustedProxyCIDRs []*net.IPNet
}
// Module handles login, logout, setup, and session middleware.
type Module struct {
store *store.Store
cfg Config
view *view.Engine
sessions *sessionStore
setup *setupManager
loginLimiter *rateLimiter
setupLimiter *rateLimiter
trustedProxies []*net.IPNet
}
// New builds the auth module. setupTokenPath is where the current setup token
// is mirrored on disk (security.md).
func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Module {
idleDays := cfg.SessionIdleDays
if idleDays <= 0 {
idleDays = 7
}
m := &Module{
store: st,
cfg: cfg,
view: v,
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
setupLimiter: newRateLimiter(10, time.Minute),
loginLimiter: newRateLimiter(10, 15*time.Minute),
trustedProxies: cfg.TrustedProxyCIDRs,
}
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
return m
}
// Bootstrap runs once at startup. If setup is not complete it mints and
// announces the first setup token (security.md).
func (m *Module) Bootstrap() error {
return m.setup.bootstrap()
}
// AllowLoginAttempt reports whether a login or account-password change attempt
// from r is within the rate limit (security.md).
func (m *Module) AllowLoginAttempt(r *http.Request) bool {
return m.loginLimiter.Allow(clientIP(r, m.trustedProxies))
}
// SessionToken returns the session token the request carries, if exactly one
// cookie of that name is present.
func (m *Module) SessionToken(r *http.Request) (string, bool) {
return m.sessionToken(r)
}
// RenameSession updates the username carried by a session.
func (m *Module) RenameSession(token, username string) {
m.sessions.Rename(token, username)
}
// DestroyOtherSessions invalidates every session except keep.
func (m *Module) DestroyOtherSessions(keep string) {
m.sessions.DestroyOthers(keep)
}
func logf(format string, args ...any) {
log.Printf(format, args...)
}
// 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.
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
}
@@ -1,72 +1,95 @@
package web
package auth
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/view"
)
// The __Host- prefix is only valid on a Secure cookie: getting this condition
// backwards would make the development instance fail to log in at all, and
// silently — the browser discards the Set-Cookie and the panel just shows the
// login form again.
func newTestSessionStore(t *testing.T) *sessionStore {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
return newSessionStore(st, 7*24*time.Hour)
}
func mustView(t *testing.T) *view.Engine {
t.Helper()
v, err := view.New("test")
if err != nil {
t.Fatalf("view: %v", err)
}
return v
}
func testModule(t *testing.T, cookieSecure bool) *Module {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
return New(st, Config{CookieSecure: cookieSecure}, mustView(t), "")
}
func TestSessionCookieNameFollowsCookieSecure(t *testing.T) {
secure := &Server{cfg: Config{CookieSecure: true}}
secure := testModule(t, true)
if got := secure.sessionCookie(); got != "__Host-selfpost_session" {
t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got)
}
plain := &Server{cfg: Config{CookieSecure: false}}
plain := testModule(t, false)
if got := plain.sessionCookie(); got != "selfpost_session" {
t.Errorf("without TLS the cookie is named %q, want the bare name", got)
}
}
// A neighbouring host on the same registrable domain can set a cookie by the
// same name; the browser then sends both, oldest first. Picking one at random
// would leave the administrator in a login loop with no explanation, so the
// request counts as signed out instead.
func TestSessionTokenRejectsDuplicates(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: false}}
m := testModule(t, false)
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"})
if token, ok := s.sessionToken(r); ok {
if token, ok := m.sessionToken(r); ok {
t.Fatalf("duplicate cookies accepted, token = %q", token)
}
}
func TestSessionTokenReadsOneCookie(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: true}}
m := testModule(t, true)
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"})
token, ok := s.sessionToken(r)
token, ok := m.sessionToken(r)
if !ok || token != "the-real-session" {
t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok)
}
}
// A cookie under the other deployment's name is not this deployment's session:
// after an upgrade the pre-14 cookie must not be honoured as if it were the
// prefixed one.
func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: true}}
m := testModule(t, true)
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"})
if _, ok := s.sessionToken(r); ok {
if _, ok := m.sessionToken(r); ok {
t.Fatal("the unprefixed cookie was accepted on a TLS deployment")
}
}
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: false}, sessions: newTestSessionStore(t)}
token := s.sessions.Create("admin")
m := testModule(t, false)
token := m.sessions.Create("admin")
reached := false
h := s.requireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
h := m.RequireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
@@ -82,19 +105,17 @@ func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
}
}
// Signing out has to expire the cookie under both names, or the cookie left
// over from a pre-__Host- build stays in the browser for the rest of its life.
func TestLogoutClearsBothCookieNames(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: true}, sessions: newTestSessionStore(t)}
token := s.sessions.Create("admin")
m := testModule(t, true)
token := m.sessions.Create("admin")
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
r.Host = "panel.example.com"
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token})
rec := httptest.NewRecorder()
s.handleLogout(rec, r)
m.HandleLogout(rec, r)
if _, ok := s.sessions.Lookup(token); ok {
if _, ok := m.sessions.Lookup(token); ok {
t.Error("the session survived sign-out")
}
set := rec.Header().Values("Set-Cookie")
@@ -110,3 +131,59 @@ func TestLogoutClearsBothCookieNames(t *testing.T) {
}
}
}
func TestSessionRename(t *testing.T) {
s := newTestSessionStore(t)
token := s.Create("admin")
s.Rename(token, "operator")
name, ok := s.Lookup(token)
if !ok {
t.Fatal("session lost after rename")
}
if name != "operator" {
t.Fatalf("session username = %q, want %q", name, "operator")
}
}
func TestSessionDestroyOthers(t *testing.T) {
s := newTestSessionStore(t)
keep := s.Create("admin")
other := s.Create("admin")
s.DestroyOthers(keep)
if _, ok := s.Lookup(keep); !ok {
t.Fatal("current session was destroyed")
}
if _, ok := s.Lookup(other); ok {
t.Fatal("other session survived")
}
}
func TestSessionLookupRejectsExpired(t *testing.T) {
s := newTestSessionStore(t)
s.idle = -time.Minute
token := s.Create("admin")
if _, ok := s.Lookup(token); ok {
t.Fatal("expired session was accepted")
}
}
func TestSessionTouchThrottled(t *testing.T) {
s := newTestSessionStore(t)
token := s.Create("admin")
if s.Touch(token) {
t.Fatal("touch renewed a session created moments ago")
}
if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil {
t.Fatalf("renew session: %v", err)
}
if !s.Touch(token) {
t.Fatal("touch did not renew a session past the throttle window")
}
}
+239
View File
@@ -0,0 +1,239 @@
package auth
import (
"errors"
"net/http"
"strings"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookieBase = "selfpost_session"
sessionCookiePrefixed = "__Host-" + sessionCookieBase
)
func (m *Module) sessionCookie() string {
if m.cfg.CookieSecure {
return sessionCookiePrefixed
}
return sessionCookieBase
}
func (m *Module) sessionToken(r *http.Request) (string, bool) {
name := m.sessionCookie()
var token string
var n int
for _, c := range r.Cookies() {
if c.Name == name {
n++
token = c.Value
}
}
switch n {
case 0:
return "", false
case 1:
return token, true
default:
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
r.Method, r.URL.Path, n, name)
return "", false
}
}
func (m *Module) clearSessionCookies(w http.ResponseWriter) {
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: m.cfg.CookieSecure || name == sessionCookiePrefixed,
SameSite: http.SameSiteLaxMode,
})
}
}
// HandleLogin serves the login form (GET) and authenticates (POST).
func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
exists, err := m.store.AdminExists()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !exists {
m.view.Render(w, http.StatusOK, "login", map[string]any{
"Title": "SelfPost — Sign in",
"Active": "login",
"SetupHint": true,
})
return
}
switch r.Method {
case http.MethodGet:
m.renderLogin(w, http.StatusOK, "")
case http.MethodPost:
m.submitLogin(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (m *Module) renderLogin(w http.ResponseWriter, status int, formErr string) {
m.view.Render(w, status, "login", map[string]any{
"Title": "SelfPost — Sign in",
"Active": "login",
"Error": formErr,
})
}
func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
if !m.loginLimiter.Allow(clientIP(r, m.trustedProxies)) {
m.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
return
}
if err := r.ParseForm(); err != nil {
m.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
admin, err := m.store.GetAdmin()
if err != nil {
if !errors.Is(err, store.ErrNoAdmin) {
logf("panel: login: get admin failed: %v", err)
}
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return
}
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
if username != admin.Username || pwErr != nil {
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return
}
token := m.sessions.Create(admin.Username)
m.setSessionCookie(w, token)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (m *Module) setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: m.sessionCookie(),
Value: token,
Path: "/",
MaxAge: m.sessions.MaxAge(),
HttpOnly: true,
Secure: m.cfg.CookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
// HandleLogout destroys the session and clears the cookie.
func (m *Module) HandleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name := m.sessionCookie()
for _, c := range r.Cookies() {
if c.Name == name {
m.sessions.Destroy(c.Value)
}
}
m.clearSessionCookies(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// HandleSetup serves the one-time administrator creation flow at
// /setup/<token> (security.md).
func (m *Module) HandleSetup(w http.ResponseWriter, r *http.Request) {
if !m.setupLimiter.Allow(clientIP(r, m.trustedProxies)) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
token := strings.TrimPrefix(r.URL.Path, "/setup/")
if token == "" || strings.Contains(token, "/") {
http.NotFound(w, r)
return
}
if !m.setup.validate(token) {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
m.renderSetupForm(w, http.StatusOK, token, "")
case http.MethodPost:
m.submitSetup(w, r, token)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (m *Module) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
m.view.Render(w, status, "setup", map[string]any{
"Title": "SelfPost — Create administrator",
"Active": "setup",
"Token": token,
"Error": formErr,
})
}
func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
if err := r.ParseForm(); err != nil {
m.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
confirm := r.PostFormValue("password_confirm")
if err := validate.Username(username); err != nil {
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
return
}
if password != confirm {
m.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
return
}
if err := validate.AdminPassword(password); err != nil {
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: setup: hashing password failed: %v", err)
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return
}
if err := m.store.CreateAdmin(username, string(hash)); err != nil {
if exists, _ := m.store.AdminExists(); exists {
m.setup.complete()
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
logf("panel: setup: create admin failed: %v", err)
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return
}
m.setup.complete()
logf("panel: administrator %q created; setup link is now disabled", username)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
+45
View File
@@ -0,0 +1,45 @@
package auth
import (
"context"
"net/http"
)
type ctxKey int
const usernameKey ctxKey = 0
// RequireAuth wraps a handler so only requests with a valid session cookie
// reach it; everyone else is redirected to the login page. The authenticated
// username is stashed in the request context for downstream handlers.
func (m *Module) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := m.sessionToken(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
username, ok := m.sessions.Lookup(token)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if isSessionActivity(r) && m.sessions.Touch(token) {
m.setSessionCookie(w, token)
}
ctx := context.WithValue(r.Context(), usernameKey, username)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func isSessionActivity(r *http.Request) bool {
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
}
// CurrentUser returns the authenticated username from the request context.
func CurrentUser(r *http.Request) string {
if v, ok := r.Context().Value(usernameKey).(string); ok {
return v
}
return ""
}
@@ -1,4 +1,4 @@
package web
package auth
import (
"sync"
@@ -6,9 +6,7 @@ import (
)
// rateLimiter is a simple fixed-window per-key counter used to throttle the
// setup and login routes (security.md). Keys are client IPs. It is not a
// precise sliding window — a coarse backstop against brute-force and log noise
// is all these routes need.
// setup and login routes (security.md). Keys are client IPs.
type rateLimiter struct {
max int
window time.Duration
@@ -30,8 +28,6 @@ func newRateLimiter(max int, window time.Duration) *rateLimiter {
}
}
// Allow records an attempt for key and reports whether it is within the limit.
// The current window is reset lazily once it elapses.
func (r *rateLimiter) Allow(key string) bool {
now := time.Now()
r.mu.Lock()
@@ -50,9 +46,6 @@ func (r *rateLimiter) Allow(key string) bool {
return true
}
// sweep drops expired buckets so the map cannot grow without bound. Called
// under the lock while a window is being reset, which is often enough given the
// low request volume of these routes.
func (r *rateLimiter) sweep(now time.Time) {
for k, b := range r.buckets {
if now.After(b.windowEnds) {
@@ -1,4 +1,4 @@
package web
package auth
import (
"crypto/sha256"
@@ -22,10 +22,7 @@ const renewThreshold = time.Hour
// cookie.
type sessionStore struct {
store *store.Store
// idle is the sliding inactivity window (PANEL_SESSION_IDLE_DAYS). There is
// no absolute cap: an administrator who keeps coming back stays signed in
// indefinitely, deliberately.
idle time.Duration
idle time.Duration
}
func newSessionStore(st *store.Store, idle time.Duration) *sessionStore {
@@ -51,10 +48,6 @@ func (s *sessionStore) Create(username string) string {
if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
logf("panel: session: create failed: %v", err)
}
// Opportunistic cleanup: a session nobody ever came back to otherwise sits
// in the table forever. Piggybacking on Create (the one write every login
// already pays for) avoids a dedicated background sweep for what is, on a
// single-admin panel, a handful of rows at most.
if _, err := s.store.DeleteExpiredSessions(now); err != nil {
logf("panel: session: prune expired failed: %v", err)
}
@@ -86,10 +79,7 @@ func (s *sessionStore) Lookup(token string) (string, bool) {
}
// Touch extends a session's sliding expiry if it has been at least
// renewThreshold since the last extension, and reports whether it did so —
// the caller uses that to decide whether the response needs a fresh
// Set-Cookie. It assumes the caller has just confirmed the session is valid
// (e.g. via Lookup); it does nothing for a token that no longer exists.
// renewThreshold since the last extension, and reports whether it did so.
func (s *sessionStore) Touch(token string) bool {
hash := hashToken(token)
row, found, err := s.store.LookupSession(hash)
@@ -100,8 +90,6 @@ func (s *sessionStore) Touch(token string) bool {
if !found {
return false
}
// expiresAt = lastRenewal + idle, so this recovers when the session was
// last extended without a separate column.
lastRenewal := row.ExpiresAt.Add(-s.idle)
now := time.Now()
if now.Sub(lastRenewal) < renewThreshold {
@@ -114,19 +102,14 @@ func (s *sessionStore) Touch(token string) bool {
return true
}
// Rename updates the username carried by a session, keeping its expiry. It is
// used when the administrator renames their own account so the current
// session keeps working under the new name.
// Rename updates the username carried by a session, keeping its expiry.
func (s *sessionStore) Rename(token, username string) {
if err := s.store.RenameSession(hashToken(token), username); err != nil {
logf("panel: session: rename failed: %v", err)
}
}
// DestroyOthers invalidates every session except keep. It is called when the
// administrator changes their password: a stolen cookie issued under the old
// password must stop working, while the admin performing the change stays
// signed in.
// DestroyOthers invalidates every session except keep.
func (s *sessionStore) DestroyOthers(keep string) {
if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil {
logf("panel: session: destroy others failed: %v", err)
@@ -1,4 +1,4 @@
package web
package auth
import (
"crypto/subtle"
@@ -32,8 +32,6 @@ func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager
return &setupManager{store: st, hostname: hostname, tokenPath: tokenPath}
}
// bootstrap runs once at startup. If setup is already complete it clears any
// stale token file; otherwise it mints and announces the first token.
func (m *setupManager) bootstrap() error {
done, err := m.store.AdminExists()
if err != nil {
@@ -49,9 +47,6 @@ func (m *setupManager) bootstrap() error {
return nil
}
// activeToken returns the current valid setup token, regenerating and
// re-announcing it if none exists or it has expired. It returns ("", false)
// once setup is complete — callers must treat that as "route gone" (404).
func (m *setupManager) activeToken() (string, bool) {
done, err := m.store.AdminExists()
if err != nil {
@@ -69,10 +64,6 @@ func (m *setupManager) activeToken() (string, bool) {
return m.token, true
}
// validate reports whether provided matches the active token, using a
// constant-time comparison to avoid leaking a correct prefix via timing
// (security.md). A mismatch does NOT regenerate or invalidate the token: failed
// attempts must not let an attacker DoS a legitimate setup (security.md).
func (m *setupManager) validate(provided string) bool {
token, ok := m.activeToken()
if !ok {
@@ -81,8 +72,6 @@ func (m *setupManager) validate(provided string) bool {
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
}
// complete marks setup as finished: the admin row now exists, so drop the
// in-memory token and remove the on-disk copy.
func (m *setupManager) complete() {
m.mu.Lock()
m.token = ""
@@ -91,16 +80,12 @@ func (m *setupManager) complete() {
m.clearTokenFile()
}
// regenerateLocked mints a fresh token, announces it and mirrors it to disk.
// Caller holds m.mu.
func (m *setupManager) regenerateLocked() {
m.token = randomToken(16) // 128 bits of entropy (security.md)
m.token = randomToken(16)
m.expiresAt = time.Now().Add(setupTokenTTL)
m.announce(m.token)
}
// announce prints the setup link to the container log and writes it to the
// token file so it can be read either way (security.md).
func (m *setupManager) announce(token string) {
url := m.setupURL(token)
logf("panel: ==================================================================")
@@ -112,7 +97,6 @@ func (m *setupManager) announce(token string) {
if m.tokenPath == "" {
return
}
// 0600: the token is a bearer secret for creating the admin.
if err := os.WriteFile(m.tokenPath, []byte(url+"\n"), 0o600); err != nil {
logf("panel: setup: could not write token file %s: %v", m.tokenPath, err)
}
@@ -1,4 +1,4 @@
package web
package auth
import (
"crypto/rand"
+66
View File
@@ -0,0 +1,66 @@
// Package handlers implements the panel's authenticated HTTP handlers.
package handlers
import (
"log"
"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"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/view"
)
// Config holds handler-specific panel configuration.
type Config struct {
Hostname string
SubmissionEnabled bool
MailLogPath string
DataDir string
DBPath string
Version string
TLSCertFile string
OpenDKIMSocket string
JournalSocket string
}
// Handlers holds dependencies for authenticated panel routes.
type Handlers struct {
store *store.Store
domains *domain.Service
apps *app.Service
cfg Config
view *view.Engine
dns *dnscheck.Checker
machine *health.MachineSampler
auth *auth.Module
}
// New builds authenticated panel handlers.
func New(
st *store.Store,
domains *domain.Service,
apps *app.Service,
cfg Config,
v *view.Engine,
dns *dnscheck.Checker,
machine *health.MachineSampler,
a *auth.Module,
) *Handlers {
return &Handlers{
store: st,
domains: domains,
apps: apps,
cfg: cfg,
view: v,
dns: dns,
machine: machine,
auth: a,
}
}
func logf(format string, args ...any) {
log.Printf(format, args...)
}
+209
View File
@@ -0,0 +1,209 @@
package handlers
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate"
"golang.org/x/crypto/bcrypt"
)
// HandleAccount serves the administrator's own account settings: the username
// and password chosen during setup are the only panel credentials
// (security.md), and until now they could be changed only by recreating the
// state. Changing them here never touches application SASL logins, which are a
// separate identity system (architecture.md § Mail path).
func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
admin, err := h.store.GetAdmin()
if err != nil {
logf("panel: account: get admin failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderAccount(w, r, http.StatusOK, "", admin.Username, admin.DMARCReportEmail)
case http.MethodPost:
h.submitAccount(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// renderAccount draws the settings form. formUsername and formDMARCEmail
// repopulate fields after a rejected submission; password fields are never
// repopulated.
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string) {
var reportAuth dnscheck.Result
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
reportAuth = h.dns.ReportAuth(ctx, hub)
cancel()
}
h.view.Render(w, status, "account", map[string]any{
"Title": "SelfPost — settings",
"User": auth.CurrentUser(r),
"Active": "account",
"FormUsername": formUsername,
"FormDMARCEmail": formDMARCEmail,
"ReportAuthName": dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail)),
"ReportAuthExample": dnscheck.ReportAuthExample(),
"ReportAuthDNS": reportAuth,
"ReportAuthHub": dnscheck.EmailDomain(formDMARCEmail),
"Error": formErr,
"Flash": accountFlash(r),
})
}
// accountFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func accountFlash(r *http.Request) string {
switch r.URL.Query().Get("updated") {
case "username":
return "Username changed."
case "password":
return "Password changed. Any other signed-in sessions were signed out."
case "both":
return "Username and password changed. Any other signed-in sessions were signed out."
case "email":
return "DMARC report address updated."
case "username-email":
return "Username and DMARC report address updated."
case "password-email":
return "Password and DMARC report address updated. Any other signed-in sessions were signed out."
case "all":
return "Settings updated. Any other signed-in sessions were signed out."
default:
return ""
}
}
// submitAccount applies a username and/or password change. The current password
// is always required, so a stolen session alone cannot lock the administrator
// out of their own panel, and the attempt is throttled on the same limiter as
// the login form so this route cannot be used to brute-force the password past
// that limit (security.md).
func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
if !h.auth.AllowLoginAttempt(r) {
h.renderAccount(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "")
return
}
if err := r.ParseForm(); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
current := r.PostFormValue("current_password")
password := r.PostFormValue("new_password")
confirm := r.PostFormValue("new_password_confirm")
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
admin, err := h.store.GetAdmin()
if err != nil {
logf("panel: account: get admin failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if username == "" {
username = admin.Username
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail)
return
}
renaming := username != admin.Username
if renaming {
if err := validate.Username(username); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
return
}
}
if err := validate.Email(dmarcEmail); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
return
}
emailChanging := dmarcEmail != admin.DMARCReportEmail
repassword := password != "" || confirm != ""
if repassword {
if password != confirm {
h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail)
return
}
if err := validate.AdminPassword(password); err != nil {
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
return
}
}
if !renaming && !repassword && !emailChanging {
h.renderAccount(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail)
return
}
hash := admin.PasswordHash
if repassword {
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: account: hashing password failed: %v", err)
h.renderAccount(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username, dmarcEmail)
return
}
hash = string(newHash)
}
if err := h.store.UpdateAdmin(username, hash, dmarcEmail); err != nil {
logf("panel: account: update admin failed: %v", err)
msg := "Could not save the changes. Please check the logs and try again."
if errors.Is(err, store.ErrNoAdmin) {
msg = "There is no administrator account to update."
}
h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail)
return
}
if token, ok := h.auth.SessionToken(r); ok {
if renaming {
h.auth.RenameSession(token, username)
}
if repassword {
h.auth.DestroyOtherSessions(token)
}
}
logf("panel: administrator account updated (username: %t, password: %t, dmarc email: %t)", renaming, repassword, emailChanging)
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
}
func updatedFlag(renamed, repassword, emailChanged bool) string {
switch {
case renamed && repassword && emailChanged:
return "all"
case renamed && emailChanged:
return "username-email"
case repassword && emailChanged:
return "password-email"
case renamed && repassword:
return "both"
case renamed:
return "username"
case repassword:
return "password"
default:
return "email"
}
}
@@ -1,4 +1,4 @@
package web
package handlers
import (
"errors"
@@ -10,6 +10,8 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate"
)
// newCred carries a freshly generated login/password to the template so it can
@@ -48,28 +50,28 @@ type appRateLimitView struct {
WindowVal string // window seconds, defaulted when unset
}
// handleDomainDetail shows a single domain: its DKIM DNS record (product.md)
// HandleDomainDetail shows a single domain: its DKIM DNS record (product.md)
// and its applications with the controls to add, edit, delete and re-issue
// credentials (product.md).
func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
func (h *Handlers) HandleDomainDetail(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
}
// renderDomainDetail renders the domain page. view supplies request-specific
// extras (form error/values, a one-time credential); everything else is loaded
// fresh from the stores so the page always reflects committed state.
func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
record, err := s.domains.DKIMRecord(d)
func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
record, err := h.domains.DKIMRecord(d)
if err != nil {
logf("panel: domain %d: dkim record: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
apps, err := s.apps.List(d.ID)
apps, err := h.apps.List(d.ID)
if err != nil {
logf("panel: domain %d: list applications: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
@@ -77,7 +79,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
}
appViews := make([]appRateLimitView, 0, len(apps))
for _, a := range apps {
rl, ok, err := s.apps.RateLimit(a.ID)
rl, ok, err := h.apps.RateLimit(a.ID)
if err != nil {
logf("panel: application %d: rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
@@ -92,7 +94,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
})
}
domainRL, domainRLok, err := s.domains.RateLimit(d.ID)
domainRL, domainRLok, err := h.domains.RateLimit(d.ID)
if err != nil {
logf("panel: domain %d: rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
@@ -102,11 +104,39 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
// What DNS actually publishes for the domain today, checked against the key
// this server signs with. Cached by the checker, so re-rendering the page
// after a form post costs nothing.
dns, srv := s.domainDNS(d, record, false)
admin, err := h.store.GetAdmin()
if err != nil {
logf("panel: domain %d: get admin: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
profileEmail := admin.DMARCReportEmail
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
dns, srv := h.domainDNS(d, record, profileEmail, false)
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
dmarcMode := "inherit"
dmarcCustom := ""
if d.DMARCRua.Valid {
if d.DMARCRua.String == "" {
dmarcMode = "none"
} else {
dmarcMode = "custom"
dmarcCustom = d.DMARCRua.String
}
}
dmarcSource := "policy"
switch {
case dmarcMode == "custom":
dmarcSource = "custom"
case dmarcMode == "none":
dmarcSource = "none"
case profileEmail != "":
dmarcSource = "settings"
}
s.render(w, status, "domain_detail", map[string]any{
h.view.Render(w, status, "domain_detail", map[string]any{
"Title": "SelfPost — " + d.Name,
"User": currentUser(r),
"User": auth.CurrentUser(r),
"Active": "domains",
"Domain": d,
"Record": record,
@@ -116,14 +146,23 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
// this server expects rather than leaving it to the documentation. The
// same builders phrase the suggestions in the check messages, so the
// page and the checks below it never recommend different records.
"SPFExample": dnscheck.SPFExample(s.cfg.Hostname, srv.IPs),
"SPFExample": dnscheck.SPFExample(h.cfg.Hostname, srv.IPs),
"DMARCName": dnscheck.DMARCRecordName(d.Name),
"DMARCExample": dnscheck.DMARCExample(d.Name),
"DMARCExample": dnscheck.DMARCExample(reportEmail),
"DMARCSource": dmarcSource,
"ProfileDMARCEmail": profileEmail,
"ResolvedDMARCEmail": reportEmail,
"DMARCRuaMode": dmarcMode,
"DMARCRuaCustom": dmarcCustom,
"ReportAuthName": reportAuthName,
"ReportAuthValue": reportAuthValue,
"NeedsReportAuth": needsReportAuth,
"SameDomainRUA": reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name),
// Client connection settings (the same for every domain on this
// instance): the hostname clients connect to, and whether the optional
// submission listener is enabled in this deployment.
"Hostname": s.cfg.Hostname,
"SubmissionEnabled": s.cfg.SubmissionEnabled,
"Hostname": h.cfg.Hostname,
"SubmissionEnabled": h.cfg.SubmissionEnabled,
"Apps": appViews,
"Error": view.FormErr,
"FormLogin": view.FormLogin,
@@ -135,7 +174,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
"List": store.AddressModeList,
"RateLimitErr": view.RateLimitErr,
"ExportErr": view.ExportErr,
"MinPwLen": minSecretFilePasswordLen,
"MinPwLen": validate.MinSecretFilePasswordLen,
"DomainHasRL": domainRLok && domainRL.Active(),
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
"DomainRLMax": intOrBlank(domainRL.MaxMessages),
@@ -149,31 +188,38 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
// and no extra environment variable is needed. That server result is returned
// alongside, because the page's suggested SPF record is built from the same
// addresses. force bypasses the cache, for the Re-check button.
func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, force bool) (dnscheck.Domain, dnscheck.Server) {
srv := s.dns.Server(s.cfg.Hostname, false)
return s.dns.Domain(dnscheck.Query{
Name: d.Name,
Selector: d.DKIMSelector,
ExpectedDKIM: record.Value,
Hostname: srv.Hostname,
ServerIPs: srv.IPs,
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
srv := h.dns.Server(h.cfg.Hostname, false)
return h.dns.Domain(dnscheck.Query{
Name: d.Name,
Selector: d.DKIMSelector,
ExpectedDKIM: record.Value,
Hostname: srv.Hostname,
ServerIPs: srv.IPs,
DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail),
}, force), srv
}
// handleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
// HandleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
// returns to its page, which then renders the fresh result.
func (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
record, err := s.domains.DKIMRecord(d)
record, err := h.domains.DKIMRecord(d)
if err != nil {
logf("panel: domain %d: dkim record: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.domainDNS(d, record, true)
admin, err := h.store.GetAdmin()
if err != nil {
logf("panel: domain %d: get admin: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.domainDNS(d, record, admin.DMARCReportEmail, true)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
@@ -205,6 +251,8 @@ func detailFlash(r *http.Request) string {
return "Application address mode updated."
case r.URL.Query().Get("ratelimit") != "":
return "Rate limit updated."
case r.URL.Query().Get("dmarc") != "":
return "DMARC report settings updated."
case r.URL.Query().Get("imported") != "":
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
case r.URL.Query().Get("rechecked") != "":
@@ -214,17 +262,17 @@ func detailFlash(r *http.Request) string {
}
}
// handleAddApplication creates an application on a domain and renders the page
// HandleAddApplication creates an application on a domain and renders the page
// back with the generated password shown once (product.md, security.md). Because the
// password cannot be recovered later, this deliberately renders inline rather
// than redirecting.
func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
func (h *Handlers) HandleAddApplication(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
s.renderDomainDetail(w, r, http.StatusBadRequest, d,
h.renderDomainDetail(w, r, http.StatusBadRequest, d,
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
return
}
@@ -238,25 +286,25 @@ func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
FormAddrs: r.PostFormValue("addresses"),
}
a, password, err := s.apps.Create(d.ID, login, mode, addrs)
a, password, err := h.apps.Create(d.ID, login, mode, addrs)
if err != nil {
repopulate.FormErr = applicationErrorMessage(err)
status := http.StatusBadRequest
if errors.Is(err, store.ErrLoginExists) {
status = http.StatusConflict
}
s.renderDomainDetail(w, r, status, d, repopulate)
h.renderDomainDetail(w, r, status, d, repopulate)
return
}
s.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password},
})
}
// handleUpdateAppMode switches an application's address mode / list (product.md).
func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
// HandleUpdateAppMode switches an application's address mode / list (product.md).
func (h *Handlers) HandleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
@@ -267,13 +315,13 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
mode := r.PostFormValue("mode")
addrs := splitAddresses(r.PostFormValue("addresses"))
if err := s.apps.UpdateMode(a.ID, mode, addrs); err != nil {
d, derr := s.domains.Get(a.DomainID)
if err := h.apps.UpdateMode(a.ID, mode, addrs); err != nil {
d, derr := h.domains.Get(a.DomainID)
if derr != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
FormMode: store.AddressModeWildcard,
})
@@ -282,38 +330,38 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
}
// handleRegenPassword issues a new password for an application and shows it once
// HandleRegenPassword issues a new password for an application and shows it once
// (product.md, security.md). Rendered inline, like creation, so the password is visible.
func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
d, err := s.domains.Get(a.DomainID)
d, err := h.domains.Get(a.DomainID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
password, err := s.apps.RegeneratePassword(a.ID)
password, err := h.apps.RegeneratePassword(a.ID)
if err != nil {
logf("panel: regenerate password for application %d: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{
FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password},
})
}
// handleDeleteApplication removes an application and returns to its domain page
// HandleDeleteApplication removes an application and returns to its domain page
// (product.md).
func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
if err := s.apps.Delete(a.ID); err != nil {
if err := h.apps.Delete(a.ID); err != nil {
logf("panel: delete application %d: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
@@ -323,13 +371,13 @@ func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request)
// lookupApplication resolves the {aid} path value to an application, writing a
// 404 for a bad id or missing application.
func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
func (h *Handlers) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return store.Application{}, false
}
a, err := s.apps.Get(id)
a, err := h.apps.Get(id)
if err != nil {
if errors.Is(err, store.ErrApplicationNotFound) {
http.NotFound(w, r)
@@ -1,4 +1,4 @@
package web
package handlers
import (
"bytes"
@@ -13,6 +13,8 @@ import (
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate"
)
// maxImportBytes caps a domain-import upload. A domain export is a small JSON
@@ -22,35 +24,35 @@ import (
// covers both forms.
const maxImportBytes = 1 << 20 // 1 MiB
// handleBackupPage renders the backup/migration screen: the full-server backup
// HandleBackupPage renders the backup/migration screen: the full-server backup
// and the domain import are separate actions with different risk, so each gets
// its own card here rather than sharing a block on the domain list.
func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) {
s.renderBackupPage(w, r, http.StatusOK, "")
func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) {
h.renderBackupPage(w, r, http.StatusOK, "")
}
// renderBackupPage draws the page; importErr surfaces a failed domain import
// (architecture.md § Persistence) next to the form that produced it.
func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
s.renderBackupPageWith(w, r, status, importErr, "")
func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
h.renderBackupPageWith(w, r, status, importErr, "")
}
// renderBackupPageWith is renderBackupPage with the second of the page's two
// error slots: backupErr belongs to the full-backup card (a rejected encryption
// password), importErr to the import card, so neither message appears under the
// wrong form.
func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
s.render(w, status, "backup", map[string]any{
func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
h.view.Render(w, status, "backup", map[string]any{
"Title": "SelfPost — backup",
"User": currentUser(r),
"User": auth.CurrentUser(r),
"Active": "backup",
"ImportErr": importErr,
"BackupErr": backupErr,
"MinPwLen": minSecretFilePasswordLen,
"MinPwLen": validate.MinSecretFilePasswordLen,
})
}
// handleBackup streams a full-server backup as a download (architecture.md §
// HandleBackup streams a full-server backup as a download (architecture.md §
// Persistence). It is an authenticated admin action (this handler sits behind
// the auth middleware). The archive carries DKIM private keys, the admin
// password hash and SASL credentials, so it is served with no-store and as an
@@ -58,10 +60,10 @@ func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, st
// "encrypt with a password", the archive is wrapped in a .spbk envelope on the
// way out, so the file that lands on their disk — wherever it is copied
// afterwards — is useless without the password.
func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
password, pwErr := secretFilePassword(r)
if pwErr != "" {
s.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
return
}
@@ -99,9 +101,9 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
}
if err := backup.Create(sink, backup.Params{
DataDir: s.cfg.DataDir,
DBPath: s.cfg.DBPath,
Version: s.cfg.Version,
DataDir: h.cfg.DataDir,
DBPath: h.cfg.DBPath,
Version: h.cfg.Version,
}); err != nil {
logf("panel: full backup failed: %v", err)
return
@@ -113,26 +115,26 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
}
}
// handleExportDomain streams a single-domain export as a secret download
// HandleExportDomain streams a single-domain export as a secret download
// (architecture.md § Persistence). Like the full backup it is POST-only (state
// is not changed, but the response contains the domain's DKIM private key and
// application passwords, so it must not be prefetchable or cached). Like the
// full backup it can be encrypted with a password, in which case the download
// is a .spde envelope instead of plain JSON.
func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
func (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
password, pwErr := secretFilePassword(r)
if pwErr != "" {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
ExportErr: pwErr,
})
return
}
exp, err := s.domains.Export(d.ID)
exp, err := h.domains.Export(d.ID)
if err != nil {
logf("panel: export domain %d: %v", d.ID, err)
http.Error(w, "export failed", http.StatusInternalServerError)
@@ -174,22 +176,22 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body)
}
// handleImportDomain accepts an uploaded domain-export file and re-creates the
// HandleImportDomain accepts an uploaded domain-export file and re-creates the
// domain on this instance (architecture.md § Persistence). The domain name is
// normalised and validated here (security.md); the domain service validates
// the selector, each login and address, and the DKIM key before writing
// anything. On success it redirects to the new domain's page; on failure it
// re-renders the backup page, where the import form lives, with a friendly
// message.
func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
if err := r.ParseMultipartForm(maxImportBytes); err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).")
h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).")
return
}
file, _, err := r.FormFile("file")
if err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.")
h.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.")
return
}
defer file.Close()
@@ -201,7 +203,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
head := make([]byte, secretfile.MagicLen)
n, err := io.ReadFull(file, head)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.")
h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.")
return
}
source := io.MultiReader(bytes.NewReader(head[:n]), file)
@@ -209,16 +211,16 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
if secretfile.HasMagic(head[:n]) {
if password == "" {
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.")
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.")
return
}
env, err := secretfile.NewReader(source, password)
if err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
return
}
if env.Type() != secretfile.TypeDomainExport {
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.")
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.")
return
}
// Read the whole plaintext first: authentication of the last chunk is
@@ -226,12 +228,12 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
// accept a truncated document before ever reaching it.
plain, err := io.ReadAll(env)
if err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
return
}
source = bytes.NewReader(plain)
} else if password != "" {
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.")
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.")
return
}
@@ -239,23 +241,23 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
dec := json.NewDecoder(source)
dec.DisallowUnknownFields()
if err := dec.Decode(&exp); err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
h.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.")
return
}
// Normalise and validate the domain name before it reaches the service, the
// same gate the add-domain form uses (security.md).
exp.Domain = normalizeDomain(exp.Domain)
if err := validateDomain(exp.Domain); err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
exp.Domain = validate.NormalizeDomain(exp.Domain)
if err := validate.Domain(exp.Domain); err != nil {
h.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
return
}
d, err := s.domains.Import(exp)
d, err := h.domains.Import(exp)
if err != nil {
logf("panel: import domain %q: %v", exp.Domain, err)
status, msg := importErrorMessage(err)
s.renderBackupPage(w, r, status, msg)
h.renderBackupPage(w, r, status, msg)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
@@ -276,8 +278,8 @@ func secretFilePassword(r *http.Request) (password, errMsg string) {
return "", ""
}
password = r.PostFormValue("password")
if len([]rune(password)) < minSecretFilePasswordLen {
return "", fmt.Sprintf("The encryption password must be at least %d characters.", minSecretFilePasswordLen)
if len([]rune(password)) < validate.MinSecretFilePasswordLen {
return "", fmt.Sprintf("The encryption password must be at least %d characters.", validate.MinSecretFilePasswordLen)
}
if password != r.PostFormValue("password_confirm") {
return "", "The two passwords do not match."
@@ -1,4 +1,4 @@
package web
package handlers
import (
"errors"
@@ -10,6 +10,7 @@ import (
"testing"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/web/validate"
)
// postForm builds the kind of request the backup and export forms submit.
@@ -24,8 +25,8 @@ func postForm(values url.Values) *http.Request {
// the archive is sealed — and leaving the box unticked has to keep producing
// the plain archive earlier versions produced.
func TestSecretFilePassword(t *testing.T) {
long := strings.Repeat("x", minSecretFilePasswordLen)
short := strings.Repeat("x", minSecretFilePasswordLen-1)
long := strings.Repeat("x", validate.MinSecretFilePasswordLen)
short := strings.Repeat("x", validate.MinSecretFilePasswordLen-1)
tests := []struct {
name string
@@ -104,13 +105,9 @@ func TestDecryptErrorMessage(t *testing.T) {
// forgets to include the partial (or the data it needs) loses the option
// silently, since the plain download still works.
func TestBackupPageOffersEncryption(t *testing.T) {
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
s := &Server{tmpl: tmpl, cfg: Config{Version: "test"}}
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
rec := httptest.NewRecorder()
s.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
h.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
http.StatusOK, "", "The two passwords do not match.")
body := rec.Body.String()
@@ -118,7 +115,7 @@ func TestBackupPageOffersEncryption(t *testing.T) {
`name="encrypt"`, `name="password"`, `name="password_confirm"`,
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
"data-import-password-fields",
fmt.Sprintf("at least %d characters", minSecretFilePasswordLen),
fmt.Sprintf("at least %d characters", validate.MinSecretFilePasswordLen),
"The two passwords do not match.",
} {
if !strings.Contains(body, want) {
+57
View File
@@ -0,0 +1,57 @@
package handlers
import (
"database/sql"
"fmt"
"net/http"
"strings"
"github.com/mixeme/selfpost/internal/web/validate"
)
// HandleDomainDMARC saves per-domain DMARC rua= settings.
func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Invalid form submission."})
return
}
var rua sql.NullString
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
case "inherit":
rua = sql.NullString{}
case "none":
rua = sql.NullString{Valid: true, String: ""}
case "custom":
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
if err := validate.Email(email); err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: err.Error()})
return
}
if email == "" {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
return
}
rua = sql.NullString{Valid: true, String: email}
default:
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
return
}
if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil {
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
return
}
h.dns.Forget(d.Name)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
}
+186
View File
@@ -0,0 +1,186 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"sync"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate"
)
// domainRow is one line of the domain list: the stored domain plus the rolled-up
// verdict of its published DNS records, so the operator sees which domains still
// need a record published without opening each one.
type domainRow struct {
store.Domain
DNS health.Status
}
// HandleDashboard is the authenticated landing page: the list of sending
// domains with their DKIM/selector and application counts, plus the add-domain
// form (product.md).
func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) {
h.renderDashboard(w, r, http.StatusOK, "", "")
}
func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
domains, err := h.domains.List()
if err != nil {
logf("panel: dashboard: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.view.Render(w, status, "dashboard", map[string]any{
"Title": "SelfPost",
"User": auth.CurrentUser(r),
"Active": "domains",
"Domains": h.domainRows(domains),
"Error": formErr,
"FormName": formName,
"Flash": dashboardFlash(r),
})
}
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
profileEmail := ""
if admin, err := h.store.GetAdmin(); err == nil {
profileEmail = admin.DMARCReportEmail
}
rows := make([]domainRow, len(domains))
var wg sync.WaitGroup
for i, d := range domains {
rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
wg.Add(1)
go func() {
defer wg.Done()
record, err := h.domains.DKIMRecord(d)
if err != nil {
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, profileEmail, false)
rows[i].DNS = dns.Overall
}()
}
wg.Wait()
return rows
}
func dashboardFlash(r *http.Request) string {
if r.URL.Query().Get("deleted") != "" {
return "Domain deleted."
}
return ""
}
// HandleAddDomain validates the submitted name, creates the domain (DKIM key +
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (product.md).
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
return
}
raw := r.PostFormValue("name")
name := validate.NormalizeDomain(raw)
if err := validate.Domain(name); err != nil {
h.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
return
}
d, err := h.domains.Add(name)
if err != nil {
if errors.Is(err, store.ErrDomainExists) {
h.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
return
}
logf("panel: add domain %q: %v", name, err)
h.renderDashboard(w, r, http.StatusInternalServerError,
"Could not add the domain. Please check the logs and try again.", raw)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
}
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
h.view.Render(w, http.StatusOK, "domain_delete", map[string]any{
"Title": "SelfPost — delete " + d.Name,
"User": auth.CurrentUser(r),
"Active": "domains",
"Domain": d,
})
}
// HandleDeleteDomain performs the deletion and returns to the domain list.
func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
id, ok := parseDomainID(w, r)
if !ok {
return
}
if d, err := h.domains.Get(id); err == nil {
defer h.dns.Forget(d.Name)
}
if err := h.domains.Delete(id); err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
return
}
logf("panel: delete domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
}
// HandleReload re-applies both the OpenDKIM configuration and the Postfix
// sender map on demand (architecture.md § Panel HTTP surface).
func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
if err := h.domains.Resync(); err != nil {
logf("panel: manual reload (opendkim): %v", err)
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
if err := h.apps.Resync(); err != nil {
logf("panel: manual reload (postfix): %v", err)
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
}
func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
id, ok := parseDomainID(w, r)
if !ok {
return store.Domain{}, false
}
d, err := h.domains.Get(id)
if err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
return store.Domain{}, false
}
logf("panel: get domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return store.Domain{}, false
}
return d, true
}
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return 0, false
}
return id, true
}
@@ -1,4 +1,4 @@
package web
package handlers
import (
"errors"
@@ -12,6 +12,7 @@ import (
"github.com/mixeme/selfpost/internal/mailhdr"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
// sendLogPageSize bounds each send-log page (product.md's monitoring screens
@@ -24,38 +25,38 @@ const (
deliveryLogLines = 200
)
// handleDeliveries renders the Deliveries page over the send log: server-side
// HandleDeliveries renders the Deliveries page over the send log: server-side
// filters by domain/application and pagination (architecture.md §
// Persistence). The row table itself is the "deliveries_rows" fragment, shared
// verbatim with handleDeliveriesRows so the initial page and its HTMX-polled
// verbatim with HandleDeliveriesRows so the initial page and its HTMX-polled
// refreshes never diverge.
func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) {
data, err := h.sendLogData(r)
if err != nil {
logf("panel: send log: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data["Title"] = "SelfPost — deliveries"
data["User"] = currentUser(r)
data["User"] = auth.CurrentUser(r)
data["Active"] = "deliveries"
s.render(w, http.StatusOK, "deliveries", data)
h.view.Render(w, http.StatusOK, "deliveries", data)
}
// handleDeliveriesRows serves the HTMX polling fragment for the delivery table
// HandleDeliveriesRows serves the HTMX polling fragment for the delivery table
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
// JSON).
func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r)
func (h *Handlers) HandleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
data, err := h.sendLogData(r)
if err != nil {
logf("panel: send log rows: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderFragment(w, http.StatusOK, "deliveries_rows", data)
h.view.RenderFragment(w, http.StatusOK, "deliveries_rows", data)
}
// handleDelivery renders one send-log row in full. The log itself carries only
// HandleDelivery renders one send-log row in full. The log itself carries only
// what identifies a message at a glance — when, who to and from, what about,
// how it ended — and every remaining field (domain, application, queue id, when
// the status was last reported) lives here, one page per row, so widening the
@@ -67,13 +68,13 @@ func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
// its history side by side, and the mail.log lines for its queue id under both.
// The queue id used to be printed here as something to go and search the system
// log for by hand; the search is done for the operator instead.
func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return
}
row, err := s.store.GetSendLog(id)
row, err := h.store.GetSendLog(id)
if err != nil {
// A row pruned on the retention window is gone, not broken.
if errors.Is(err, store.ErrSendLogNotFound) {
@@ -85,10 +86,10 @@ func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) {
return
}
row.Subject = mailhdr.DecodeSubject(row.Subject)
logRows, logNote := s.deliveryLog(row)
s.render(w, http.StatusOK, "delivery", map[string]any{
logRows, logNote := h.deliveryLog(row)
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
"Title": "SelfPost — delivery",
"User": currentUser(r),
"User": auth.CurrentUser(r),
"Active": "deliveries",
"Row": row,
// The status in the panel's own badge vocabulary, so the headline reads
@@ -223,11 +224,11 @@ type deliveryLogRow struct {
// have aged out of the log — so none of them is an error on the page. Only a
// log that cannot be read at all is reported as a fault, and that one is
// logged for the operator as well.
func (s *Server) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) {
func (h *Handlers) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) {
if row.QueueID == "" {
return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it."
}
lines, err := logtail.QueueLines(s.cfg.MailLogPath, row.QueueID, deliveryLogLines)
lines, err := logtail.QueueLines(h.cfg.MailLogPath, row.QueueID, deliveryLogLines)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
logf("panel: delivery log %s: %v", row.QueueID, err)
return nil, "Could not read the mail log."
@@ -267,7 +268,7 @@ func deliveriesBackURL(r *http.Request) string {
// sendLogData reads the domain/app filters and page number off the query
// string, queries the store, and assembles everything the template needs
// (filter dropdown options plus the current selection, rows, and pagination).
func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
q := r.URL.Query()
filter := store.SendLogFilter{
Domain: q.Get("domain"),
@@ -275,11 +276,11 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
}
page := parsePage(q.Get("p"))
total, err := s.store.CountSendLog(filter)
total, err := h.store.CountSendLog(filter)
if err != nil {
return nil, err
}
rows, err := s.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
if err != nil {
return nil, err
}
@@ -289,7 +290,7 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
for i := range rows {
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
}
domains, err := s.store.ListDomains()
domains, err := h.store.ListDomains()
if err != nil {
return nil, err
}
@@ -297,7 +298,7 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
for i, d := range domains {
domainNames[i] = d.Name
}
logins, err := s.store.ListApplicationLogins()
logins, err := h.store.ListApplicationLogins()
if err != nil {
return nil, err
}
@@ -331,23 +332,23 @@ func parsePage(v string) int {
return n
}
// handleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
// surface).
func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
out, errText := readQueue()
s.render(w, http.StatusOK, "mail_queue", map[string]any{
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
"Title": "SelfPost — mail queue",
"User": currentUser(r),
"User": auth.CurrentUser(r),
"Active": "mail_queue",
"Output": out,
"Error": errText,
})
}
// handleMailQueueBody serves the HTMX polling fragment for the queue view.
func (s *Server) handleMailQueueBody(w http.ResponseWriter, r *http.Request) {
// HandleMailQueueBody serves the HTMX polling fragment for the queue view.
func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) {
out, errText := readQueue()
s.renderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{
"Output": out,
"Error": errText,
})
@@ -365,30 +366,30 @@ func readQueue() (string, string) {
return out, ""
}
// handleSystemLog renders the System log page over mail.log (architecture.md §
// HandleSystemLog renders the System log page over mail.log (architecture.md §
// Panel HTTP surface).
func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail()
s.render(w, http.StatusOK, "system_log", map[string]any{
func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
lines, errText := h.readLogTail()
h.view.Render(w, http.StatusOK, "system_log", map[string]any{
"Title": "SelfPost — system log",
"User": currentUser(r),
"User": auth.CurrentUser(r),
"Active": "system_log",
"Lines": lines,
"Error": errText,
})
}
// handleSystemLogBody serves the HTMX polling fragment for the log-tail view.
func (s *Server) handleSystemLogBody(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail()
s.renderFragment(w, http.StatusOK, "system_log_body", map[string]any{
// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view.
func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
lines, errText := h.readLogTail()
h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
"Lines": lines,
"Error": errText,
})
}
func (s *Server) readLogTail() ([]string, string) {
lines, err := logtail.TailLines(s.cfg.MailLogPath, logTailLines)
func (h *Handlers) readLogTail() ([]string, string) {
lines, err := logtail.TailLines(h.cfg.MailLogPath, logTailLines)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
// Rotation renamed the file away; Postfix recreates it on reload
@@ -396,7 +397,7 @@ func (s *Server) readLogTail() ([]string, string) {
// than a failure worth alarming the operator about.
return nil, ""
}
logf("panel: tail %s: %v", s.cfg.MailLogPath, err)
logf("panel: tail %s: %v", h.cfg.MailLogPath, err)
return nil, "Could not read the mail log."
}
return lines, ""
@@ -1,4 +1,4 @@
package web
package handlers
import (
"net/http"
@@ -16,9 +16,9 @@ import (
// recreate it on reload (spec B.2); a missing file in that window is a normal,
// transient gap, not an operator-facing failure.
func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
s := &Server{cfg: Config{MailLogPath: filepath.Join(t.TempDir(), "mail.log")}}
h := &Handlers{cfg: Config{MailLogPath: filepath.Join(t.TempDir(), "mail.log")}}
lines, errText := s.readLogTail()
lines, errText := h.readLogTail()
if lines != nil {
t.Errorf("lines = %v, want nil", lines)
}
@@ -31,9 +31,9 @@ func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
// when, from, to, subject and status, and links each row to the page carrying
// the rest. A column added back here is one the table has no width for.
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
s, row := serverWithDelivery(t)
h, row := serverWithDelivery(t)
out := getBody(t, s.handleDeliveries, "/deliveries")
out := getBody(t, h.HandleDeliveries, "/deliveries")
for _, want := range []string{
row.CreatedAt.Format("2006-01-02 15:04:05"),
"noreply@bs.example.ru", "public@example.ru",
@@ -56,11 +56,11 @@ func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
// decoded them, and those rows are still in the send log. Decoding on the way
// out is what keeps them readable, so the encoding must not survive to the page.
func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
s, _ := serverWithDelivery(t)
h, _ := serverWithDelivery(t)
for name, out := range map[string]string{
"log": getBody(t, s.handleDeliveries, "/deliveries"),
"rows": getBody(t, s.handleDeliveriesRows, "/deliveries/rows"),
"log": getBody(t, h.HandleDeliveries, "/deliveries"),
"rows": getBody(t, h.HandleDeliveriesRows, "/deliveries/rows"),
} {
if strings.Contains(out, "=?utf-8?Q?") {
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out)
@@ -74,9 +74,9 @@ func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
// Everything the log dropped has to be somewhere, and that somewhere is the
// per-row page — including for a row still holding an encoded subject.
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
s, row := serverWithDelivery(t)
h, row := serverWithDelivery(t)
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
for _, want := range []string{
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
"noreply@bs.example.ru", "public@example.ru", "sent",
@@ -95,9 +95,9 @@ func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
// journal holds, stated as the steps they stand for, so a row is readable as
// what happened to the message rather than as a list of fields.
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
s, row := serverWithDelivery(t)
h, row := serverWithDelivery(t)
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
for _, want := range []string{
"Accepted and queued", "Delivered",
row.CreatedAt.Format("2006-01-02 15:04:05"),
@@ -121,19 +121,19 @@ func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
// for is drawn as one that has not happened rather than dated with the moment
// the row was written.
func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
s, _ := serverWithDelivery(t)
if err := s.store.InsertQueued(store.SendLogEntry{
h, _ := serverWithDelivery(t)
if err := h.store.InsertQueued(store.SendLogEntry{
QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C",
From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going",
}); err != nil {
t.Fatalf("insert: %v", err)
}
rows, err := s.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
rows, err := h.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(rows[0].ID))
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(rows[0].ID))
for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} {
if !strings.Contains(out, want) {
t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out)
@@ -146,14 +146,14 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
// lines — as a table of when and what, so the seconds between the connection
// and the reply line up down one edge.
func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
s, row := serverWithDelivery(t)
s.cfg.MailLogPath = writeMailLog(t,
h, row := serverWithDelivery(t)
h.cfg.MailLogPath = writeMailLog(t,
"2026-08-03T05:15:52.219218+00:00 host postfix/smtpd[20]: 4A1B2C3D: client=mail.example.com[203.0.113.4]",
"2026-08-03T05:15:52.300000+00:00 host postfix/qmgr[10]: 99999999: from=<other@example.ru>, size=500, nrcpt=1 (queue active)",
"2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, dsn=2.0.0, status=sent (250 OK)",
)
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
for _, want := range []string{
"<th>Time</th>", "<th>Message</th>",
// The stamp is split off into its own cell, without the microseconds
@@ -174,10 +174,10 @@ func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
// A line whose head is not a timestamp still has to show in full; the format is
// the log's, not ours, and a line we cannot split is a line we must not drop.
func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
s, row := serverWithDelivery(t)
s.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)")
h, row := serverWithDelivery(t)
h.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)")
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "host postfix/smtp[26]: 4A1B2C3D: to=&lt;public@example.ru&gt;, status=sent (250 OK)") {
t.Errorf("an unstamped log line did not survive the split into columns:\n%s", out)
}
@@ -186,10 +186,10 @@ func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
// Rows outlive mail.log, and a message the milter refused never reached the
// queue at all. Neither is a fault, so neither may render as an error.
func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
s, row := serverWithDelivery(t)
s.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
h, row := serverWithDelivery(t)
h.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "rotated away") {
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
}
@@ -201,13 +201,13 @@ func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
// Send-log rows are pruned on the retention window, so a bookmarked delivery
// that no longer exists is a 404, not a 500.
func TestDeliveryPageNotFound(t *testing.T) {
s, _ := serverWithDelivery(t)
h, _ := serverWithDelivery(t)
for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
s.handleDelivery(rec, req)
h.HandleDelivery(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", path, rec.Code)
}
@@ -216,7 +216,7 @@ func TestDeliveryPageNotFound(t *testing.T) {
// serverWithDelivery builds a panel over a store holding one delivery, written
// the way the journal-milter wrote them before it decoded subjects itself.
func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
@@ -242,11 +242,7 @@ func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
return &Server{store: st, tmpl: tmpl, cfg: Config{Version: "test"}}, rows[0]
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0]
}
// getBody runs one handler over a GET and returns the page it wrote, failing
@@ -1,4 +1,4 @@
package web
package handlers
import (
"fmt"
@@ -10,15 +10,8 @@ import (
"github.com/mixeme/selfpost/internal/store"
)
// defaultRateLimitWindowSeconds is the sliding-window length used when an
// admin sets a message ceiling but leaves the window blank (guide § Rate
// limiting, matching the level-1 default hour; guide § Environment variables:
// RATE_LIMIT_WINDOW_SECONDS).
const defaultRateLimitWindowSeconds = 3600
// rateLimitInput is the validated result of a rate-limit form submission.
// clear means "remove the differentiated limit" (guide § Rate limiting: an
// empty IP binding leaves only level 1).
type rateLimitInput struct {
clear bool
ips []string
@@ -26,10 +19,6 @@ type rateLimitInput struct {
windowSeconds int
}
// parseRateLimitForm validates a rate-limit submission on the server
// (security.md). It returns clear=true when the admin removes the limit or
// leaves the IP binding empty; otherwise it requires a positive ceiling and
// window. The returned error's message is safe to show to the admin.
func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission")
@@ -42,8 +31,6 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
return rateLimitInput{}, err
}
if len(ips) == 0 {
// No IP binding: the differentiated limit does not apply (guide § Rate
// limiting).
return rateLimitInput{clear: true}, nil
}
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
@@ -57,10 +44,6 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
return rateLimitInput{ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
}
// parseIPList parses the allowed-IP field (IPs separated by newlines, commas or
// whitespace) into a deduplicated list of canonical addresses, rejecting any
// token that is not a valid IP (security.md). The values are only ever stored as
// SQLite parameters and compared in the milter, never written to a config file.
func parseIPList(raw string) ([]string, error) {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
@@ -81,8 +64,6 @@ func parseIPList(raw string) ([]string, error) {
return out, nil
}
// parsePositiveInt parses a trimmed integer field, returning def when it is
// blank. A non-numeric value returns an error.
func parsePositiveInt(raw string, def int) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -91,23 +72,20 @@ func parsePositiveInt(raw string, def int) (int, error) {
return strconv.Atoi(raw)
}
// handleDomainRateLimit saves or clears a domain-level differentiated rate
// limit (guide § Rate limiting). No reload is needed — the milter reads the
// row live.
func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
in, err := parseRateLimitForm(r)
if err != nil {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: err.Error(),
})
return
}
if err := s.applyRateLimit(in, s.domains.SaveRateLimit, s.domains.ClearRateLimit, d.ID); err != nil {
if err := h.applyRateLimit(in, h.domains.SaveRateLimit, h.domains.ClearRateLimit, d.ID); err != nil {
logf("panel: domain %d: save rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
@@ -115,27 +93,25 @@ func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
}
// handleAppRateLimit saves or clears an application-level differentiated rate
// limit (guide § Rate limiting).
func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
d, err := s.domains.Get(a.DomainID)
d, err := h.domains.Get(a.DomainID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
in, err := parseRateLimitForm(r)
if err != nil {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
})
return
}
if err := s.applyRateLimit(in, s.apps.SaveRateLimit, s.apps.ClearRateLimit, a.ID); err != nil {
if err := h.applyRateLimit(in, h.apps.SaveRateLimit, h.apps.ClearRateLimit, a.ID); err != nil {
logf("panel: application %d: save rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
@@ -143,9 +119,7 @@ func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
}
// applyRateLimit dispatches a validated input to the save or clear method of the
// relevant service, keyed by the domain or application id.
func (s *Server) applyRateLimit(
func (h *Handlers) applyRateLimit(
in rateLimitInput,
save func(id int64, ips []string, maxMessages, windowSeconds int) error,
clear func(id int64) error,
+111
View File
@@ -0,0 +1,111 @@
package handlers
import (
"net/http"
"strings"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/web/auth"
)
func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) {
data := h.statusBody()
srv := h.dns.Server(h.cfg.Hostname, false)
data["Title"] = "SelfPost — status"
data["User"] = auth.CurrentUser(r)
data["Active"] = "status"
data["Flash"] = statusFlash(r)
data["Hostname"] = h.cfg.Hostname
data["PTR"] = srv.PTR
h.view.Render(w, http.StatusOK, "status", data)
}
func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, _ *http.Request) {
h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody())
}
func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) {
h.dns.Server(h.cfg.Hostname, true)
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
}
func (h *Handlers) statusBody() map[string]any {
procs, procErr := health.Processes()
procStatus := health.StatusUnknown
if procErr != nil {
logf("panel: status: supervisorctl: %v", procErr)
} else {
for _, p := range procs {
procStatus = health.Worst(procStatus, p.Status)
}
}
queueText, queueErr := readQueue()
queueStatus := health.StatusOK
if queueErr != "" {
queueStatus = health.StatusWarn
}
cert := health.CheckCertificate(h.cfg.TLSCertFile)
sockets := []health.Socket{
health.CheckSocket("OpenDKIM", h.cfg.OpenDKIMSocket, true),
health.CheckSocket("send-log", h.cfg.JournalSocket, false),
}
socketStatus := health.StatusUnknown
for _, sock := range sockets {
socketStatus = health.Worst(socketStatus, sock.Status)
}
machine := h.machine.Sample()
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
return map[string]any{
"Processes": procs,
"ProcessError": procErr != nil,
"ProcessStatus": procStatus,
"QueueSummary": queueSummary(queueText),
"QueueError": queueErr,
"QueueStatus": queueStatus,
"Machine": machine,
"Cert": cert,
"Sockets": sockets,
"SocketStatus": socketStatus,
"OverallStatus": overall,
"OverallHeading": overallHeading(overall),
}
}
func queueSummary(out string) string {
lines := strings.Split(strings.TrimSpace(out), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
return strings.TrimSpace(strings.TrimPrefix(line, "--"))
}
}
return ""
}
func overallHeading(worst health.Status) string {
switch worst {
case health.StatusError:
return "A component needs attention — see the details below."
case health.StatusWarn:
return "Running, with warnings below."
case health.StatusOK:
return "All components are running normally."
default:
return "Some checks could not be performed."
}
}
func statusFlash(r *http.Request) string {
switch {
case r.URL.Query().Get("reloaded") != "":
return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked."
default:
return ""
}
}
+16
View File
@@ -0,0 +1,16 @@
package handlers
import (
"testing"
"github.com/mixeme/selfpost/internal/web/view"
)
func mustView(t *testing.T) *view.Engine {
t.Helper()
v, err := view.New("test")
if err != nil {
t.Fatalf("view: %v", err)
}
return v
}
-168
View File
@@ -1,168 +0,0 @@
package web
import (
"errors"
"net/http"
"strings"
"github.com/mixeme/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
// handleAccount serves the administrator's own account settings: the username
// and password chosen during setup are the only panel credentials
// (security.md), and until now they could be changed only by recreating the
// state. Changing them here never touches application SASL logins, which are a
// separate identity system (architecture.md § Mail path).
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.renderAccount(w, r, http.StatusOK, "", currentUser(r))
case http.MethodPost:
s.submitAccount(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// renderAccount draws the settings form. formUsername repopulates the username
// field after a rejected submission; the password fields are never repopulated.
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
s.render(w, status, "account", map[string]any{
"Title": "SelfPost — settings",
"User": currentUser(r),
"Active": "account",
"FormUsername": formUsername,
"Error": formErr,
"Flash": accountFlash(r),
})
}
// accountFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func accountFlash(r *http.Request) string {
switch r.URL.Query().Get("updated") {
case "username":
return "Username changed."
case "password":
return "Password changed. Any other signed-in sessions were signed out."
case "both":
return "Username and password changed. Any other signed-in sessions were signed out."
default:
return ""
}
}
// submitAccount applies a username and/or password change. The current password
// is always required, so a stolen session alone cannot lock the administrator
// out of their own panel, and the attempt is throttled on the same limiter as
// the login form so this route cannot be used to brute-force the password past
// that limit (security.md).
func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
s.renderAccount(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", currentUser(r))
return
}
if err := r.ParseForm(); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r))
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
current := r.PostFormValue("current_password")
password := r.PostFormValue("new_password")
confirm := r.PostFormValue("new_password_confirm")
admin, err := s.store.GetAdmin()
if err != nil {
logf("panel: account: get admin failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if username == "" {
username = admin.Username
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username)
return
}
renaming := username != admin.Username
if renaming {
if err := validateUsername(username); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
return
}
}
// An empty pair of new-password fields means "leave the password alone", so
// the username can be changed on its own.
repassword := password != "" || confirm != ""
if repassword {
if password != confirm {
s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username)
return
}
if err := validateAdminPassword(password); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
return
}
}
if !renaming && !repassword {
s.renderAccount(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, a new password, or both.", username)
return
}
hash := admin.PasswordHash
if repassword {
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: account: hashing password failed: %v", err)
s.renderAccount(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username)
return
}
hash = string(newHash)
}
if err := s.store.UpdateAdmin(username, hash); err != nil {
logf("panel: account: update admin failed: %v", err)
msg := "Could not save the changes. Please check the logs and try again."
if errors.Is(err, store.ErrNoAdmin) {
msg = "There is no administrator account to update."
}
s.renderAccount(w, r, http.StatusInternalServerError, msg, username)
return
}
// Keep this session usable under the new name, and — when the password
// changed — drop every other session so a cookie captured under the old
// password stops working.
if token, ok := s.sessionToken(r); ok {
if renaming {
s.sessions.Rename(token, username)
}
if repassword {
s.sessions.DestroyOthers(token)
}
}
logf("panel: administrator account updated (username changed: %t, password changed: %t)", renaming, repassword)
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword), http.StatusSeeOther)
}
// updatedFlag names what changed, for the fixed post-redirect flash message.
func updatedFlag(renamed, repassword bool) string {
switch {
case renamed && repassword:
return "both"
case renamed:
return "username"
default:
return "password"
}
}
-198
View File
@@ -1,198 +0,0 @@
package web
import (
"errors"
"net/http"
"strings"
"github.com/mixeme/selfpost/internal/store"
"golang.org/x/crypto/bcrypt"
)
// The panel session cookie's two possible names. In the production shape —
// TLS in front, so CookieSecure — it carries the __Host- prefix, which turns
// what the cookie's attributes merely promise into something the browser
// enforces: Secure, Path=/ and, the point of the exercise, no Domain
// attribute, so no other host may set a cookie by this name.
// The prefix is only valid on a Secure cookie, so a development instance on
// plain HTTP has to keep the bare name: with the prefix the browser would
// discard the Set-Cookie outright and logging in would silently never stick.
const (
sessionCookieBase = "selfpost_session"
sessionCookiePrefixed = "__Host-" + sessionCookieBase
)
// sessionCookie is the session cookie's name for this deployment.
func (s *Server) sessionCookie() string {
if s.cfg.CookieSecure {
return sessionCookiePrefixed
}
return sessionCookieBase
}
// sessionToken returns the session token the request carries, if exactly one
// cookie of that name is present.
//
// It walks r.Cookies() rather than calling r.Cookie, which silently returns
// the first match. Two cookies with the same name mean somebody other than
// this panel set one of them — a host on the same registrable domain can,
// with Domain=example.com, and the browser will then send both — and RFC 6265
// makes the older one come first, so "the first match" is precisely the
// attacker's. The value cannot be forged into a valid session, so the effect
// is denial of service, not compromise; refusing the request and saying so in
// the log is what makes it diagnosable instead of an endless login loop. The
// __Host- prefix prevents this outright, but only where it applies — this
// check also covers the plain-HTTP development shape.
func (s *Server) sessionToken(r *http.Request) (string, bool) {
name := s.sessionCookie()
var token string
var n int
for _, c := range r.Cookies() {
if c.Name == name {
n++
token = c.Value
}
}
switch n {
case 0:
return "", false
case 1:
return token, true
default:
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
r.Method, r.URL.Path, n, name)
return "", false
}
}
// clearSessionCookies expires the session cookie under both names, so an
// upgrade that switches to the __Host- prefix does not leave the old cookie
// sitting in the browser until it is closed.
func (s *Server) clearSessionCookies(w http.ResponseWriter) {
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
// The prefixed name is only accepted at all when Secure is set,
// including on the expiring copy.
Secure: s.cfg.CookieSecure || name == sessionCookiePrefixed,
SameSite: http.SameSiteLaxMode,
})
}
}
// handleLogin serves the login form (GET) and authenticates (POST). Until an
// administrator exists there is nobody to log in, so it points at setup.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
exists, err := s.store.AdminExists()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !exists {
// No admin yet: login is meaningless. Send a clear message rather than
// a failing form.
s.render(w, http.StatusOK, "login", map[string]any{
"Title": "SelfPost — Sign in",
"Active": "login",
"SetupHint": true,
})
return
}
switch r.Method {
case http.MethodGet:
s.renderLogin(w, http.StatusOK, "")
case http.MethodPost:
s.submitLogin(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) {
// Active names the page for the layout even though no navigation is drawn
// here: it is what puts page-login on <main>, which the stylesheet uses to
// give the signed-out pages a column the width of their own card.
s.render(w, status, "login", map[string]any{
"Title": "SelfPost — Sign in",
"Active": "login",
"Error": formErr,
})
}
func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
// Brute-force throttle by client IP (security.md).
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
return
}
if err := r.ParseForm(); err != nil {
s.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
admin, err := s.store.GetAdmin()
if err != nil {
if !errors.Is(err, store.ErrNoAdmin) {
logf("panel: login: get admin failed: %v", err)
}
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return
}
// Always run bcrypt so timing does not distinguish "wrong user" from
// "wrong password", and compare the username too.
pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password))
if username != admin.Username || pwErr != nil {
s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
return
}
token := s.sessions.Create(admin.Username)
s.setSessionCookie(w, token)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// setSessionCookie (re)issues the session cookie with a fresh Max-Age equal
// to the sliding idle window (plan B.1), so the browser-side expiry tracks
// whatever the database row was just set to — at login, and again whenever
// requireAuth extends an active session.
func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: s.sessionCookie(),
Value: token,
Path: "/",
MaxAge: s.sessions.MaxAge(),
HttpOnly: true,
Secure: s.cfg.CookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
// handleLogout destroys the session and clears the cookie.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Destroy every token presented under the session cookie's name: if a
// shadowing duplicate is present (see sessionToken) one of them is the
// real session, and a value that names no session is simply not found.
name := s.sessionCookie()
for _, c := range r.Cookies() {
if c.Name == name {
s.sessions.Destroy(c.Value)
}
}
s.clearSessionCookies(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
-206
View File
@@ -1,206 +0,0 @@
package web
import (
"errors"
"fmt"
"net/http"
"strconv"
"sync"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
)
// domainRow is one line of the domain list: the stored domain plus the rolled-up
// verdict of its published DNS records, so the operator sees which domains still
// need a record published without opening each one.
type domainRow struct {
store.Domain
DNS health.Status
}
// handleDashboard is the authenticated landing page: the list of sending
// domains with their DKIM/selector and application counts, plus the add-domain
// form (product.md).
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
s.renderDashboard(w, r, http.StatusOK, "", "")
}
// renderDashboard renders the domain list. formErr and formName repopulate the
// add-domain form after a rejected submission; flash surfaces a one-shot status
// message keyed by a redirect query flag (never reflected user input).
func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
domains, err := s.domains.List()
if err != nil {
logf("panel: dashboard: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.render(w, status, "dashboard", map[string]any{
"Title": "SelfPost",
"User": currentUser(r),
"Active": "domains",
"Domains": s.domainRows(domains),
"Error": formErr,
"FormName": formName,
"Flash": dashboardFlash(r),
})
}
// domainRows attaches each domain's DNS verdict to its row. The checks run
// concurrently rather than one after another: each carries its own timeout, so
// in series a dead resolver would multiply that wait by the number of domains
// and the list would look hung. The checker caches results for a few minutes,
// so a repeat view of the list costs no lookups at all, and it is the same
// cache the domain page fills — opening a domain after the list is free.
func (s *Server) domainRows(domains []store.Domain) []domainRow {
rows := make([]domainRow, len(domains))
var wg sync.WaitGroup
for i, d := range domains {
rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
wg.Add(1)
go func() {
defer wg.Done()
record, err := s.domains.DKIMRecord(d)
if err != nil {
// Without the expected key there is nothing to compare DNS
// against; leave the row unknown rather than accusing the
// domain of a misconfiguration this server caused.
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
return
}
dns, _ := s.domainDNS(d, record, false)
rows[i].DNS = dns.Overall
}()
}
wg.Wait()
return rows
}
// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func dashboardFlash(r *http.Request) string {
if r.URL.Query().Get("deleted") != "" {
return "Domain deleted."
}
return ""
}
// handleAddDomain validates the submitted name, creates the domain (DKIM key +
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (product.md).
func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
return
}
raw := r.PostFormValue("name")
name := normalizeDomain(raw)
if err := validateDomain(name); err != nil {
s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
return
}
d, err := s.domains.Add(name)
if err != nil {
if errors.Is(err, store.ErrDomainExists) {
s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
return
}
logf("panel: add domain %q: %v", name, err)
s.renderDashboard(w, r, http.StatusInternalServerError,
"Could not add the domain. Please check the logs and try again.", raw)
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
}
// handleDeleteConfirm shows the cascade warning before a domain is removed: the
// panel must explicitly state that all bound applications go with it (product.md).
func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok {
return
}
s.render(w, http.StatusOK, "domain_delete", map[string]any{
"Title": "SelfPost — delete " + d.Name,
"User": currentUser(r),
"Active": "domains",
"Domain": d,
})
}
// handleDeleteDomain performs the deletion (cascade + DKIM key + OpenDKIM reload)
// and returns to the domain list.
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
id, ok := parseDomainID(w, r)
if !ok {
return
}
// Drop any cached DNS verdict for the name while it is still resolvable, so
// re-adding the domain later starts from a fresh check instead of a stale
// one from before it was removed.
if d, err := s.domains.Get(id); err == nil {
defer s.dns.Forget(d.Name)
}
if err := s.domains.Delete(id); err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
return
}
logf("panel: delete domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
}
// handleReload re-applies both the OpenDKIM configuration and the Postfix
// sender map on demand (architecture.md § Panel HTTP surface). Each Resync
// regenerates its files from the database and reloads its daemon, so the
// button doubles as a drift-recovery. The button lives on the status page: it
// is a "put the daemons back in the state the database describes" action,
// which belongs with the rest of the server-health screen rather than in the
// domain list's top bar.
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
if err := s.domains.Resync(); err != nil {
logf("panel: manual reload (opendkim): %v", err)
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
if err := s.apps.Resync(); err != nil {
logf("panel: manual reload (postfix): %v", err)
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
}
// lookupDomain resolves the {id} path value to a domain, writing a 404 for a
// bad id or a missing domain and reporting ok=false in that case.
func (s *Server) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
id, ok := parseDomainID(w, r)
if !ok {
return store.Domain{}, false
}
d, err := s.domains.Get(id)
if err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
return store.Domain{}, false
}
logf("panel: get domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return store.Domain{}, false
}
return d, true
}
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return 0, false
}
return id, true
}
+40
View File
@@ -0,0 +1,40 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/legal"
)
func TestLicenseHandlerServesAGPL(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/license", nil)
handleLicense(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
ct := rec.Header().Get("Content-Type")
if !strings.HasPrefix(ct, "text/plain") {
t.Errorf("Content-Type = %q, want text/plain", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "GNU AFFERO GENERAL PUBLIC LICENSE") {
t.Error("response is missing the AGPL title")
}
if body != string(legal.License) {
t.Error("response body does not match legal.License")
}
}
func TestLicenseHandlerRejectsNonGET(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/license", nil)
handleLicense(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code)
}
}
-99
View File
@@ -1,99 +0,0 @@
package web
import (
"net/http"
"strings"
"golang.org/x/crypto/bcrypt"
)
// handleSetup serves the one-time administrator creation flow at
// /setup/<token> (security.md). Once an administrator exists the whole route
// returns 404; an invalid or expired token is indistinguishable from a missing
// page, also 404.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
// Route-specific rate limit, separate from login (security.md).
if !s.setupLimiter.Allow(clientIP(r, s.trustedProxies)) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
token := strings.TrimPrefix(r.URL.Path, "/setup/")
// Reject nested/garbage paths outright.
if token == "" || strings.Contains(token, "/") {
http.NotFound(w, r)
return
}
if !s.setup.validate(token) {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
s.renderSetupForm(w, http.StatusOK, token, "")
case http.MethodPost:
s.submitSetup(w, r, token)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
s.render(w, status, "setup", map[string]any{
"Title": "SelfPost — Create administrator",
"Active": "setup",
"Token": token,
"Error": formErr,
})
}
func (s *Server) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
if err := r.ParseForm(); err != nil {
s.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
confirm := r.PostFormValue("password_confirm")
if err := validateUsername(username); err != nil {
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
return
}
if password != confirm {
s.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
return
}
if err := validateAdminPassword(password); err != nil {
s.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
logf("panel: setup: hashing password failed: %v", err)
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return
}
if err := s.store.CreateAdmin(username, string(hash)); err != nil {
// A concurrent submission may have already created the admin; the
// id=1 / non-empty-table guard makes this the second writer. Treat it
// as "setup already done" rather than an error.
if exists, _ := s.store.AdminExists(); exists {
s.setup.complete()
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
logf("panel: setup: create admin failed: %v", err)
s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return
}
// Setup is now permanently complete: burn the token (security.md).
s.setup.complete()
logf("panel: administrator %q created; setup link is now disabled", username)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
-140
View File
@@ -1,140 +0,0 @@
package web
import (
"net/http"
"strings"
"github.com/mixeme/selfpost/internal/health"
)
// handleStatus renders the server status page: the panel's landing page and the
// one screen that answers "is the service healthy and will mail be accepted".
// The cheap local checks live in the polled "status_body"
// fragment; the hostname/PTR lookup and the configuration reload sit outside it,
// because neither belongs on a five-second timer.
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
data := s.statusBody()
srv := s.dns.Server(s.cfg.Hostname, false)
data["Title"] = "SelfPost — status"
data["User"] = currentUser(r)
data["Active"] = "status"
data["Flash"] = statusFlash(r)
data["Hostname"] = s.cfg.Hostname
data["PTR"] = srv.PTR
s.render(w, http.StatusOK, "status", data)
}
// handleStatusFragment serves the HTMX polling fragment for the local checks
// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
// JSON).
func (s *Server) handleStatusFragment(w http.ResponseWriter, _ *http.Request) {
s.renderFragment(w, http.StatusOK, "status_body", s.statusBody())
}
// handleStatusRecheck forces a fresh hostname/PTR lookup, bypassing the cache,
// and returns to the page. DNS is the one part of this screen that talks to the
// network, so it refreshes on demand rather than with the poll.
func (s *Server) handleStatusRecheck(w http.ResponseWriter, r *http.Request) {
s.dns.Server(s.cfg.Hostname, true)
http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
}
// statusBody collects the local checks the fragment renders. Each one
// reports its own problem rather than failing the page, so a broken component
// costs one line and not the whole screen.
func (s *Server) statusBody() map[string]any {
procs, procErr := health.Processes()
procStatus := health.StatusUnknown
if procErr != nil {
// Outside the container (or if the control socket is gone) there is
// nothing to report — "unknown", not "everything is broken".
logf("panel: status: supervisorctl: %v", procErr)
} else {
for _, p := range procs {
procStatus = health.Worst(procStatus, p.Status)
}
}
queueText, queueErr := readQueue()
queueStatus := health.StatusOK
if queueErr != "" {
queueStatus = health.StatusWarn
}
cert := health.CheckCertificate(s.cfg.TLSCertFile)
sockets := []health.Socket{
// OpenDKIM signs every outgoing message and Postfix is configured to
// tempfail without it: a missing socket stops mail.
health.CheckSocket("OpenDKIM", s.cfg.OpenDKIMSocket, true),
// The journal-milter only records the send log and fails open.
health.CheckSocket("send-log", s.cfg.JournalSocket, false),
}
socketStatus := health.StatusUnknown
for _, sock := range sockets {
socketStatus = health.Worst(socketStatus, sock.Status)
}
// Resource usage of the machine underneath. It is graded like the rest —
// a processor that is fully busy or a machine out of memory delays or
// kills the mail path — so it counts towards the headline verdict, and
// its rates are measured against the previous poll (internal/health).
machine := s.machine.Sample()
overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status)
return map[string]any{
"Processes": procs,
"ProcessError": procErr != nil,
"ProcessStatus": procStatus,
"QueueSummary": queueSummary(queueText),
"QueueError": queueErr,
"QueueStatus": queueStatus,
"Machine": machine,
"Cert": cert,
"Sockets": sockets,
"SocketStatus": socketStatus,
"OverallStatus": overall,
"OverallHeading": overallHeading(overall),
}
}
// queueSummary reduces postqueue's listing to the one line worth showing on
// the status page; the full listing has its own screen (architecture.md §
// Panel HTTP surface). postqueue prints either "Mail queue is empty" or a
// trailing "-- N Kbytes in M Requests."
func queueSummary(out string) string {
lines := strings.Split(strings.TrimSpace(out), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
return strings.TrimSpace(strings.TrimPrefix(line, "--"))
}
}
return ""
}
// overallHeading turns the worst check into the page's one-line verdict.
func overallHeading(worst health.Status) string {
switch worst {
case health.StatusError:
return "A component needs attention — see the details below."
case health.StatusWarn:
return "Running, with warnings below."
case health.StatusOK:
return "All components are running normally."
default:
return "Some checks could not be performed."
}
}
// statusFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func statusFlash(r *http.Request) string {
switch {
case r.URL.Query().Get("reloaded") != "":
return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it."
case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked."
default:
return ""
}
}
-55
View File
@@ -1,55 +0,0 @@
package web
import (
"context"
"net/http"
)
type ctxKey int
const usernameKey ctxKey = 0
// requireAuth wraps a handler so only requests with a valid session cookie
// reach it; everyone else is redirected to the login page. The authenticated
// username is stashed in the request context for downstream handlers.
//
// It also extends the sliding session (plan B.1) on activity, defined as
// everything except a GET request carrying HX-Request: the four monitoring
// fragments (/status/fragment, /mail-queue/body, /system-log/body,
// /deliveries/rows)
// poll on a timer regardless of whether anyone is looking at the tab, so
// counting those as activity would make "N days idle" mean "N days since a
// browser tab was last open" instead.
func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := s.sessionToken(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
username, ok := s.sessions.Lookup(token)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if isSessionActivity(r) && s.sessions.Touch(token) {
s.setSessionCookie(w, token)
}
ctx := context.WithValue(r.Context(), usernameKey, username)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// isSessionActivity reports whether a request counts as administrator
// activity for the sliding session timeout, per requireAuth's doc comment.
func isSessionActivity(r *http.Request) bool {
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
}
// currentUser returns the authenticated username from the request context.
func currentUser(r *http.Request) string {
if v, ok := r.Context().Value(usernameKey).(string); ok {
return v
}
return ""
}
-84
View File
@@ -1,84 +0,0 @@
package web
import (
"path/filepath"
"testing"
"time"
"github.com/mixeme/selfpost/internal/store"
)
func newTestSessionStore(t *testing.T) *sessionStore {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
return newSessionStore(st, 7*24*time.Hour)
}
func TestSessionRename(t *testing.T) {
s := newTestSessionStore(t)
token := s.Create("admin")
s.Rename(token, "operator")
name, ok := s.Lookup(token)
if !ok {
t.Fatal("session lost after rename")
}
if name != "operator" {
t.Fatalf("session username = %q, want %q", name, "operator")
}
}
// A password change must invalidate every other session (so a cookie captured
// under the old password stops working) while keeping the one performing the
// change signed in.
func TestSessionDestroyOthers(t *testing.T) {
s := newTestSessionStore(t)
keep := s.Create("admin")
other := s.Create("admin")
s.DestroyOthers(keep)
if _, ok := s.Lookup(keep); !ok {
t.Fatal("current session was destroyed")
}
if _, ok := s.Lookup(other); ok {
t.Fatal("other session survived")
}
}
// A session past its sliding idle expiry must not be honoured.
func TestSessionLookupRejectsExpired(t *testing.T) {
s := newTestSessionStore(t)
s.idle = -time.Minute // already expired the instant it's created
token := s.Create("admin")
if _, ok := s.Lookup(token); ok {
t.Fatal("expired session was accepted")
}
}
// Touch must not rewrite the expiry (or report a renewal) inside the
// once-an-hour throttle window, so an active tab's polling doesn't turn into
// a database write per request.
func TestSessionTouchThrottled(t *testing.T) {
s := newTestSessionStore(t)
token := s.Create("admin")
if s.Touch(token) {
t.Fatal("touch renewed a session created moments ago")
}
// Back-date the session's last renewal by rewriting its expiry, as if it
// had been created (or last renewed) 2 hours ago rather than moments ago.
if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil {
t.Fatalf("renew session: %v", err)
}
if !s.Touch(token) {
t.Fatal("touch did not renew a session past the throttle window")
}
}
-32
View File
@@ -1,32 +0,0 @@
{{define "content"}}
<h1>Settings</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<div class="card narrow">
<h2>Panel credentials</h2>
<p class="muted">These are the credentials for this control panel only.
Applications keep their own logins and passwords, which are not affected.</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/account">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password"
autocomplete="current-password" required>
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
<label for="new_password_confirm">Confirm new password</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
<button type="submit">Save changes</button>
</form>
<p class="muted">Leave both new-password fields empty to change the username
only. Changing the password signs out every other session; this one stays
signed in.</p>
</div>
{{end}}
-107
View File
@@ -1,107 +0,0 @@
package web
import (
"fmt"
"strings"
"unicode"
)
// minAdminPasswordLen is the floor for the administrator password. The panel is
// public (security.md), so this is deliberately not tiny.
const minAdminPasswordLen = 12
const (
minUsernameLen = 3
maxUsernameLen = 64
)
// minSecretFilePasswordLen is the floor for the password protecting an
// encrypted backup or domain export. Such a file is offline and can be attacked
// at leisure, so the floor matches the administrator password's rather than the
// weaker "any password is better than none".
const minSecretFilePasswordLen = minAdminPasswordLen
// validateUsername enforces a strict server-side whitelist (security.md):
// letters, digits, dot, dash, underscore. Client validation is never trusted.
func validateUsername(u string) error {
if len(u) < minUsernameLen || len(u) > maxUsernameLen {
return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen)
}
for _, r := range u {
if r > unicode.MaxASCII || (!isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_') {
return fmt.Errorf("username may contain only letters, digits, '.', '-' and '_'")
}
}
return nil
}
// validateAdminPassword enforces a minimum length. Composition rules beyond
// length tend to reduce entropy in practice, so length is the sole gate.
func validateAdminPassword(p string) error {
if len(p) < minAdminPasswordLen {
return fmt.Errorf("password must be at least %d characters", minAdminPasswordLen)
}
return nil
}
func isASCIILetterOrDigit(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
}
const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
// normalizeDomain lower-cases and trims a domain name. Domain names are
// case-insensitive, and the generated OpenDKIM tables/keys use the canonical
// lower-case form, so we normalise before both validation and storage.
func normalizeDomain(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
// validateDomain enforces a strict server-side whitelist for sending-domain
// names (security.md). The result is safe to write verbatim into the OpenDKIM
// KeyTable/SigningTable and to use as a filesystem path segment: only
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
// shape. Input must already be normalised with normalizeDomain.
//
// This is deliberately stricter than "any string DNS might accept" — no
// leading/trailing dots or hyphens, no empty or over-long labels, and at least
// two labels so single-word hostnames cannot be registered as sending domains.
func validateDomain(name string) error {
if name == "" {
return fmt.Errorf("domain is required")
}
if len(name) > maxDomainLen {
return fmt.Errorf("domain must be at most %d characters", maxDomainLen)
}
labels := strings.Split(name, ".")
if len(labels) < 2 {
return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
}
for _, label := range labels {
if err := validateDomainLabel(label); err != nil {
return err
}
}
return nil
}
func validateDomainLabel(label string) error {
if len(label) == 0 {
return fmt.Errorf("domain must not contain an empty label")
}
if len(label) > 63 {
return fmt.Errorf("each domain label must be at most 63 characters")
}
if label[0] == '-' || label[len(label)-1] == '-' {
return fmt.Errorf("domain labels must not start or end with '-'")
}
for i := 0; i < len(label); i++ {
c := label[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
if !lower && !digit && c != '-' {
return fmt.Errorf("domain may contain only lower-case letters, digits, '.' and '-'")
}
}
return nil
}
+148
View File
@@ -0,0 +1,148 @@
// Package validate holds shared server-side form validation for the panel.
package validate
import (
"fmt"
"strings"
"unicode"
)
// MinAdminPasswordLen is the floor for the administrator password. The panel is
// public (security.md), so this is deliberately not tiny.
const MinAdminPasswordLen = 12
const (
minUsernameLen = 3
maxUsernameLen = 64
)
// MinSecretFilePasswordLen is the floor for the password protecting an
// encrypted backup or domain export. Such a file is offline and can be attacked
// at leisure, so the floor matches the administrator password's rather than the
// weaker "any password is better than none".
const MinSecretFilePasswordLen = MinAdminPasswordLen
// Username enforces a strict server-side whitelist (security.md): letters,
// digits, dot, dash, underscore. Client validation is never trusted.
func Username(u string) error {
if len(u) < minUsernameLen || len(u) > maxUsernameLen {
return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen)
}
for _, r := range u {
if r > unicode.MaxASCII || (!isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_') {
return fmt.Errorf("username may contain only letters, digits, '.', '-' and '_'")
}
}
return nil
}
// AdminPassword enforces a minimum length. Composition rules beyond length tend
// to reduce entropy in practice, so length is the sole gate.
func AdminPassword(p string) error {
if len(p) < MinAdminPasswordLen {
return fmt.Errorf("password must be at least %d characters", MinAdminPasswordLen)
}
return nil
}
func isASCIILetterOrDigit(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
}
const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
// NormalizeDomain lower-cases and trims a domain name. Domain names are
// case-insensitive, and the generated OpenDKIM tables/keys use the canonical
// lower-case form, so we normalise before both validation and storage.
func NormalizeDomain(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
// Domain enforces a strict server-side whitelist for sending-domain names
// (security.md). The result is safe to write verbatim into the OpenDKIM
// KeyTable/SigningTable and to use as a filesystem path segment: only
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
// shape. Input must already be normalised with NormalizeDomain.
func Domain(name string) error {
if name == "" {
return fmt.Errorf("domain is required")
}
if len(name) > maxDomainLen {
return fmt.Errorf("domain must be at most %d characters", maxDomainLen)
}
labels := strings.Split(name, ".")
if len(labels) < 2 {
return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
}
for _, label := range labels {
if err := domainLabel(label); err != nil {
return err
}
}
return nil
}
func domainLabel(label string) error {
if len(label) == 0 {
return fmt.Errorf("domain must not contain an empty label")
}
if len(label) > 63 {
return fmt.Errorf("each domain label must be at most 63 characters")
}
if label[0] == '-' || label[len(label)-1] == '-' {
return fmt.Errorf("domain labels must not start or end with '-'")
}
for i := 0; i < len(label); i++ {
c := label[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
if !lower && !digit && c != '-' {
return fmt.Errorf("domain may contain only lower-case letters, digits, '.' and '-'")
}
}
return nil
}
// freemailDomains lists public mail hosts that cannot publish _report._dmarc
// authorisation for third-party sending domains.
var freemailDomains = map[string]struct{}{
"gmail.com": {},
"googlemail.com": {},
"outlook.com": {},
"hotmail.com": {},
"live.com": {},
"yahoo.com": {},
"icloud.com": {},
"me.com": {},
"proton.me": {},
"protonmail.com": {},
}
// Email checks a DMARC rua= mailbox. Empty is allowed (policy-only).
func Email(addr string) error {
addr = strings.TrimSpace(addr)
if addr == "" {
return nil
}
at := strings.LastIndex(addr, "@")
if at <= 0 || at >= len(addr)-1 {
return fmt.Errorf("enter a valid email address")
}
local := addr[:at]
domain := NormalizeDomain(addr[at+1:])
if err := Domain(domain); err != nil {
return fmt.Errorf("email domain is invalid: %w", err)
}
for _, r := range local {
if r > unicode.MaxASCII {
return fmt.Errorf("email address must be ASCII")
}
if !isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_' && r != '+' {
return fmt.Errorf("email address contains invalid characters")
}
}
if _, blocked := freemailDomains[domain]; blocked {
return fmt.Errorf("use an address on a domain you control; public mail hosts cannot receive authorised DMARC reports")
}
return nil
}
@@ -0,0 +1,18 @@
package validate
import "testing"
func TestValidateEmail(t *testing.T) {
if err := Email(""); err != nil {
t.Errorf("empty: %v", err)
}
if err := Email("reports@mail.example.com"); err != nil {
t.Errorf("valid: %v", err)
}
if err := Email("bad"); err == nil {
t.Error("bad address accepted")
}
if err := Email("x@gmail.com"); err == nil {
t.Error("gmail accepted")
}
}
+67
View File
@@ -0,0 +1,67 @@
package validate
import "testing"
func TestNormalizeDomain(t *testing.T) {
cases := map[string]string{
" Example.COM ": "example.com",
"MAIL.Example.Org": "mail.example.org",
"example.com": "example.com",
}
for in, want := range cases {
if got := NormalizeDomain(in); got != want {
t.Errorf("NormalizeDomain(%q) = %q, want %q", in, got, want)
}
}
}
func TestValidateDomainValid(t *testing.T) {
valid := []string{
"example.com",
"mail.example.com",
"a.co",
"sub-domain.example.co.uk",
"x1.y2.z3",
"1example.com",
}
for _, d := range valid {
if err := Domain(d); err != nil {
t.Errorf("Domain(%q) unexpected error: %v", d, err)
}
}
}
func TestValidateDomainInvalid(t *testing.T) {
invalid := []string{
"",
"localhost",
"example",
".example.com",
"example.com.",
"exa mple.com",
"example..com",
"-example.com",
"example-.com",
"example.com\n",
"exa*mple.com",
"exa_mple.com",
"Example.com",
"пример.рф",
"example.c/m",
}
for _, d := range invalid {
if err := Domain(d); err == nil {
t.Errorf("Domain(%q) = nil, want error", d)
}
}
}
func TestValidateDomainLongLabelRejected(t *testing.T) {
label := make([]byte, 64)
for i := range label {
label[i] = 'a'
}
if err := Domain(string(label) + ".com"); err == nil {
t.Error("expected error for over-long label")
}
}
-67
View File
@@ -1,67 +0,0 @@
package web
import "testing"
func TestNormalizeDomain(t *testing.T) {
cases := map[string]string{
" Example.COM ": "example.com",
"MAIL.Example.Org": "mail.example.org",
"example.com": "example.com",
}
for in, want := range cases {
if got := normalizeDomain(in); got != want {
t.Errorf("normalizeDomain(%q) = %q, want %q", in, got, want)
}
}
}
func TestValidateDomainValid(t *testing.T) {
valid := []string{
"example.com",
"mail.example.com",
"a.co",
"sub-domain.example.co.uk",
"x1.y2.z3",
"1example.com",
}
for _, d := range valid {
if err := validateDomain(d); err != nil {
t.Errorf("validateDomain(%q) unexpected error: %v", d, err)
}
}
}
func TestValidateDomainInvalid(t *testing.T) {
invalid := []string{
"", // empty
"localhost", // single label
"example", // single label
".example.com", // leading dot -> empty label
"example.com.", // trailing dot -> empty label
"exa mple.com", // space
"example..com", // empty label
"-example.com", // label starts with '-'
"example-.com", // label ends with '-'
"example.com\n", // newline (config injection attempt)
"exa*mple.com", // disallowed char
"exa_mple.com", // underscore not allowed in domains
"Example.com", // upper-case (must be normalised first)
"пример.рф", // non-ASCII
"example.c/m", // slash (path-traversal attempt)
}
for _, d := range invalid {
if err := validateDomain(d); err == nil {
t.Errorf("validateDomain(%q) = nil, want error", d)
}
}
}
func TestValidateDomainLongLabelRejected(t *testing.T) {
label := make([]byte, 64)
for i := range label {
label[i] = 'a'
}
if err := validateDomain(string(label) + ".com"); err == nil {
t.Error("expected error for over-long label")
}
}
@@ -1,4 +1,4 @@
package web
package view
import (
"crypto/sha256"
@@ -39,7 +39,7 @@ func buildStaticETags() map[string]string {
return etags
}
// staticHandler serves the embedded assets under /static/ with a content ETag.
// StaticHandler serves the embedded assets under /static/ with a content ETag.
//
// Cache-Control is no-cache rather than a max-age: it lets the browser keep the
// copy but requires it to revalidate, so an asset that changed is picked up on
@@ -47,7 +47,7 @@ func buildStaticETags() map[string]string {
// handful of small files on a single-operator panel that trade is the right way
// round — correctness after a deploy matters more than saving the round trip.
// http.ServeContent answers the conditional request from the ETag we set here.
func staticHandler() http.Handler {
func StaticHandler() http.Handler {
files := http.FileServer(http.FS(assetsFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if etag, ok := staticETags[path.Clean(r.URL.Path)]; ok {

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

@@ -176,7 +176,12 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.back { display: block; margin-bottom: 1rem; }
/* Build version, closing every authenticated page. Quiet on purpose: it is
reference material, not something to read on the way past. */
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; }
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; line-height: 1.45; }
.version a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.version a:hover { color: var(--fg); }
/* The signed-out column is 24rem; a right-aligned multi-link notice wraps
into a ragged edge, so centre it there. */
main.page-login .version, main.page-setup .version { text-align: center; }
select, textarea {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
@@ -1,4 +1,4 @@
package web
package view
import (
"net/http"
@@ -13,7 +13,7 @@ func serveStatic(path string, headers map[string]string) *httptest.ResponseRecor
r.Header.Set(k, v)
}
rec := httptest.NewRecorder()
staticHandler().ServeHTTP(rec, r)
StaticHandler().ServeHTTP(rec, r)
return rec
}
+68
View File
@@ -0,0 +1,68 @@
{{define "content"}}
<h1>Settings</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<div class="card narrow">
<h2>Panel credentials</h2>
<p class="muted">These are the credentials for this control panel only.
Applications keep their own logins and passwords, which are not affected.</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/account">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username"
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password"
autocomplete="current-password" required>
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
<label for="new_password_confirm">Confirm new password</label>
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
<h3>DMARC aggregate reports</h3>
<p class="muted">Optional default <code>rua=</code> address for every sending
domain (can be overridden per domain). Use a mailbox on a domain that
receives inbound mail. SelfPost is send-only today; a future release will
be able to receive reports in the panel itself.</p>
<label for="dmarc_report_email">Default report address</label>
<input id="dmarc_report_email" name="dmarc_report_email" type="email"
autocomplete="email" autocapitalize="none" spellcheck="false"
value="{{.FormDMARCEmail}}" placeholder="reports@your-mail-domain.com">
{{if .FormDMARCEmail}}
<p class="muted">When <code>rua=</code> points at another domain, that hub
domain must publish a report-authorisation record so receivers will deliver
the XML aggregates.</p>
<label>Report authorization — host / name</label>
<div class="code-row">
<span class="code">{{.ReportAuthName}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Report authorization — value</label>
<div class="code-row">
<span class="code">{{.ReportAuthExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{if .ReportAuthDNS.Status}}
<label>Report authorization DNS <span class="st st-{{.ReportAuthDNS.Status}}">{{.ReportAuthDNS.Status}}</span></label>
<p class="{{if eq .ReportAuthDNS.Status "ok"}}muted{{else}}error{{end}}">{{.ReportAuthDNS.Detail}}</p>
{{if .ReportAuthDNS.Records}}<span class="code">{{range .ReportAuthDNS.Records}}{{.}}
{{end}}</span>{{end}}
{{end}}
{{end}}
<button type="submit">Save changes</button>
</form>
<p class="muted">Leave both new-password fields empty to change the username
or DMARC address only. Changing the password signs out every other session;
this one stays signed in.</p>
</div>
{{end}}
@@ -51,8 +51,10 @@
<h2>SPF and DMARC records</h2>
<p class="muted">These two are not generated the way the DKIM record above is —
they are policy, and the domain may already publish an SPF record for other
senders. What follows is what this server expects to see and what the checks
below look for. Publish both as TXT records.</p>
senders. SelfPost is a send-only relay — most operators have no inbox on the
sending domain, so the suggested DMARC record omits <code>rua=</code> unless a
report address is configured below or in <a href="/account">Settings</a>.
Publish both as TXT records.</p>
<label>SPF — host / name</label>
<div class="code-row">
@@ -68,9 +70,23 @@
<p class="muted">A domain may have only one SPF record. If it already has one,
do not add a second — add this server's mechanism to the existing record
instead, before its <code>all</code> term. <code>-all</code> at the end means
nothing else may send as the domain; use <code>~all</code> while other senders
are still being sorted out.</p>
instead, before its <code>all</code> term.</p>
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
<label for="dmarc_rua_mode">DMARC aggregate reports (rua=)</label>
<select id="dmarc_rua_mode" name="dmarc_rua_mode">
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
</select>
<label for="dmarc_rua_email">Custom report address</label>
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
placeholder="reports@your-mail-domain.com">
<button type="submit">Save DMARC report settings</button>
</form>
<label>DMARC — host / name</label>
<div class="code-row">
@@ -78,17 +94,36 @@
<button type="button" class="copy">Copy</button>
</div>
<label>DMARC — value</label>
<label>DMARC — value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
<div class="code-row">
<span class="code">{{.DMARCExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
<p class="muted">Point <code>rua=</code> at a mailbox that is actually read —
the aggregate reports are how you find out who else sends as the domain.
<code>p=none</code> changes nothing about delivery, so it is safe to publish
straight away; tighten it to <code>p=quarantine</code> and then
<code>p=reject</code> once the reports come back clean.</p>
{{if .SameDomainRUA}}
<p class="error">The report address is on this sending domain. SelfPost does
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
reception in a future release.</p>
{{end}}
{{if .NeedsReportAuth}}
<label>Report authorization — host / name</label>
<div class="code-row">
<span class="code">{{.ReportAuthName}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Report authorization — value</label>
<div class="code-row">
<span class="code">{{.ReportAuthValue}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{end}}
<p class="muted"><code>p=none</code> changes nothing about delivery. Omit
<code>rua=</code> on a send-only domain, or point it at a mailbox that
receives inbound mail. Tighten to <code>p=quarantine</code> and then
<code>p=reject</code> once aggregate reports look clean.</p>
</div>
<div class="card" id="dns-status">
@@ -116,6 +151,13 @@
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
{{end}}</span>{{end}}
{{if .DNS.DMARCReportAuth.Status}}
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
{{end}}</span>{{end}}
{{end}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
<button type="submit">Re-check</button>
</form>
@@ -37,11 +37,17 @@
tell the send log from a single delivery's page. */}}
<main class="page-{{.Active}} {{template "wide" .}}">
{{template "content" .}}
{{/* The running version, on every authenticated page: it is what a backup
manifest is checked against on restore and the first thing to establish
when something behaves unexpectedly. Only for signed-in administrators —
the login and setup pages must not advertise it to the internet. */}}
{{if .User}}<footer class="version">SelfPost {{.Version}}</footer>{{end}}
{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the licence,
where the Corresponding Source is, and that there is no warranty. Shown
on every page, including login/setup — those are interactive UIs too.
The running version stays signed-in only: it is what a backup manifest is
checked against on restore, and must not be advertised to the internet. */}}
<footer class="version">
{{if .User}}SelfPost {{.Version}} · {{end}}{{.Copyright}} ·
<a href="/license">License (AGPL-3.0)</a> ·
<a href="{{.SourceURL}}">Source</a> ·
No warranty
</footer>
</main>
</div>
</body>
@@ -1,4 +1,4 @@
package web
package view
import (
"bytes"
@@ -18,11 +18,11 @@ import (
// every page template must resolve it. This is what makes "the nav is on every
// authenticated page" a structural property instead of a checklist item.
func TestEveryPageResolvesNav(t *testing.T) {
tmpl, err := loadTemplates()
engine, err := New("test")
if err != nil {
t.Fatalf("loadTemplates: %v", err)
t.Fatalf("New: %v", err)
}
for name, page := range tmpl.pages {
for name, page := range engine.Pages() {
if page.Lookup("nav") == nil {
t.Errorf("page %q does not resolve the shared nav template", name)
}
@@ -37,16 +37,16 @@ func TestEveryPageResolvesNav(t *testing.T) {
// asserted here: the long pages produce a list, and a page that defines nothing
// produces nothing at all.
func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) {
tmpl, err := loadTemplates()
engine, err := New("test")
if err != nil {
t.Fatalf("loadTemplates: %v", err)
t.Fatalf("New: %v", err)
}
// Anchors the index links to, taken from the page's own cards.
wantAnchors := map[string]string{
"status": `href="#certificate"`,
"domain_detail": `href="#danger"`,
}
for name, page := range tmpl.pages {
for name, page := range engine.Pages() {
var buf bytes.Buffer
// The domain page's index hides the freshly generated credential entry
// unless one is on the page, so the data map carries the key it reads.
@@ -68,15 +68,15 @@ func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) {
// nothing about rendering the page says so. Every anchor the index offers must
// name an element the same page defines an id for.
func TestSectionLinksPointAtCardsThatExist(t *testing.T) {
tmpl, err := loadTemplates()
engine, err := New("test")
if err != nil {
t.Fatalf("loadTemplates: %v", err)
t.Fatalf("New: %v", err)
}
// The pages that carry an index; both are checked with a credential shown,
// which is the domain page's one conditional entry.
for _, name := range []string{"status", "domain_detail"} {
var index bytes.Buffer
if err := tmpl.pages[name].ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil {
if err := engine.Page(name).ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil {
t.Fatalf("execute sections for %q: %v", name, err)
}
// The cards are spread over the page's template files, so the ids are
@@ -105,16 +105,28 @@ func TestSectionLinksPointAtCardsThatExist(t *testing.T) {
// The version comes from render(), not from each handler's data map, so the
// footer is only correct as long as every page composes with the layout and
// render keeps supplying the key. Both are asserted here rather than trusted.
// Appropriate Legal Notices (copyright, licence, source, no warranty) must
// appear on every page, including the signed-out ones.
func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
tmpl, err := loadTemplates()
engine, err := New("9.9.9-test")
if err != nil {
t.Fatalf("loadTemplates: %v", err)
t.Fatalf("New: %v", err)
}
legalBits := []string{
"Copyright © 2026 Mikhail Yenuchenko",
`href="/license"`,
"License (AGPL-3.0)",
`href="https://github.com/mixeme/selfpost"`,
"Source",
"No warranty",
}
rendered := 0
for name := range tmpl.pages {
for name := range engine.Pages() {
var buf bytes.Buffer
err := tmpl.pages[name].ExecuteTemplate(&buf, "layout.html", map[string]any{
err := engine.Page(name).ExecuteTemplate(&buf, "layout.html", map[string]any{
"Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
})
if err != nil {
// Pages whose content block needs more data than this cannot be
@@ -123,35 +135,49 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
continue
}
rendered++
if !strings.Contains(buf.String(), "SelfPost 9.9.9-test") {
out := buf.String()
if !strings.Contains(out, "SelfPost 9.9.9-test") {
t.Errorf("page %q does not show the version in the layout footer", name)
}
for _, want := range legalBits {
if !strings.Contains(out, want) {
t.Errorf("page %q is missing legal notice %q", name, want)
}
}
}
if rendered == 0 {
t.Fatal("no page rendered, so the footer was never actually checked")
}
// Signed out (login, setup) the version must not be advertised.
// Signed out (login, setup) the version must not be advertised, but the
// Appropriate Legal Notices must still be present.
var buf bytes.Buffer
if err := tmpl.pages["login"].ExecuteTemplate(&buf, "layout.html", map[string]any{
if err := engine.Page("login").ExecuteTemplate(&buf, "layout.html", map[string]any{
"Title": "t", "Active": "", "Version": "9.9.9-test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost",
}); err != nil {
t.Fatalf("execute login: %v", err)
}
if strings.Contains(buf.String(), "9.9.9-test") {
t.Errorf("the login page shows the version to unauthenticated visitors:\n%s", buf.String())
out := buf.String()
if strings.Contains(out, "9.9.9-test") {
t.Errorf("the login page shows the version to unauthenticated visitors:\n%s", out)
}
for _, want := range legalBits {
if !strings.Contains(out, want) {
t.Errorf("login page is missing legal notice %q", want)
}
}
}
func TestRenderSuppliesTheVersion(t *testing.T) {
tmpl, err := loadTemplates()
engine, err := New("9.9.9-test")
if err != nil {
t.Fatalf("loadTemplates: %v", err)
t.Fatalf("New: %v", err)
}
s := &Server{tmpl: tmpl, cfg: Config{Version: "9.9.9-test"}}
rec := httptest.NewRecorder()
data := map[string]any{"Title": "t", "User": "admin"}
s.render(rec, http.StatusOK, "backup", data)
engine.Render(rec, http.StatusOK, "backup", data)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
@@ -159,18 +185,28 @@ func TestRenderSuppliesTheVersion(t *testing.T) {
if got := data["Version"]; got != "9.9.9-test" {
t.Errorf("render did not supply Version (got %v)", got)
}
if !strings.Contains(rec.Body.String(), "SelfPost 9.9.9-test") {
t.Errorf("rendered page does not show the version:\n%s", rec.Body.String())
if got := data["Copyright"]; got != "Copyright © 2026 Mikhail Yenuchenko" {
t.Errorf("render did not supply Copyright (got %v)", got)
}
if got := data["SourceURL"]; got != "https://github.com/mixeme/selfpost" {
t.Errorf("render did not supply SourceURL (got %v)", got)
}
body := rec.Body.String()
if !strings.Contains(body, "SelfPost 9.9.9-test") {
t.Errorf("rendered page does not show the version:\n%s", body)
}
if !strings.Contains(body, `href="/license"`) || !strings.Contains(body, "No warranty") {
t.Errorf("rendered page is missing Appropriate Legal Notices:\n%s", body)
}
}
func TestNavMarksActivePage(t *testing.T) {
tmpl, err := loadTemplates()
engine, err := New("test")
if err != nil {
t.Fatalf("loadTemplates: %v", err)
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{
err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "mail_queue",
})
@@ -192,12 +228,12 @@ func TestNavMarksActivePage(t *testing.T) {
}
func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
tmpl, err := loadTemplates()
engine, err := New("test")
if err != nil {
t.Fatalf("loadTemplates: %v", err)
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := tmpl.pages["status"].ExecuteTemplate(&buf, "nav", map[string]any{
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
}); err != nil {
@@ -221,12 +257,12 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
// silently comes back at the measure, with its table squeezed into two thirds
// of the column — so the set is asserted here, in both directions.
func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
tmpl, err := loadTemplates()
engine, err := New("test")
if err != nil {
t.Fatalf("loadTemplates: %v", err)
t.Fatalf("New: %v", err)
}
wide := map[string]bool{"deliveries": true, "delivery": true, "mail_queue": true, "system_log": true}
for name, page := range tmpl.pages {
for name, page := range engine.Pages() {
var buf bytes.Buffer
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
t.Fatalf("execute the wide block of %s: %v", name, err)
@@ -357,12 +393,12 @@ func TestStatusPageWithoutMachineMetrics(t *testing.T) {
func renderStatusPage(t *testing.T, data map[string]any) string {
t.Helper()
tmpl, err := loadTemplates()
engine, err := New("test")
if err != nil {
t.Fatalf("loadTemplates: %v", err)
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := tmpl.pages["status"].ExecuteTemplate(&buf, "layout.html", data); err != nil {
if err := engine.Page("status").ExecuteTemplate(&buf, "layout.html", data); err != nil {
t.Fatalf("execute status page: %v", err)
}
return buf.String()
@@ -423,7 +459,7 @@ func statusPageData() map[string]any {
}
// dnscheckResult mirrors dnscheck.Result's shape for the template test, so the
// web package's template tests do not depend on the checker's constructor.
// view package's template tests do not depend on the checker's constructor.
type dnscheckResult struct {
Status health.Status
Detail string
@@ -1,22 +1,26 @@
package web
// Package view embeds the panel's HTML templates and static assets and renders
// pages and HTMX polling fragments.
package view
import (
"bytes"
"embed"
"fmt"
"html/template"
"log"
"net/http"
"github.com/mixeme/selfpost/internal/legal"
)
// templates holds the parsed page and fragment templates. Each page is parsed
// together with the shared base layout so {{ template "base" . }} works.
// Fragments (HTMX polling targets, architecture.md § Panel HTTP surface) are
// parsed standalone, without the layout, so they can be swapped into an
// existing page as an HTML snippet rather than a full document. Rendering
// always goes through html/template, which auto-escapes all interpolated data
// regardless (security.md).
type templates struct {
//go:embed templates/*.html static/*
var assetsFS embed.FS
// Engine holds parsed page and fragment templates.
type Engine struct {
pages map[string]*template.Template
fragments map[string]*template.Template
version string
}
// pageFiles maps a logical page name to its template files. Every page
@@ -50,10 +54,12 @@ var fragmentFiles = map[string]string{
"status_body": "templates/status_body.html",
}
func loadTemplates() (*templates, error) {
t := &templates{
// New parses embedded templates. version is stamped into every page footer.
func New(version string) (*Engine, error) {
e := &Engine{
pages: make(map[string]*template.Template),
fragments: make(map[string]*template.Template),
version: version,
}
for name, files := range pageFiles {
patterns := append([]string{"templates/layout.html"}, files...)
@@ -61,22 +67,33 @@ func loadTemplates() (*templates, error) {
if err != nil {
return nil, fmt.Errorf("parse template %s: %w", name, err)
}
t.pages[name] = tmpl
e.pages[name] = tmpl
}
for name, file := range fragmentFiles {
tmpl, err := template.ParseFS(assetsFS, file)
if err != nil {
return nil, fmt.Errorf("parse fragment %s: %w", name, err)
}
t.fragments[name] = tmpl
e.fragments[name] = tmpl
}
return t, nil
return e, nil
}
// render writes a page using the base layout. Rendering to a buffer first means
// Page returns a parsed page template by logical name. It is exported for
// template guard tests that assert structural properties across all pages.
func (e *Engine) Page(name string) *template.Template {
return e.pages[name]
}
// Pages returns all parsed page templates keyed by logical name.
func (e *Engine) Pages() map[string]*template.Template {
return e.pages
}
// Render writes a page using the base layout. Rendering to a buffer first means
// a template error yields a clean 500 instead of a half-written page.
func (s *Server) render(w http.ResponseWriter, status int, page string, data any) {
tmpl, ok := s.tmpl.pages[page]
func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any) {
tmpl, ok := e.pages[page]
if !ok {
http.Error(w, "template not found", http.StatusInternalServerError)
return
@@ -84,17 +101,19 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any
// The layout's navigation compares .Active against each item, so the key
// must exist on every authenticated page. Defaulting it here keeps a page
// that forgets it from failing to render — it simply highlights nothing.
// .Version, shown in the layout's footer, is supplied the same way: it is
// the same value on every page, so no handler should have to pass it.
// Footer fields (.Version, .Copyright, .SourceURL) are the same on every
// page, so no handler should have to pass them.
if m, ok := data.(map[string]any); ok {
if _, has := m["Active"]; !has {
m["Active"] = ""
}
m["Version"] = s.cfg.Version
m["Version"] = e.version
m["Copyright"] = legal.CopyrightLine
m["SourceURL"] = legal.SourceURL
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
logf("panel: render %s: %v", page, err)
log.Printf("panel: render %s: %v", page, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
@@ -103,18 +122,18 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any
_, _ = buf.WriteTo(w)
}
// renderFragment writes an HTMX polling fragment as a bare HTML snippet, with
// RenderFragment writes an HTMX polling fragment as a bare HTML snippet, with
// no surrounding layout (architecture.md § Panel HTTP surface: fragment
// endpoints return HTML, not JSON).
func (s *Server) renderFragment(w http.ResponseWriter, status int, name string, data any) {
tmpl, ok := s.tmpl.fragments[name]
func (e *Engine) RenderFragment(w http.ResponseWriter, status int, name string, data any) {
tmpl, ok := e.fragments[name]
if !ok {
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
logf("panel: render fragment %s: %v", name, err)
log.Printf("panel: render fragment %s: %v", name, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
+73 -142
View File
@@ -1,26 +1,24 @@
// Package web implements the SelfPost control panel's HTTP surface: the
// 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/legal"
"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"
)
//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
@@ -76,24 +74,9 @@ type Config struct {
// 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
auth *auth.Module
handlers *handlers.Handlers
}
// New builds the panel server. setupTokenPath is where the current setup token
@@ -102,118 +85,89 @@ type Server struct {
// 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()
v, err := view.New(cfg.Version)
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
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, handlers.Config{
Hostname: cfg.Hostname,
SubmissionEnabled: cfg.SubmissionEnabled,
MailLogPath: cfg.MailLogPath,
DataDir: cfg.DataDir,
DBPath: cfg.DBPath,
Version: cfg.Version,
TLSCertFile: cfg.TLSCertFile,
OpenDKIMSocket: cfg.OpenDKIMSocket,
JournalSocket: cfg.JournalSocket,
}, 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.setup.bootstrap()
return s.auth.Bootstrap()
}
// Handler returns the panel's HTTP handler (router).
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
h := s.handlers
// Health check stays unauthenticated for the container/orchestrator.
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)
// Vendored static assets (HTMX). Served from the embedded FS, with a
// content ETag so a replaced asset survives the browser cache (static.go).
mux.Handle("/static/", staticHandler())
// 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 /status", h.HandleStatus)
authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment)
authed.HandleFunc("POST /status/recheck", h.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)
authed.HandleFunc("GET /domains", h.HandleDashboard)
authed.HandleFunc("POST /domains", h.HandleAddDomain)
authed.HandleFunc("POST /domains/import", h.HandleImportDomain)
authed.HandleFunc("GET /domains/{id}", h.HandleDomainDetail)
authed.HandleFunc("POST /domains/{id}/dns-recheck", h.HandleDomainDNSRecheck)
authed.HandleFunc("GET /domains/{id}/delete", h.HandleDeleteConfirm)
authed.HandleFunc("POST /domains/{id}/delete", h.HandleDeleteDomain)
authed.HandleFunc("POST /domains/{id}/applications", h.HandleAddApplication)
authed.HandleFunc("POST /domains/{id}/ratelimit", h.HandleDomainRateLimit)
authed.HandleFunc("POST /domains/{id}/dmarc", h.HandleDomainDMARC)
authed.HandleFunc("POST /domains/{id}/export", h.HandleExportDomain)
authed.HandleFunc("POST /applications/{aid}/mode", h.HandleUpdateAppMode)
authed.HandleFunc("POST /applications/{aid}/password", h.HandleRegenPassword)
authed.HandleFunc("POST /applications/{aid}/ratelimit", h.HandleAppRateLimit)
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
authed.HandleFunc("POST /reload", h.HandleReload)
// Administrator's own panel credentials.
authed.HandleFunc("/account", s.handleAccount)
authed.HandleFunc("/account", h.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)
authed.HandleFunc("GET /backup", h.HandleBackupPage)
authed.HandleFunc("POST /backup", h.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 /deliveries/{id}", s.handleDelivery)
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)
authed.HandleFunc("GET /deliveries", h.HandleDeliveries)
authed.HandleFunc("GET /deliveries/rows", h.HandleDeliveriesRows)
authed.HandleFunc("GET /deliveries/{id}", h.HandleDelivery)
authed.HandleFunc("GET /mail-queue", h.HandleMailQueue)
authed.HandleFunc("GET /mail-queue/body", h.HandleMailQueueBody)
authed.HandleFunc("GET /system-log", h.HandleSystemLog)
authed.HandleFunc("GET /system-log/body", h.HandleSystemLogBody)
mux.Handle("/", s.requireAuth(authed))
// Security headers and the origin check wrap everything, including the
// unauthenticated login and setup routes.
mux.Handle("/", s.auth.RequireAuth(authed))
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)
}
@@ -228,43 +182,20 @@ func handleHealth(w http.ResponseWriter, _ *http.Request) {
_, _ = 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
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
}
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()
}
}
}
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
}
return host
_, _ = w.Write(legal.License)
}
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...)
}