Split internal/web into subpackages before domain-admin growth.
test / test (push) Has been cancelled

Lay out view, auth, validate, and handlers under internal/web while keeping
the cmd/panel API unchanged; update roadmap and changelog for web-split closure.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-10 23:19:09 +03:00
parent efaf016c5f
commit 155b721438
69 changed files with 1455 additions and 1528 deletions
+7
View File
@@ -5,6 +5,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
### Changed
- `internal/web` split into subpackages (`web/view`, `web/auth`, `web/validate`,
`web/handlers`); the composition root (`web.New`, `web.Config`, `Server.Handler`)
is unchanged for `cmd/panel`. Templates and static assets moved under
`internal/web/view/`.
### Added ### Added
- panel: DMARC guidance for send-only relays — the suggested `_dmarc` record - panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
+11 -3
View File
@@ -188,10 +188,10 @@ holds the cookie works after process restart, redeploy, or full backup restore.
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no - **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
absolute cap (regular use keeps the session alive indefinitely). absolute cap (regular use keeps the session alive indefinitely).
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour - **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
(`renewThreshold` in [internal/web/session.go](../internal/web/session.go)). (`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
- **Password change** — all other sessions are deleted; the current session stays - **Password change** — all other sessions are deleted; the current session stays
active ([internal/store/sessions.go](../internal/store/sessions.go), active ([internal/store/sessions.go](../internal/store/sessions.go),
[handlers_account.go](../internal/web/handlers_account.go)). [handlers_account.go](../internal/web/handlers/handlers_account.go)).
Restoring an **older** backup also restores session rows: a session invalidated Restoring an **older** backup also restores session rows: a session invalidated
after that backup was taken can become valid again if the browser still has the after that backup was taken can become valid again if the browser still has the
@@ -218,7 +218,15 @@ flowchart TB
backupcli["selfpost-backup CLI"] backupcli["selfpost-backup CLI"]
end end
subgraph web ["internal/web — HTTP surface"] subgraph web ["internal/web — HTTP surface"]
handlers["handlers_*.go, templates, session/security"] webRoot["web.go — router, security"]
viewPkg["web/view — templates, static"]
authPkg["web/auth — session, login, setup"]
handlersPkg["web/handlers — authenticated pages"]
webRoot --> viewPkg
webRoot --> authPkg
webRoot --> handlersPkg
handlersPkg --> authPkg
handlersPkg --> viewPkg
end end
subgraph services ["Services — multi-store operations + rollback"] subgraph services ["Services — multi-store operations + rollback"]
domainSvc["internal/domain"] domainSvc["internal/domain"]
+2 -2
View File
@@ -112,7 +112,7 @@ the tree are AGPL-3.0-compatible.
| Asset | Version | Repository | License | | Asset | Version | Repository | License |
|---|---|---|---| |---|---|---|---|
| `internal/web/static/htmx.min.js` | 2.0.4 | <https://github.com/bigskysoftware/htmx> | 0BSD | | `internal/web/view/static/htmx.min.js` | 2.0.4 | <https://github.com/bigskysoftware/htmx> | 0BSD |
### E2e module (`test/e2e/go.mod`) ### E2e module (`test/e2e/go.mod`)
@@ -354,7 +354,7 @@ to prose.
| Mail path | [build/postfix-config.sh](../build/postfix-config.sh) | | Mail path | [build/postfix-config.sh](../build/postfix-config.sh) |
| Panel routes | [internal/web/web.go](../internal/web/web.go) | | Panel routes | [internal/web/web.go](../internal/web/web.go) |
| Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) | | Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) |
| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/session.go](../internal/web/session.go) | | Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/auth/session.go](../internal/web/auth/session.go) |
| Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) | | Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
| Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) | | Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
| Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) | | Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) |
+1 -1
View File
@@ -23,7 +23,7 @@ list:
deletion, its own L2 limit); deletion, its own L2 limit);
- the domain's DKIM/DNS status; - the domain's DKIM/DNS status;
- the send log filtered to the domain — the filter already exists in the log - the send log filtered to the domain — the filter already exists in the log
([sendLogData](../../internal/web/handlers_monitor.go)). ([sendLogData](../../internal/web/handlers/handlers_monitor.go)).
What stays outside the role is what is global by nature: What stays outside the role is what is global by nature:
+18 -4
View File
@@ -1,6 +1,6 @@
# Plan: web-split (splitting `internal/web`) # Plan: web-split (splitting `internal/web`)
**Status:** agreed **Status:** done (see [CHANGELOG](../CHANGELOG.md) `[Unreleased]`)
**Version:** `1.x`; an internal refactor, it does not force a break on its own. **Version:** `1.x`; an internal refactor, it does not force a break on its own.
--- ---
@@ -42,11 +42,25 @@ domains. The refactor is cheaper before that growth than after it.
The order is a recommendation, not a blocker. The order is a recommendation, not a blocker.
## Chosen scheme
**Horizontal split into four packages** (decided at implementation):
```
internal/web/ # Config, Server, New, Handler — composition root; security.go
internal/web/view/ # embed templates/static, render/renderFragment, staticHandler
internal/web/auth/ # session, login/logout/setup, requireAuth, currentUser
internal/web/validate/ # shared form validation (avoids auth ↔ handlers import cycle)
internal/web/handlers/ # all authenticated page handlers (handlers_*.go)
```
`cmd/panel` keeps importing only `internal/web`. Subpackages are not exported
beyond what the composition root needs.
## Done when ## Done when
The decision is made deliberately when the work starts — either the package is The package is split along the scheme above. After the split: `build`/`vet`/`test`
split along the chosen scheme, or it is settled that it stays flat. After a green, the panel's behaviour unchanged.
split: `build`/`vet`/`test` green, the panel's behaviour unchanged.
## Risks ## Risks
+5 -27
View File
@@ -26,17 +26,15 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| ID | Topic | Status | Plan | | ID | Topic | Status | Plan |
|---|---|---|---| |---|---|---|---|
| web-split | Splitting `internal/web` | **agreed** | [plans/web-split.md](plans/web-split.md) |
| domain-admin | Domain administrator role | **agreed** | [plans/domain-admin.md](plans/domain-admin.md) | | domain-admin | Domain administrator role | **agreed** | [plans/domain-admin.md](plans/domain-admin.md) |
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) | | inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
| contributing | `CONTRIBUTING.md` | candidate | — | | contributing | `CONTRIBUTING.md` | candidate | — |
| visual-style | Обновление визуального стиля | candidate | — | | visual-style | Обновление визуального стиля | candidate | — |
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) | | dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
**Recommended order** (not binding): **web-split → domain-admin → **Recommended order** (not binding): **domain-admin →
inbound-relay** — first the package split, then role-wide authorisation, then inbound-relay** — role-wide authorisation first, then the inbound relay vertical
the new vertical slice of the inbound relay. Deviating is allowed; there are no slice. Deviating is allowed; there are no hard phases here.
hard phases here.
After a context reset, pick an item marked `agreed` or `in progress`, then work After a context reset, pick an item marked `agreed` or `in progress`, then work
the checklist in its linked plan. the checklist in its linked plan.
@@ -59,8 +57,7 @@ engine stays outside the image, only the attachment point is provided.
**Dependencies / risks:** a finished outbound path; open relay and backscatter; **Dependencies / risks:** a finished outbound path; open relay and backscatter;
a wider attack surface (port 25 accepting mail). a wider attack surface (port 25 accepting mail).
**Order:** recommended after [web-split](plans/web-split.md) and **Order:** recommended after [domain-admin](plans/domain-admin.md).
[domain-admin](plans/domain-admin.md).
**Version:** target bump `1.x`; `2.x` possible — to be settled once the **Version:** target bump `1.x`; `2.x` possible — to be settled once the
implementation lands. implementation lands.
@@ -80,32 +77,13 @@ assigned domains (one or several).
**Done when:** see [plans/domain-admin.md](plans/domain-admin.md). **Done when:** see [plans/domain-admin.md](plans/domain-admin.md).
**Dependencies / risks:** a users table, the role in the session, authorisation **Dependencies / risks:** a users table, the role in the session, authorisation
in every handler, setup and backup. **Order:** recommended after in every handler, setup and backup. **Order:** recommended **before**
[web-split](plans/web-split.md), before
[inbound-relay](plans/inbound-relay.md). [inbound-relay](plans/inbound-relay.md).
**Version:** `1.x` MINOR, given a compatible migration of the current **Version:** `1.x` MINOR, given a compatible migration of the current
administrator into a global one. administrator into a global one.
--- ---
## web-split
**Goal:** deliberately split `internal/web` (or settle on keeping the package
flat) before it grows under inbound-relay and domain-admin.
**Boundary:** an internal refactor; the panel's behaviour for the operator does
not change.
**Done when:** the package is split along the chosen scheme, or it is settled
that it stays flat — see [plans/web-split.md](plans/web-split.md).
**Dependencies / risks:** exporting a package-private API. **Order:**
recommended **first** among the agreed features (before domain-admin and
inbound-relay).
**Version:** `1.x`; on its own it does not force a break.
---
## contributing ## contributing
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run **Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
+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 ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath"
"strings" "strings"
"testing" "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 func newTestSessionStore(t *testing.T) *sessionStore {
// backwards would make the development instance fail to log in at all, and t.Helper()
// silently — the browser discards the Set-Cookie and the panel just shows the st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
// login form again. 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) { func TestSessionCookieNameFollowsCookieSecure(t *testing.T) {
secure := &Server{cfg: Config{CookieSecure: true}} secure := testModule(t, true)
if got := secure.sessionCookie(); got != "__Host-selfpost_session" { if got := secure.sessionCookie(); got != "__Host-selfpost_session" {
t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got) 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" { if got := plain.sessionCookie(); got != "selfpost_session" {
t.Errorf("without TLS the cookie is named %q, want the bare name", got) 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) { 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 := 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: "planted-by-a-neighbour"})
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"}) 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) t.Fatalf("duplicate cookies accepted, token = %q", token)
} }
} }
func TestSessionTokenReadsOneCookie(t *testing.T) { 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 := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"}) 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" { if !ok || token != "the-real-session" {
t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok) 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) { 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 := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"}) 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") t.Fatal("the unprefixed cookie was accepted on a TLS deployment")
} }
} }
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) { func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: false}, sessions: newTestSessionStore(t)} m := testModule(t, false)
token := s.sessions.Create("admin") token := m.sessions.Create("admin")
reached := false 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 := 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: "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) { func TestLogoutClearsBothCookieNames(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: true}, sessions: newTestSessionStore(t)} m := testModule(t, true)
token := s.sessions.Create("admin") token := m.sessions.Create("admin")
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil) r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
r.Host = "panel.example.com" r.Host = "panel.example.com"
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token}) r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token})
rec := httptest.NewRecorder() 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") t.Error("the session survived sign-out")
} }
set := rec.Header().Values("Set-Cookie") 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 ( import (
"sync" "sync"
@@ -6,9 +6,7 @@ import (
) )
// rateLimiter is a simple fixed-window per-key counter used to throttle the // 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 // setup and login routes (security.md). Keys are client IPs.
// precise sliding window — a coarse backstop against brute-force and log noise
// is all these routes need.
type rateLimiter struct { type rateLimiter struct {
max int max int
window time.Duration 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 { func (r *rateLimiter) Allow(key string) bool {
now := time.Now() now := time.Now()
r.mu.Lock() r.mu.Lock()
@@ -50,9 +46,6 @@ func (r *rateLimiter) Allow(key string) bool {
return true 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) { func (r *rateLimiter) sweep(now time.Time) {
for k, b := range r.buckets { for k, b := range r.buckets {
if now.After(b.windowEnds) { if now.After(b.windowEnds) {
@@ -1,4 +1,4 @@
package web package auth
import ( import (
"crypto/sha256" "crypto/sha256"
@@ -22,9 +22,6 @@ const renewThreshold = time.Hour
// cookie. // cookie.
type sessionStore struct { type sessionStore struct {
store *store.Store 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
} }
@@ -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 { if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil {
logf("panel: session: create failed: %v", err) 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 { if _, err := s.store.DeleteExpiredSessions(now); err != nil {
logf("panel: session: prune expired failed: %v", err) 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 // Touch extends a session's sliding expiry if it has been at least
// renewThreshold since the last extension, and reports whether it did so // 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.
func (s *sessionStore) Touch(token string) bool { func (s *sessionStore) Touch(token string) bool {
hash := hashToken(token) hash := hashToken(token)
row, found, err := s.store.LookupSession(hash) row, found, err := s.store.LookupSession(hash)
@@ -100,8 +90,6 @@ func (s *sessionStore) Touch(token string) bool {
if !found { if !found {
return false return false
} }
// expiresAt = lastRenewal + idle, so this recovers when the session was
// last extended without a separate column.
lastRenewal := row.ExpiresAt.Add(-s.idle) lastRenewal := row.ExpiresAt.Add(-s.idle)
now := time.Now() now := time.Now()
if now.Sub(lastRenewal) < renewThreshold { if now.Sub(lastRenewal) < renewThreshold {
@@ -114,19 +102,14 @@ func (s *sessionStore) Touch(token string) bool {
return true return true
} }
// Rename updates the username carried by a session, keeping its expiry. It is // Rename updates the username carried by a session, keeping its expiry.
// used when the administrator renames their own account so the current
// session keeps working under the new name.
func (s *sessionStore) Rename(token, username string) { func (s *sessionStore) Rename(token, username string) {
if err := s.store.RenameSession(hashToken(token), username); err != nil { if err := s.store.RenameSession(hashToken(token), username); err != nil {
logf("panel: session: rename failed: %v", err) logf("panel: session: rename failed: %v", err)
} }
} }
// DestroyOthers invalidates every session except keep. It is called when the // DestroyOthers invalidates every session except keep.
// administrator changes their password: a stolen cookie issued under the old
// password must stop working, while the admin performing the change stays
// signed in.
func (s *sessionStore) DestroyOthers(keep string) { func (s *sessionStore) DestroyOthers(keep string) {
if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil { if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil {
logf("panel: session: destroy others failed: %v", err) logf("panel: session: destroy others failed: %v", err)
@@ -1,4 +1,4 @@
package web package auth
import ( import (
"crypto/subtle" "crypto/subtle"
@@ -32,8 +32,6 @@ func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager
return &setupManager{store: st, hostname: hostname, tokenPath: tokenPath} 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 { func (m *setupManager) bootstrap() error {
done, err := m.store.AdminExists() done, err := m.store.AdminExists()
if err != nil { if err != nil {
@@ -49,9 +47,6 @@ func (m *setupManager) bootstrap() error {
return nil 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) { func (m *setupManager) activeToken() (string, bool) {
done, err := m.store.AdminExists() done, err := m.store.AdminExists()
if err != nil { if err != nil {
@@ -69,10 +64,6 @@ func (m *setupManager) activeToken() (string, bool) {
return m.token, true 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 { func (m *setupManager) validate(provided string) bool {
token, ok := m.activeToken() token, ok := m.activeToken()
if !ok { if !ok {
@@ -81,8 +72,6 @@ func (m *setupManager) validate(provided string) bool {
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1 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() { func (m *setupManager) complete() {
m.mu.Lock() m.mu.Lock()
m.token = "" m.token = ""
@@ -91,16 +80,12 @@ func (m *setupManager) complete() {
m.clearTokenFile() m.clearTokenFile()
} }
// regenerateLocked mints a fresh token, announces it and mirrors it to disk.
// Caller holds m.mu.
func (m *setupManager) regenerateLocked() { 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.expiresAt = time.Now().Add(setupTokenTTL)
m.announce(m.token) 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) { func (m *setupManager) announce(token string) {
url := m.setupURL(token) url := m.setupURL(token)
logf("panel: ==================================================================") logf("panel: ==================================================================")
@@ -112,7 +97,6 @@ func (m *setupManager) announce(token string) {
if m.tokenPath == "" { if m.tokenPath == "" {
return return
} }
// 0600: the token is a bearer secret for creating the admin.
if err := os.WriteFile(m.tokenPath, []byte(url+"\n"), 0o600); err != nil { 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) logf("panel: setup: could not write token file %s: %v", m.tokenPath, err)
} }
@@ -1,4 +1,4 @@
package web package auth
import ( import (
"crypto/rand" "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...)
}
@@ -1,4 +1,4 @@
package web package handlers
import ( import (
"context" "context"
@@ -9,26 +9,28 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/store" "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" "golang.org/x/crypto/bcrypt"
) )
// handleAccount serves the administrator's own account settings: the username // HandleAccount serves the administrator's own account settings: the username
// and password chosen during setup are the only panel credentials // and password chosen during setup are the only panel credentials
// (security.md), and until now they could be changed only by recreating the // (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 // state. Changing them here never touches application SASL logins, which are a
// separate identity system (architecture.md § Mail path). // separate identity system (architecture.md § Mail path).
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
admin, err := s.store.GetAdmin() admin, err := h.store.GetAdmin()
if err != nil { if err != nil {
logf("panel: account: get admin failed: %v", err) logf("panel: account: get admin failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
s.renderAccount(w, r, http.StatusOK, "", admin.Username, admin.DMARCReportEmail) h.renderAccount(w, r, http.StatusOK, "", admin.Username, admin.DMARCReportEmail)
case http.MethodPost: case http.MethodPost:
s.submitAccount(w, r) h.submitAccount(w, r)
default: default:
w.Header().Set("Allow", "GET, POST") w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -38,16 +40,16 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
// renderAccount draws the settings form. formUsername and formDMARCEmail // renderAccount draws the settings form. formUsername and formDMARCEmail
// repopulate fields after a rejected submission; password fields are never // repopulate fields after a rejected submission; password fields are never
// repopulated. // repopulated.
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string) { func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string) {
var reportAuth dnscheck.Result var reportAuth dnscheck.Result
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" { if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
reportAuth = s.dns.ReportAuth(ctx, hub) reportAuth = h.dns.ReportAuth(ctx, hub)
cancel() cancel()
} }
s.render(w, status, "account", map[string]any{ h.view.Render(w, status, "account", map[string]any{
"Title": "SelfPost — settings", "Title": "SelfPost — settings",
"User": currentUser(r), "User": auth.CurrentUser(r),
"Active": "account", "Active": "account",
"FormUsername": formUsername, "FormUsername": formUsername,
"FormDMARCEmail": formDMARCEmail, "FormDMARCEmail": formDMARCEmail,
@@ -88,14 +90,14 @@ func accountFlash(r *http.Request) string {
// out of their own panel, and the attempt is throttled on the same limiter as // 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 // the login form so this route cannot be used to brute-force the password past
// that limit (security.md). // that limit (security.md).
func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) { func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) { if !h.auth.AllowLoginAttempt(r) {
s.renderAccount(w, r, http.StatusTooManyRequests, h.renderAccount(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", currentUser(r), "") "Too many attempts. Please wait and try again.", auth.CurrentUser(r), "")
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r), "") h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "")
return return
} }
@@ -105,7 +107,7 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
confirm := r.PostFormValue("new_password_confirm") confirm := r.PostFormValue("new_password_confirm")
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email")) dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
admin, err := s.store.GetAdmin() admin, err := h.store.GetAdmin()
if err != nil { if err != nil {
logf("panel: account: get admin failed: %v", err) logf("panel: account: get admin failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
@@ -116,20 +118,20 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
} }
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail) h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail)
return return
} }
renaming := username != admin.Username renaming := username != admin.Username
if renaming { if renaming {
if err := validateUsername(username); err != nil { if err := validate.Username(username); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
return return
} }
} }
if err := validateEmail(dmarcEmail); err != nil { if err := validate.Email(dmarcEmail); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
return return
} }
@@ -138,16 +140,16 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
repassword := password != "" || confirm != "" repassword := password != "" || confirm != ""
if repassword { if repassword {
if password != confirm { if password != confirm {
s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail) h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail)
return return
} }
if err := validateAdminPassword(password); err != nil { if err := validate.AdminPassword(password); err != nil {
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
return return
} }
} }
if !renaming && !repassword && !emailChanging { if !renaming && !repassword && !emailChanging {
s.renderAccount(w, r, http.StatusBadRequest, h.renderAccount(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail) "Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail)
return return
} }
@@ -157,29 +159,29 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
logf("panel: account: hashing password failed: %v", err) logf("panel: account: hashing password failed: %v", err)
s.renderAccount(w, r, http.StatusInternalServerError, h.renderAccount(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username, dmarcEmail) "Internal error. Please try again.", username, dmarcEmail)
return return
} }
hash = string(newHash) hash = string(newHash)
} }
if err := s.store.UpdateAdmin(username, hash, dmarcEmail); err != nil { if err := h.store.UpdateAdmin(username, hash, dmarcEmail); err != nil {
logf("panel: account: update admin failed: %v", err) logf("panel: account: update admin failed: %v", err)
msg := "Could not save the changes. Please check the logs and try again." msg := "Could not save the changes. Please check the logs and try again."
if errors.Is(err, store.ErrNoAdmin) { if errors.Is(err, store.ErrNoAdmin) {
msg = "There is no administrator account to update." msg = "There is no administrator account to update."
} }
s.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail) h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail)
return return
} }
if token, ok := s.sessionToken(r); ok { if token, ok := h.auth.SessionToken(r); ok {
if renaming { if renaming {
s.sessions.Rename(token, username) h.auth.RenameSession(token, username)
} }
if repassword { if repassword {
s.sessions.DestroyOthers(token) h.auth.DestroyOtherSessions(token)
} }
} }
@@ -1,4 +1,4 @@
package web package handlers
import ( import (
"errors" "errors"
@@ -10,6 +10,8 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/store" "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 // 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 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 // and its applications with the controls to add, edit, delete and re-issue
// credentials (product.md). // credentials (product.md).
func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDomainDetail(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r) d, ok := h.lookupDomain(w, r)
if !ok { if !ok {
return 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 // renderDomainDetail renders the domain page. view supplies request-specific
// extras (form error/values, a one-time credential); everything else is loaded // extras (form error/values, a one-time credential); everything else is loaded
// fresh from the stores so the page always reflects committed state. // 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) { func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
record, err := s.domains.DKIMRecord(d) record, err := h.domains.DKIMRecord(d)
if err != nil { if err != nil {
logf("panel: domain %d: dkim record: %v", d.ID, err) logf("panel: domain %d: dkim record: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
apps, err := s.apps.List(d.ID) apps, err := h.apps.List(d.ID)
if err != nil { if err != nil {
logf("panel: domain %d: list applications: %v", d.ID, err) logf("panel: domain %d: list applications: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) 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)) appViews := make([]appRateLimitView, 0, len(apps))
for _, a := range apps { for _, a := range apps {
rl, ok, err := s.apps.RateLimit(a.ID) rl, ok, err := h.apps.RateLimit(a.ID)
if err != nil { if err != nil {
logf("panel: application %d: rate limit: %v", a.ID, err) logf("panel: application %d: rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) 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 { if err != nil {
logf("panel: domain %d: rate limit: %v", d.ID, err) logf("panel: domain %d: rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
@@ -102,7 +104,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
// What DNS actually publishes for the domain today, checked against the key // 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 // this server signs with. Cached by the checker, so re-rendering the page
// after a form post costs nothing. // after a form post costs nothing.
admin, err := s.store.GetAdmin() admin, err := h.store.GetAdmin()
if err != nil { if err != nil {
logf("panel: domain %d: get admin: %v", d.ID, err) logf("panel: domain %d: get admin: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
@@ -110,7 +112,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
} }
profileEmail := admin.DMARCReportEmail profileEmail := admin.DMARCReportEmail
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail) reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
dns, srv := s.domainDNS(d, record, profileEmail, false) dns, srv := h.domainDNS(d, record, profileEmail, false)
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail) reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
dmarcMode := "inherit" dmarcMode := "inherit"
dmarcCustom := "" dmarcCustom := ""
@@ -132,9 +134,9 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
dmarcSource = "settings" 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, "Title": "SelfPost — " + d.Name,
"User": currentUser(r), "User": auth.CurrentUser(r),
"Active": "domains", "Active": "domains",
"Domain": d, "Domain": d,
"Record": record, "Record": record,
@@ -144,7 +146,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
// this server expects rather than leaving it to the documentation. The // this server expects rather than leaving it to the documentation. The
// same builders phrase the suggestions in the check messages, so the // same builders phrase the suggestions in the check messages, so the
// page and the checks below it never recommend different records. // 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), "DMARCName": dnscheck.DMARCRecordName(d.Name),
"DMARCExample": dnscheck.DMARCExample(reportEmail), "DMARCExample": dnscheck.DMARCExample(reportEmail),
"DMARCSource": dmarcSource, "DMARCSource": dmarcSource,
@@ -159,8 +161,8 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
// Client connection settings (the same for every domain on this // Client connection settings (the same for every domain on this
// instance): the hostname clients connect to, and whether the optional // instance): the hostname clients connect to, and whether the optional
// submission listener is enabled in this deployment. // submission listener is enabled in this deployment.
"Hostname": s.cfg.Hostname, "Hostname": h.cfg.Hostname,
"SubmissionEnabled": s.cfg.SubmissionEnabled, "SubmissionEnabled": h.cfg.SubmissionEnabled,
"Apps": appViews, "Apps": appViews,
"Error": view.FormErr, "Error": view.FormErr,
"FormLogin": view.FormLogin, "FormLogin": view.FormLogin,
@@ -172,7 +174,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
"List": store.AddressModeList, "List": store.AddressModeList,
"RateLimitErr": view.RateLimitErr, "RateLimitErr": view.RateLimitErr,
"ExportErr": view.ExportErr, "ExportErr": view.ExportErr,
"MinPwLen": minSecretFilePasswordLen, "MinPwLen": validate.MinSecretFilePasswordLen,
"DomainHasRL": domainRLok && domainRL.Active(), "DomainHasRL": domainRLok && domainRL.Active(),
"DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"), "DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"),
"DomainRLMax": intOrBlank(domainRL.MaxMessages), "DomainRLMax": intOrBlank(domainRL.MaxMessages),
@@ -186,9 +188,9 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
// and no extra environment variable is needed. That server result is returned // 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 // alongside, because the page's suggested SPF record is built from the same
// addresses. force bypasses the cache, for the Re-check button. // addresses. force bypasses the cache, for the Re-check button.
func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) { func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
srv := s.dns.Server(s.cfg.Hostname, false) srv := h.dns.Server(h.cfg.Hostname, false)
return s.dns.Domain(dnscheck.Query{ return h.dns.Domain(dnscheck.Query{
Name: d.Name, Name: d.Name,
Selector: d.DKIMSelector, Selector: d.DKIMSelector,
ExpectedDKIM: record.Value, ExpectedDKIM: record.Value,
@@ -198,26 +200,26 @@ func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmai
}, force), srv }, 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. // returns to its page, which then renders the fresh result.
func (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r) d, ok := h.lookupDomain(w, r)
if !ok { if !ok {
return return
} }
record, err := s.domains.DKIMRecord(d) record, err := h.domains.DKIMRecord(d)
if err != nil { if err != nil {
logf("panel: domain %d: dkim record: %v", d.ID, err) logf("panel: domain %d: dkim record: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
admin, err := s.store.GetAdmin() admin, err := h.store.GetAdmin()
if err != nil { if err != nil {
logf("panel: domain %d: get admin: %v", d.ID, err) logf("panel: domain %d: get admin: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
s.domainDNS(d, record, admin.DMARCReportEmail, true) h.domainDNS(d, record, admin.DMARCReportEmail, true)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
} }
@@ -260,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 // back with the generated password shown once (product.md, security.md). Because the
// password cannot be recovered later, this deliberately renders inline rather // password cannot be recovered later, this deliberately renders inline rather
// than redirecting. // than redirecting.
func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleAddApplication(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r) d, ok := h.lookupDomain(w, r)
if !ok { if !ok {
return return
} }
if err := r.ParseForm(); err != nil { 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}) detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
return return
} }
@@ -284,25 +286,25 @@ func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) {
FormAddrs: r.PostFormValue("addresses"), 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 { if err != nil {
repopulate.FormErr = applicationErrorMessage(err) repopulate.FormErr = applicationErrorMessage(err)
status := http.StatusBadRequest status := http.StatusBadRequest
if errors.Is(err, store.ErrLoginExists) { if errors.Is(err, store.ErrLoginExists) {
status = http.StatusConflict status = http.StatusConflict
} }
s.renderDomainDetail(w, r, status, d, repopulate) h.renderDomainDetail(w, r, status, d, repopulate)
return return
} }
s.renderDomainDetail(w, r, http.StatusCreated, d, detailView{ h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
FormMode: store.AddressModeWildcard, FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password}, NewCred: &newCred{Login: a.Login, Password: password},
}) })
} }
// handleUpdateAppMode switches an application's address mode / list (product.md). // HandleUpdateAppMode switches an application's address mode / list (product.md).
func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r) a, ok := h.lookupApplication(w, r)
if !ok { if !ok {
return return
} }
@@ -313,13 +315,13 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
mode := r.PostFormValue("mode") mode := r.PostFormValue("mode")
addrs := splitAddresses(r.PostFormValue("addresses")) addrs := splitAddresses(r.PostFormValue("addresses"))
if err := s.apps.UpdateMode(a.ID, mode, addrs); err != nil { if err := h.apps.UpdateMode(a.ID, mode, addrs); err != nil {
d, derr := s.domains.Get(a.DomainID) d, derr := h.domains.Get(a.DomainID)
if derr != nil { if derr != nil {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return 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)), FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
FormMode: store.AddressModeWildcard, FormMode: store.AddressModeWildcard,
}) })
@@ -328,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) 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. // (product.md, security.md). Rendered inline, like creation, so the password is visible.
func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r) a, ok := h.lookupApplication(w, r)
if !ok { if !ok {
return return
} }
d, err := s.domains.Get(a.DomainID) d, err := h.domains.Get(a.DomainID)
if err != nil { if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
password, err := s.apps.RegeneratePassword(a.ID) password, err := h.apps.RegeneratePassword(a.ID)
if err != nil { if err != nil {
logf("panel: regenerate password for application %d: %v", a.ID, err) logf("panel: regenerate password for application %d: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
s.renderDomainDetail(w, r, http.StatusOK, d, detailView{ h.renderDomainDetail(w, r, http.StatusOK, d, detailView{
FormMode: store.AddressModeWildcard, FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password}, 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). // (product.md).
func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r) a, ok := h.lookupApplication(w, r)
if !ok { if !ok {
return 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) logf("panel: delete application %d: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
@@ -369,13 +371,13 @@ func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request)
// lookupApplication resolves the {aid} path value to an application, writing a // lookupApplication resolves the {aid} path value to an application, writing a
// 404 for a bad id or missing application. // 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) id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64)
if err != nil || id <= 0 { if err != nil || id <= 0 {
http.NotFound(w, r) http.NotFound(w, r)
return store.Application{}, false return store.Application{}, false
} }
a, err := s.apps.Get(id) a, err := h.apps.Get(id)
if err != nil { if err != nil {
if errors.Is(err, store.ErrApplicationNotFound) { if errors.Is(err, store.ErrApplicationNotFound) {
http.NotFound(w, r) http.NotFound(w, r)
@@ -1,4 +1,4 @@
package web package handlers
import ( import (
"bytes" "bytes"
@@ -13,6 +13,8 @@ import (
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/secretfile" "github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/store" "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 // maxImportBytes caps a domain-import upload. A domain export is a small JSON
@@ -22,35 +24,35 @@ import (
// covers both forms. // covers both forms.
const maxImportBytes = 1 << 20 // 1 MiB 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 // 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. // its own card here rather than sharing a block on the domain list.
func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) {
s.renderBackupPage(w, r, http.StatusOK, "") h.renderBackupPage(w, r, http.StatusOK, "")
} }
// renderBackupPage draws the page; importErr surfaces a failed domain import // renderBackupPage draws the page; importErr surfaces a failed domain import
// (architecture.md § Persistence) next to the form that produced it. // (architecture.md § Persistence) next to the form that produced it.
func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) { func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) {
s.renderBackupPageWith(w, r, status, importErr, "") h.renderBackupPageWith(w, r, status, importErr, "")
} }
// renderBackupPageWith is renderBackupPage with the second of the page's two // renderBackupPageWith is renderBackupPage with the second of the page's two
// error slots: backupErr belongs to the full-backup card (a rejected encryption // error slots: backupErr belongs to the full-backup card (a rejected encryption
// password), importErr to the import card, so neither message appears under the // password), importErr to the import card, so neither message appears under the
// wrong form. // wrong form.
func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) { func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) {
s.render(w, status, "backup", map[string]any{ h.view.Render(w, status, "backup", map[string]any{
"Title": "SelfPost — backup", "Title": "SelfPost — backup",
"User": currentUser(r), "User": auth.CurrentUser(r),
"Active": "backup", "Active": "backup",
"ImportErr": importErr, "ImportErr": importErr,
"BackupErr": backupErr, "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 // Persistence). It is an authenticated admin action (this handler sits behind
// the auth middleware). The archive carries DKIM private keys, the admin // 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 // 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 // "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 // way out, so the file that lands on their disk — wherever it is copied
// afterwards — is useless without the password. // 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) password, pwErr := secretFilePassword(r)
if pwErr != "" { if pwErr != "" {
s.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr) h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
return return
} }
@@ -99,9 +101,9 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
} }
if err := backup.Create(sink, backup.Params{ if err := backup.Create(sink, backup.Params{
DataDir: s.cfg.DataDir, DataDir: h.cfg.DataDir,
DBPath: s.cfg.DBPath, DBPath: h.cfg.DBPath,
Version: s.cfg.Version, Version: h.cfg.Version,
}); err != nil { }); err != nil {
logf("panel: full backup failed: %v", err) logf("panel: full backup failed: %v", err)
return 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 // (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 // 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 // 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 // full backup it can be encrypted with a password, in which case the download
// is a .spde envelope instead of plain JSON. // is a .spde envelope instead of plain JSON.
func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r) d, ok := h.lookupDomain(w, r)
if !ok { if !ok {
return return
} }
password, pwErr := secretFilePassword(r) password, pwErr := secretFilePassword(r)
if pwErr != "" { if pwErr != "" {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard, FormMode: store.AddressModeWildcard,
ExportErr: pwErr, ExportErr: pwErr,
}) })
return return
} }
exp, err := s.domains.Export(d.ID) exp, err := h.domains.Export(d.ID)
if err != nil { if err != nil {
logf("panel: export domain %d: %v", d.ID, err) logf("panel: export domain %d: %v", d.ID, err)
http.Error(w, "export failed", http.StatusInternalServerError) http.Error(w, "export failed", http.StatusInternalServerError)
@@ -174,22 +176,22 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(body) _, _ = 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 // domain on this instance (architecture.md § Persistence). The domain name is
// normalised and validated here (security.md); the domain service validates // normalised and validated here (security.md); the domain service validates
// the selector, each login and address, and the DKIM key before writing // 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 // 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 // re-renders the backup page, where the import form lives, with a friendly
// message. // 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) r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
if err := r.ParseMultipartForm(maxImportBytes); err != nil { 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 return
} }
file, _, err := r.FormFile("file") file, _, err := r.FormFile("file")
if err != nil { 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 return
} }
defer file.Close() defer file.Close()
@@ -201,7 +203,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
head := make([]byte, secretfile.MagicLen) head := make([]byte, secretfile.MagicLen)
n, err := io.ReadFull(file, head) n, err := io.ReadFull(file, head)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { 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 return
} }
source := io.MultiReader(bytes.NewReader(head[:n]), file) 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 secretfile.HasMagic(head[:n]) {
if password == "" { 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 return
} }
env, err := secretfile.NewReader(source, password) env, err := secretfile.NewReader(source, password)
if err != nil { if err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err)) h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
return return
} }
if env.Type() != secretfile.TypeDomainExport { 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 return
} }
// Read the whole plaintext first: authentication of the last chunk is // 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. // accept a truncated document before ever reaching it.
plain, err := io.ReadAll(env) plain, err := io.ReadAll(env)
if err != nil { if err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err)) h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err))
return return
} }
source = bytes.NewReader(plain) source = bytes.NewReader(plain)
} else if password != "" { } 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 return
} }
@@ -239,23 +241,23 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) {
dec := json.NewDecoder(source) dec := json.NewDecoder(source)
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
if err := dec.Decode(&exp); err != nil { 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 return
} }
// Normalise and validate the domain name before it reaches the service, the // Normalise and validate the domain name before it reaches the service, the
// same gate the add-domain form uses (security.md). // same gate the add-domain form uses (security.md).
exp.Domain = normalizeDomain(exp.Domain) exp.Domain = validate.NormalizeDomain(exp.Domain)
if err := validateDomain(exp.Domain); err != nil { if err := validate.Domain(exp.Domain); err != nil {
s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error()) h.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error())
return return
} }
d, err := s.domains.Import(exp) d, err := h.domains.Import(exp)
if err != nil { if err != nil {
logf("panel: import domain %q: %v", exp.Domain, err) logf("panel: import domain %q: %v", exp.Domain, err)
status, msg := importErrorMessage(err) status, msg := importErrorMessage(err)
s.renderBackupPage(w, r, status, msg) h.renderBackupPage(w, r, status, msg)
return return
} }
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther) 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 "", "" return "", ""
} }
password = r.PostFormValue("password") password = r.PostFormValue("password")
if len([]rune(password)) < minSecretFilePasswordLen { if len([]rune(password)) < validate.MinSecretFilePasswordLen {
return "", fmt.Sprintf("The encryption password must be at least %d characters.", minSecretFilePasswordLen) return "", fmt.Sprintf("The encryption password must be at least %d characters.", validate.MinSecretFilePasswordLen)
} }
if password != r.PostFormValue("password_confirm") { if password != r.PostFormValue("password_confirm") {
return "", "The two passwords do not match." return "", "The two passwords do not match."
@@ -1,4 +1,4 @@
package web package handlers
import ( import (
"errors" "errors"
@@ -10,6 +10,7 @@ import (
"testing" "testing"
"github.com/mixeme/selfpost/internal/secretfile" "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. // 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 archive is sealed — and leaving the box unticked has to keep producing
// the plain archive earlier versions produced. // the plain archive earlier versions produced.
func TestSecretFilePassword(t *testing.T) { func TestSecretFilePassword(t *testing.T) {
long := strings.Repeat("x", minSecretFilePasswordLen) long := strings.Repeat("x", validate.MinSecretFilePasswordLen)
short := strings.Repeat("x", minSecretFilePasswordLen-1) short := strings.Repeat("x", validate.MinSecretFilePasswordLen-1)
tests := []struct { tests := []struct {
name string name string
@@ -104,13 +105,9 @@ func TestDecryptErrorMessage(t *testing.T) {
// forgets to include the partial (or the data it needs) loses the option // forgets to include the partial (or the data it needs) loses the option
// silently, since the plain download still works. // silently, since the plain download still works.
func TestBackupPageOffersEncryption(t *testing.T) { func TestBackupPageOffersEncryption(t *testing.T) {
tmpl, err := loadTemplates() h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
s := &Server{tmpl: tmpl, cfg: Config{Version: "test"}}
rec := httptest.NewRecorder() 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.") http.StatusOK, "", "The two passwords do not match.")
body := rec.Body.String() body := rec.Body.String()
@@ -118,7 +115,7 @@ func TestBackupPageOffersEncryption(t *testing.T) {
`name="encrypt"`, `name="password"`, `name="password_confirm"`, `name="encrypt"`, `name="password"`, `name="password_confirm"`,
`name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields", `name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields",
"data-import-password-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.", "The two passwords do not match.",
} { } {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
@@ -1,4 +1,4 @@
package web package handlers
import ( import (
"database/sql" "database/sql"
@@ -6,21 +6,22 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/mixeme/selfpost/internal/web/validate"
) )
// handleDomainDMARC saves per-domain DMARC rua= settings. // HandleDomainDMARC saves per-domain DMARC rua= settings.
func (s *Server) handleDomainDMARC(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST") w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
} }
d, ok := s.lookupDomain(w, r) d, ok := h.lookupDomain(w, r)
if !ok { if !ok {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Invalid form submission."}) h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Invalid form submission."})
return return
} }
@@ -32,25 +33,25 @@ func (s *Server) handleDomainDMARC(w http.ResponseWriter, r *http.Request) {
rua = sql.NullString{Valid: true, String: ""} rua = sql.NullString{Valid: true, String: ""}
case "custom": case "custom":
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email")) email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
if err := validateEmail(email); err != nil { if err := validate.Email(email); err != nil {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: err.Error()}) h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: err.Error()})
return return
} }
if email == "" { if email == "" {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."}) h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
return return
} }
rua = sql.NullString{Valid: true, String: email} rua = sql.NullString{Valid: true, String: email}
default: default:
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."}) h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
return return
} }
if err := s.store.UpdateDomainDMARCRua(d.ID, rua); err != nil { if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil {
logf("panel: domain %d: save dmarc rua: %v", d.ID, err) logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
s.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."}) h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
return return
} }
s.dns.Forget(d.Name) h.dns.Forget(d.Name)
http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther) 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 ( import (
"errors" "errors"
@@ -12,6 +12,7 @@ import (
"github.com/mixeme/selfpost/internal/mailhdr" "github.com/mixeme/selfpost/internal/mailhdr"
"github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
) )
// sendLogPageSize bounds each send-log page (product.md's monitoring screens // sendLogPageSize bounds each send-log page (product.md's monitoring screens
@@ -24,38 +25,38 @@ const (
deliveryLogLines = 200 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 § // filters by domain/application and pagination (architecture.md §
// Persistence). The row table itself is the "deliveries_rows" fragment, shared // 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. // refreshes never diverge.
func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r) data, err := h.sendLogData(r)
if err != nil { if err != nil {
logf("panel: send log: %v", err) logf("panel: send log: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
data["Title"] = "SelfPost — deliveries" data["Title"] = "SelfPost — deliveries"
data["User"] = currentUser(r) data["User"] = auth.CurrentUser(r)
data["Active"] = "deliveries" 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 // (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not
// JSON). // JSON).
func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
data, err := s.sendLogData(r) data, err := h.sendLogData(r)
if err != nil { if err != nil {
logf("panel: send log rows: %v", err) logf("panel: send log rows: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return 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, // 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 // 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 // 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. // 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 // 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. // 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) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 { if err != nil || id <= 0 {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
row, err := s.store.GetSendLog(id) row, err := h.store.GetSendLog(id)
if err != nil { if err != nil {
// A row pruned on the retention window is gone, not broken. // A row pruned on the retention window is gone, not broken.
if errors.Is(err, store.ErrSendLogNotFound) { if errors.Is(err, store.ErrSendLogNotFound) {
@@ -85,10 +86,10 @@ func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) {
return return
} }
row.Subject = mailhdr.DecodeSubject(row.Subject) row.Subject = mailhdr.DecodeSubject(row.Subject)
logRows, logNote := s.deliveryLog(row) logRows, logNote := h.deliveryLog(row)
s.render(w, http.StatusOK, "delivery", map[string]any{ h.view.Render(w, http.StatusOK, "delivery", map[string]any{
"Title": "SelfPost — delivery", "Title": "SelfPost — delivery",
"User": currentUser(r), "User": auth.CurrentUser(r),
"Active": "deliveries", "Active": "deliveries",
"Row": row, "Row": row,
// The status in the panel's own badge vocabulary, so the headline reads // 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 // 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 // log that cannot be read at all is reported as a fault, and that one is
// logged for the operator as well. // 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 == "" { if row.QueueID == "" {
return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it." 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) { if err != nil && !errors.Is(err, fs.ErrNotExist) {
logf("panel: delivery log %s: %v", row.QueueID, err) logf("panel: delivery log %s: %v", row.QueueID, err)
return nil, "Could not read the mail log." 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 // sendLogData reads the domain/app filters and page number off the query
// string, queries the store, and assembles everything the template needs // string, queries the store, and assembles everything the template needs
// (filter dropdown options plus the current selection, rows, and pagination). // (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() q := r.URL.Query()
filter := store.SendLogFilter{ filter := store.SendLogFilter{
Domain: q.Get("domain"), Domain: q.Get("domain"),
@@ -275,11 +276,11 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
} }
page := parsePage(q.Get("p")) page := parsePage(q.Get("p"))
total, err := s.store.CountSendLog(filter) total, err := h.store.CountSendLog(filter)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -289,7 +290,7 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
for i := range rows { for i := range rows {
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject) rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
} }
domains, err := s.store.ListDomains() domains, err := h.store.ListDomains()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -297,7 +298,7 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
for i, d := range domains { for i, d := range domains {
domainNames[i] = d.Name domainNames[i] = d.Name
} }
logins, err := s.store.ListApplicationLogins() logins, err := h.store.ListApplicationLogins()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -331,23 +332,23 @@ func parsePage(v string) int {
return n return n
} }
// handleMailQueue renders the Mail queue page (architecture.md § Panel HTTP // HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP
// surface). // surface).
func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
out, errText := readQueue() 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", "Title": "SelfPost — mail queue",
"User": currentUser(r), "User": auth.CurrentUser(r),
"Active": "mail_queue", "Active": "mail_queue",
"Output": out, "Output": out,
"Error": errText, "Error": errText,
}) })
} }
// handleMailQueueBody serves the HTMX polling fragment for the queue view. // HandleMailQueueBody serves the HTMX polling fragment for the queue view.
func (s *Server) handleMailQueueBody(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) {
out, errText := readQueue() 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, "Output": out,
"Error": errText, "Error": errText,
}) })
@@ -365,30 +366,30 @@ func readQueue() (string, string) {
return out, "" 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). // Panel HTTP surface).
func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail() lines, errText := h.readLogTail()
s.render(w, http.StatusOK, "system_log", map[string]any{ h.view.Render(w, http.StatusOK, "system_log", map[string]any{
"Title": "SelfPost — system log", "Title": "SelfPost — system log",
"User": currentUser(r), "User": auth.CurrentUser(r),
"Active": "system_log", "Active": "system_log",
"Lines": lines, "Lines": lines,
"Error": errText, "Error": errText,
}) })
} }
// handleSystemLogBody serves the HTMX polling fragment for the log-tail view. // HandleSystemLogBody serves the HTMX polling fragment for the log-tail view.
func (s *Server) handleSystemLogBody(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) {
lines, errText := s.readLogTail() lines, errText := h.readLogTail()
s.renderFragment(w, http.StatusOK, "system_log_body", map[string]any{ h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{
"Lines": lines, "Lines": lines,
"Error": errText, "Error": errText,
}) })
} }
func (s *Server) readLogTail() ([]string, string) { func (h *Handlers) readLogTail() ([]string, string) {
lines, err := logtail.TailLines(s.cfg.MailLogPath, logTailLines) lines, err := logtail.TailLines(h.cfg.MailLogPath, logTailLines)
if err != nil { if err != nil {
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
// Rotation renamed the file away; Postfix recreates it on reload // 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. // than a failure worth alarming the operator about.
return nil, "" 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 nil, "Could not read the mail log."
} }
return lines, "" return lines, ""
@@ -1,4 +1,4 @@
package web package handlers
import ( import (
"net/http" "net/http"
@@ -16,9 +16,9 @@ import (
// recreate it on reload (spec B.2); a missing file in that window is a normal, // recreate it on reload (spec B.2); a missing file in that window is a normal,
// transient gap, not an operator-facing failure. // transient gap, not an operator-facing failure.
func TestReadLogTailMissingFileIsNotAnError(t *testing.T) { 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 { if lines != nil {
t.Errorf("lines = %v, want nil", lines) 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 // 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. // the rest. A column added back here is one the table has no width for.
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) { 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{ for _, want := range []string{
row.CreatedAt.Format("2006-01-02 15:04:05"), row.CreatedAt.Format("2006-01-02 15:04:05"),
"noreply@bs.example.ru", "public@example.ru", "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 // 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. // out is what keeps them readable, so the encoding must not survive to the page.
func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) { func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
s, _ := serverWithDelivery(t) h, _ := serverWithDelivery(t)
for name, out := range map[string]string{ for name, out := range map[string]string{
"log": getBody(t, s.handleDeliveries, "/deliveries"), "log": getBody(t, h.HandleDeliveries, "/deliveries"),
"rows": getBody(t, s.handleDeliveriesRows, "/deliveries/rows"), "rows": getBody(t, h.HandleDeliveriesRows, "/deliveries/rows"),
} { } {
if strings.Contains(out, "=?utf-8?Q?") { if strings.Contains(out, "=?utf-8?Q?") {
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out) 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 // 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. // per-row page — including for a row still holding an encoded subject.
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) { 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{ for _, want := range []string{
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка", "bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
"noreply@bs.example.ru", "public@example.ru", "sent", "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 // 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. // what happened to the message rather than as a list of fields.
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) { 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{ for _, want := range []string{
"Accepted and queued", "Delivered", "Accepted and queued", "Delivered",
row.CreatedAt.Format("2006-01-02 15:04:05"), 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 // for is drawn as one that has not happened rather than dated with the moment
// the row was written. // the row was written.
func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) { func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
s, _ := serverWithDelivery(t) h, _ := serverWithDelivery(t)
if err := s.store.InsertQueued(store.SendLogEntry{ if err := h.store.InsertQueued(store.SendLogEntry{
QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C", QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C",
From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going", From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going",
}); err != nil { }); err != nil {
t.Fatalf("insert: %v", err) 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 { if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows)) 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"} { for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out) 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 // lines — as a table of when and what, so the seconds between the connection
// and the reply line up down one edge. // and the reply line up down one edge.
func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) { func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
s, row := serverWithDelivery(t) h, row := serverWithDelivery(t)
s.cfg.MailLogPath = writeMailLog(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.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: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)", "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{ for _, want := range []string{
"<th>Time</th>", "<th>Message</th>", "<th>Time</th>", "<th>Message</th>",
// The stamp is split off into its own cell, without the microseconds // 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 // 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. // the log's, not ours, and a line we cannot split is a line we must not drop.
func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) { func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
s, row := serverWithDelivery(t) h, row := serverWithDelivery(t)
s.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)") 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)") { 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) 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 // 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. // queue at all. Neither is a fault, so neither may render as an error.
func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) { func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
s, row := serverWithDelivery(t) h, row := serverWithDelivery(t)
s.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created 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") { if !strings.Contains(out, "rotated away") {
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out) 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 // Send-log rows are pruned on the retention window, so a bookmarked delivery
// that no longer exists is a 404, not a 500. // that no longer exists is a 404, not a 500.
func TestDeliveryPageNotFound(t *testing.T) { func TestDeliveryPageNotFound(t *testing.T) {
s, _ := serverWithDelivery(t) h, _ := serverWithDelivery(t)
for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} { for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil) req := httptest.NewRequest(http.MethodGet, path, nil)
req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/")) req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
s.handleDelivery(rec, req) h.HandleDelivery(rec, req)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", path, rec.Code) 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 // serverWithDelivery builds a panel over a store holding one delivery, written
// the way the journal-milter wrote them before it decoded subjects itself. // 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() t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db")) st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil { if err != nil {
@@ -242,11 +242,7 @@ func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) {
t.Fatalf("query: %v (%d rows)", err, len(rows)) t.Fatalf("query: %v (%d rows)", err, len(rows))
} }
tmpl, err := loadTemplates() return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0]
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
return &Server{store: st, tmpl: tmpl, cfg: Config{Version: "test"}}, rows[0]
} }
// getBody runs one handler over a GET and returns the page it wrote, failing // getBody runs one handler over a GET and returns the page it wrote, failing
@@ -1,4 +1,4 @@
package web package handlers
import ( import (
"fmt" "fmt"
@@ -10,15 +10,8 @@ import (
"github.com/mixeme/selfpost/internal/store" "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 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 { type rateLimitInput struct {
clear bool clear bool
ips []string ips []string
@@ -26,10 +19,6 @@ type rateLimitInput struct {
windowSeconds int 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) { func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission") return rateLimitInput{}, fmt.Errorf("invalid form submission")
@@ -42,8 +31,6 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
return rateLimitInput{}, err return rateLimitInput{}, err
} }
if len(ips) == 0 { if len(ips) == 0 {
// No IP binding: the differentiated limit does not apply (guide § Rate
// limiting).
return rateLimitInput{clear: true}, nil return rateLimitInput{clear: true}, nil
} }
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0) 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 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) { func parseIPList(raw string) ([]string, error) {
fields := strings.FieldsFunc(raw, func(r rune) bool { fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';' return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
@@ -81,8 +64,6 @@ func parseIPList(raw string) ([]string, error) {
return out, nil 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) { func parsePositiveInt(raw string, def int) (int, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@@ -91,23 +72,20 @@ func parsePositiveInt(raw string, def int) (int, error) {
return strconv.Atoi(raw) return strconv.Atoi(raw)
} }
// handleDomainRateLimit saves or clears a domain-level differentiated rate func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
// limit (guide § Rate limiting). No reload is needed — the milter reads the d, ok := h.lookupDomain(w, r)
// row live.
func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
if !ok { if !ok {
return return
} }
in, err := parseRateLimitForm(r) in, err := parseRateLimitForm(r)
if err != nil { if err != nil {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard, FormMode: store.AddressModeWildcard,
RateLimitErr: err.Error(), RateLimitErr: err.Error(),
}) })
return 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) logf("panel: domain %d: save rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return 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) http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
} }
// handleAppRateLimit saves or clears an application-level differentiated rate func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
// limit (guide § Rate limiting). a, ok := h.lookupApplication(w, r)
func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok { if !ok {
return return
} }
d, err := s.domains.Get(a.DomainID) d, err := h.domains.Get(a.DomainID)
if err != nil { if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
in, err := parseRateLimitForm(r) in, err := parseRateLimitForm(r)
if err != nil { if err != nil {
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard, FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()), RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
}) })
return 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) logf("panel: application %d: save rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return 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) 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 func (h *Handlers) applyRateLimit(
// relevant service, keyed by the domain or application id.
func (s *Server) applyRateLimit(
in rateLimitInput, in rateLimitInput,
save func(id int64, ips []string, maxMessages, windowSeconds int) error, save func(id int64, ips []string, maxMessages, windowSeconds int) error,
clear func(id int64) 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
}
-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)
}
-210
View File
@@ -1,210 +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 {
profileEmail := ""
if admin, err := s.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 := 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, profileEmail, 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
}
-25
View File
@@ -1,25 +0,0 @@
package web
import (
"net/http"
"github.com/mixeme/selfpost/internal/legal"
)
// handleLicense serves the AGPL-3.0 text so every interactive page's
// "License" footer link works without leaving the panel (AGPL Appropriate
// Legal Notices). Unauthenticated on purpose: the notice must be reachable
// from the login and setup screens too.
func (s *Server) handleLicense(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(legal.License)
}
+2 -4
View File
@@ -10,10 +10,9 @@ import (
) )
func TestLicenseHandlerServesAGPL(t *testing.T) { func TestLicenseHandlerServesAGPL(t *testing.T) {
s := &Server{}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/license", nil) req := httptest.NewRequest(http.MethodGet, "/license", nil)
s.handleLicense(rec, req) handleLicense(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code) t.Fatalf("status = %d, want 200", rec.Code)
@@ -32,10 +31,9 @@ func TestLicenseHandlerServesAGPL(t *testing.T) {
} }
func TestLicenseHandlerRejectsNonGET(t *testing.T) { func TestLicenseHandlerRejectsNonGET(t *testing.T) {
s := &Server{}
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/license", nil) req := httptest.NewRequest(http.MethodPost, "/license", nil)
s.handleLicense(rec, req) handleLicense(rec, req)
if rec.Code != http.StatusMethodNotAllowed { if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code) 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")
}
}
@@ -1,4 +1,5 @@
package web // Package validate holds shared server-side form validation for the panel.
package validate
import ( import (
"fmt" "fmt"
@@ -6,24 +7,24 @@ import (
"unicode" "unicode"
) )
// minAdminPasswordLen is the floor for the administrator password. The panel is // MinAdminPasswordLen is the floor for the administrator password. The panel is
// public (security.md), so this is deliberately not tiny. // public (security.md), so this is deliberately not tiny.
const minAdminPasswordLen = 12 const MinAdminPasswordLen = 12
const ( const (
minUsernameLen = 3 minUsernameLen = 3
maxUsernameLen = 64 maxUsernameLen = 64
) )
// minSecretFilePasswordLen is the floor for the password protecting an // MinSecretFilePasswordLen is the floor for the password protecting an
// encrypted backup or domain export. Such a file is offline and can be attacked // 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 // at leisure, so the floor matches the administrator password's rather than the
// weaker "any password is better than none". // weaker "any password is better than none".
const minSecretFilePasswordLen = minAdminPasswordLen const MinSecretFilePasswordLen = MinAdminPasswordLen
// validateUsername enforces a strict server-side whitelist (security.md): // Username enforces a strict server-side whitelist (security.md): letters,
// letters, digits, dot, dash, underscore. Client validation is never trusted. // digits, dot, dash, underscore. Client validation is never trusted.
func validateUsername(u string) error { func Username(u string) error {
if len(u) < minUsernameLen || len(u) > maxUsernameLen { if len(u) < minUsernameLen || len(u) > maxUsernameLen {
return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen) return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen)
} }
@@ -35,11 +36,11 @@ func validateUsername(u string) error {
return nil return nil
} }
// validateAdminPassword enforces a minimum length. Composition rules beyond // AdminPassword enforces a minimum length. Composition rules beyond length tend
// length tend to reduce entropy in practice, so length is the sole gate. // to reduce entropy in practice, so length is the sole gate.
func validateAdminPassword(p string) error { func AdminPassword(p string) error {
if len(p) < minAdminPasswordLen { if len(p) < MinAdminPasswordLen {
return fmt.Errorf("password must be at least %d characters", minAdminPasswordLen) return fmt.Errorf("password must be at least %d characters", MinAdminPasswordLen)
} }
return nil return nil
} }
@@ -50,23 +51,19 @@ func isASCIILetterOrDigit(r rune) bool {
const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
// normalizeDomain lower-cases and trims a domain name. Domain names are // NormalizeDomain lower-cases and trims a domain name. Domain names are
// case-insensitive, and the generated OpenDKIM tables/keys use the canonical // case-insensitive, and the generated OpenDKIM tables/keys use the canonical
// lower-case form, so we normalise before both validation and storage. // lower-case form, so we normalise before both validation and storage.
func normalizeDomain(name string) string { func NormalizeDomain(name string) string {
return strings.ToLower(strings.TrimSpace(name)) return strings.ToLower(strings.TrimSpace(name))
} }
// validateDomain enforces a strict server-side whitelist for sending-domain // Domain enforces a strict server-side whitelist for sending-domain names
// names (security.md). The result is safe to write verbatim into the OpenDKIM // (security.md). The result is safe to write verbatim into the OpenDKIM
// KeyTable/SigningTable and to use as a filesystem path segment: only // KeyTable/SigningTable and to use as a filesystem path segment: only
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label // lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
// shape. Input must already be normalised with normalizeDomain. // shape. Input must already be normalised with NormalizeDomain.
// func Domain(name string) error {
// 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 == "" { if name == "" {
return fmt.Errorf("domain is required") return fmt.Errorf("domain is required")
} }
@@ -78,14 +75,14 @@ func validateDomain(name string) error {
return fmt.Errorf("domain must include at least one dot (e.g. example.com)") return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
} }
for _, label := range labels { for _, label := range labels {
if err := validateDomainLabel(label); err != nil { if err := domainLabel(label); err != nil {
return err return err
} }
} }
return nil return nil
} }
func validateDomainLabel(label string) error { func domainLabel(label string) error {
if len(label) == 0 { if len(label) == 0 {
return fmt.Errorf("domain must not contain an empty label") return fmt.Errorf("domain must not contain an empty label")
} }
@@ -121,8 +118,8 @@ var freemailDomains = map[string]struct{}{
"protonmail.com": {}, "protonmail.com": {},
} }
// validateEmail checks a DMARC rua= mailbox. Empty is allowed (policy-only). // Email checks a DMARC rua= mailbox. Empty is allowed (policy-only).
func validateEmail(addr string) error { func Email(addr string) error {
addr = strings.TrimSpace(addr) addr = strings.TrimSpace(addr)
if addr == "" { if addr == "" {
return nil return nil
@@ -132,8 +129,8 @@ func validateEmail(addr string) error {
return fmt.Errorf("enter a valid email address") return fmt.Errorf("enter a valid email address")
} }
local := addr[:at] local := addr[:at]
domain := normalizeDomain(addr[at+1:]) domain := NormalizeDomain(addr[at+1:])
if err := validateDomain(domain); err != nil { if err := Domain(domain); err != nil {
return fmt.Errorf("email domain is invalid: %w", err) return fmt.Errorf("email domain is invalid: %w", err)
} }
for _, r := range local { for _, r := range local {
@@ -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")
}
}
-18
View File
@@ -1,18 +0,0 @@
package web
import "testing"
func TestValidateEmail(t *testing.T) {
if err := validateEmail(""); err != nil {
t.Errorf("empty: %v", err)
}
if err := validateEmail("reports@mail.example.com"); err != nil {
t.Errorf("valid: %v", err)
}
if err := validateEmail("bad"); err == nil {
t.Error("bad address accepted")
}
if err := validateEmail("x@gmail.com"); err == nil {
t.Error("gmail accepted")
}
}
-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 ( import (
"crypto/sha256" "crypto/sha256"
@@ -39,7 +39,7 @@ func buildStaticETags() map[string]string {
return etags 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 // 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 // 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 // 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. // round — correctness after a deploy matters more than saving the round trip.
// http.ServeContent answers the conditional request from the ETag we set here. // 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)) files := http.FileServer(http.FS(assetsFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if etag, ok := staticETags[path.Clean(r.URL.Path)]; ok { 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

@@ -1,4 +1,4 @@
package web package view
import ( import (
"net/http" "net/http"
@@ -13,7 +13,7 @@ func serveStatic(path string, headers map[string]string) *httptest.ResponseRecor
r.Header.Set(k, v) r.Header.Set(k, v)
} }
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
staticHandler().ServeHTTP(rec, r) StaticHandler().ServeHTTP(rec, r)
return rec return rec
} }
@@ -1,4 +1,4 @@
package web package view
import ( import (
"bytes" "bytes"
@@ -18,11 +18,11 @@ import (
// every page template must resolve it. This is what makes "the nav is on every // 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. // authenticated page" a structural property instead of a checklist item.
func TestEveryPageResolvesNav(t *testing.T) { func TestEveryPageResolvesNav(t *testing.T) {
tmpl, err := loadTemplates() engine, err := New("test")
if err != nil { 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 { if page.Lookup("nav") == nil {
t.Errorf("page %q does not resolve the shared nav template", name) 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 // asserted here: the long pages produce a list, and a page that defines nothing
// produces nothing at all. // produces nothing at all.
func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) { func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) {
tmpl, err := loadTemplates() engine, err := New("test")
if err != nil { 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. // Anchors the index links to, taken from the page's own cards.
wantAnchors := map[string]string{ wantAnchors := map[string]string{
"status": `href="#certificate"`, "status": `href="#certificate"`,
"domain_detail": `href="#danger"`, "domain_detail": `href="#danger"`,
} }
for name, page := range tmpl.pages { for name, page := range engine.Pages() {
var buf bytes.Buffer var buf bytes.Buffer
// The domain page's index hides the freshly generated credential entry // 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. // 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 // nothing about rendering the page says so. Every anchor the index offers must
// name an element the same page defines an id for. // name an element the same page defines an id for.
func TestSectionLinksPointAtCardsThatExist(t *testing.T) { func TestSectionLinksPointAtCardsThatExist(t *testing.T) {
tmpl, err := loadTemplates() engine, err := New("test")
if err != nil { 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, // The pages that carry an index; both are checked with a credential shown,
// which is the domain page's one conditional entry. // which is the domain page's one conditional entry.
for _, name := range []string{"status", "domain_detail"} { for _, name := range []string{"status", "domain_detail"} {
var index bytes.Buffer 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) t.Fatalf("execute sections for %q: %v", name, err)
} }
// The cards are spread over the page's template files, so the ids are // The cards are spread over the page's template files, so the ids are
@@ -108,9 +108,9 @@ func TestSectionLinksPointAtCardsThatExist(t *testing.T) {
// Appropriate Legal Notices (copyright, licence, source, no warranty) must // Appropriate Legal Notices (copyright, licence, source, no warranty) must
// appear on every page, including the signed-out ones. // appear on every page, including the signed-out ones.
func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) { func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
tmpl, err := loadTemplates() engine, err := New("9.9.9-test")
if err != nil { if err != nil {
t.Fatalf("loadTemplates: %v", err) t.Fatalf("New: %v", err)
} }
legalBits := []string{ legalBits := []string{
"Copyright © 2026 Mikhail Yenuchenko", "Copyright © 2026 Mikhail Yenuchenko",
@@ -121,9 +121,9 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
"No warranty", "No warranty",
} }
rendered := 0 rendered := 0
for name := range tmpl.pages { for name := range engine.Pages() {
var buf bytes.Buffer 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", "Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko", "Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost", "SourceURL": "https://github.com/mixeme/selfpost",
@@ -152,7 +152,7 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
// Signed out (login, setup) the version must not be advertised, but the // Signed out (login, setup) the version must not be advertised, but the
// Appropriate Legal Notices must still be present. // Appropriate Legal Notices must still be present.
var buf bytes.Buffer 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", "Title": "t", "Active": "", "Version": "9.9.9-test",
"Copyright": "Copyright © 2026 Mikhail Yenuchenko", "Copyright": "Copyright © 2026 Mikhail Yenuchenko",
"SourceURL": "https://github.com/mixeme/selfpost", "SourceURL": "https://github.com/mixeme/selfpost",
@@ -171,14 +171,13 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
} }
func TestRenderSuppliesTheVersion(t *testing.T) { func TestRenderSuppliesTheVersion(t *testing.T) {
tmpl, err := loadTemplates() engine, err := New("9.9.9-test")
if err != nil { 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() rec := httptest.NewRecorder()
data := map[string]any{"Title": "t", "User": "admin"} 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 { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code) t.Fatalf("status = %d, want 200", rec.Code)
@@ -202,12 +201,12 @@ func TestRenderSuppliesTheVersion(t *testing.T) {
} }
func TestNavMarksActivePage(t *testing.T) { func TestNavMarksActivePage(t *testing.T) {
tmpl, err := loadTemplates() engine, err := New("test")
if err != nil { if err != nil {
t.Fatalf("loadTemplates: %v", err) t.Fatalf("New: %v", err)
} }
var buf bytes.Buffer 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", "User": "admin",
"Active": "mail_queue", "Active": "mail_queue",
}) })
@@ -229,12 +228,12 @@ func TestNavMarksActivePage(t *testing.T) {
} }
func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) { func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
tmpl, err := loadTemplates() engine, err := New("test")
if err != nil { if err != nil {
t.Fatalf("loadTemplates: %v", err) t.Fatalf("New: %v", err)
} }
var buf bytes.Buffer 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", "User": "admin",
"Active": "status", "Active": "status",
}); err != nil { }); err != nil {
@@ -258,12 +257,12 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
// silently comes back at the measure, with its table squeezed into two thirds // 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. // of the column — so the set is asserted here, in both directions.
func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) { func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
tmpl, err := loadTemplates() engine, err := New("test")
if err != nil { 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} 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 var buf bytes.Buffer
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil { if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
t.Fatalf("execute the wide block of %s: %v", name, err) t.Fatalf("execute the wide block of %s: %v", name, err)
@@ -394,12 +393,12 @@ func TestStatusPageWithoutMachineMetrics(t *testing.T) {
func renderStatusPage(t *testing.T, data map[string]any) string { func renderStatusPage(t *testing.T, data map[string]any) string {
t.Helper() t.Helper()
tmpl, err := loadTemplates() engine, err := New("test")
if err != nil { if err != nil {
t.Fatalf("loadTemplates: %v", err) t.Fatalf("New: %v", err)
} }
var buf bytes.Buffer 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) t.Fatalf("execute status page: %v", err)
} }
return buf.String() return buf.String()
@@ -460,7 +459,7 @@ func statusPageData() map[string]any {
} }
// dnscheckResult mirrors dnscheck.Result's shape for the template test, so the // 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 { type dnscheckResult struct {
Status health.Status Status health.Status
Detail string Detail string
@@ -1,24 +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 ( import (
"bytes" "bytes"
"embed"
"fmt" "fmt"
"html/template" "html/template"
"log"
"net/http" "net/http"
"github.com/mixeme/selfpost/internal/legal" "github.com/mixeme/selfpost/internal/legal"
) )
// templates holds the parsed page and fragment templates. Each page is parsed //go:embed templates/*.html static/*
// together with the shared base layout so {{ template "base" . }} works. var assetsFS embed.FS
// Fragments (HTMX polling targets, architecture.md § Panel HTTP surface) are
// parsed standalone, without the layout, so they can be swapped into an // Engine holds parsed page and fragment templates.
// existing page as an HTML snippet rather than a full document. Rendering type Engine struct {
// always goes through html/template, which auto-escapes all interpolated data
// regardless (security.md).
type templates struct {
pages map[string]*template.Template pages map[string]*template.Template
fragments map[string]*template.Template fragments map[string]*template.Template
version string
} }
// pageFiles maps a logical page name to its template files. Every page // pageFiles maps a logical page name to its template files. Every page
@@ -52,10 +54,12 @@ var fragmentFiles = map[string]string{
"status_body": "templates/status_body.html", "status_body": "templates/status_body.html",
} }
func loadTemplates() (*templates, error) { // New parses embedded templates. version is stamped into every page footer.
t := &templates{ func New(version string) (*Engine, error) {
e := &Engine{
pages: make(map[string]*template.Template), pages: make(map[string]*template.Template),
fragments: make(map[string]*template.Template), fragments: make(map[string]*template.Template),
version: version,
} }
for name, files := range pageFiles { for name, files := range pageFiles {
patterns := append([]string{"templates/layout.html"}, files...) patterns := append([]string{"templates/layout.html"}, files...)
@@ -63,22 +67,33 @@ func loadTemplates() (*templates, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("parse template %s: %w", name, err) return nil, fmt.Errorf("parse template %s: %w", name, err)
} }
t.pages[name] = tmpl e.pages[name] = tmpl
} }
for name, file := range fragmentFiles { for name, file := range fragmentFiles {
tmpl, err := template.ParseFS(assetsFS, file) tmpl, err := template.ParseFS(assetsFS, file)
if err != nil { if err != nil {
return nil, fmt.Errorf("parse fragment %s: %w", name, err) 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. // 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) { func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any) {
tmpl, ok := s.tmpl.pages[page] tmpl, ok := e.pages[page]
if !ok { if !ok {
http.Error(w, "template not found", http.StatusInternalServerError) http.Error(w, "template not found", http.StatusInternalServerError)
return return
@@ -92,13 +107,13 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any
if _, has := m["Active"]; !has { if _, has := m["Active"]; !has {
m["Active"] = "" m["Active"] = ""
} }
m["Version"] = s.cfg.Version m["Version"] = e.version
m["Copyright"] = legal.CopyrightLine m["Copyright"] = legal.CopyrightLine
m["SourceURL"] = legal.SourceURL m["SourceURL"] = legal.SourceURL
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil { 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) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
@@ -107,18 +122,18 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any
_, _ = buf.WriteTo(w) _, _ = 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 // no surrounding layout (architecture.md § Panel HTTP surface: fragment
// endpoints return HTML, not JSON). // endpoints return HTML, not JSON).
func (s *Server) renderFragment(w http.ResponseWriter, status int, name string, data any) { func (e *Engine) RenderFragment(w http.ResponseWriter, status int, name string, data any) {
tmpl, ok := s.tmpl.fragments[name] tmpl, ok := e.fragments[name]
if !ok { if !ok {
http.Error(w, "template not found", http.StatusInternalServerError) http.Error(w, "template not found", http.StatusInternalServerError)
return return
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil { 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) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
+81 -156
View File
@@ -4,27 +4,25 @@
package web package web
import ( import (
"embed"
"log" "log"
"net" "net"
"net/http" "net/http"
"strings"
"time"
"github.com/mixeme/selfpost/internal/app" "github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/legal"
"github.com/mixeme/selfpost/internal/store" "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. // Config holds the panel's HTTP-facing configuration.
type Config struct { type Config struct {
// Hostname is the server's external hostname, used to build the absolute // Hostname is the server's external hostname, used to build the absolute
// setup link shown in the logs (security.md; guide В§ Environment // setup link shown in the logs (security.md; guide § Environment
// variables for SELFPOST_HOSTNAME). // variables for SELFPOST_HOSTNAME).
Hostname string Hostname string
// CookieSecure sets the Secure attribute on the session cookie. It defaults // CookieSecure sets the Secure attribute on the session cookie. It defaults
@@ -33,16 +31,16 @@ type Config struct {
CookieSecure bool CookieSecure bool
// SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also // SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also
// runs the 587/STARTTLS submission listener next to the primary 465 one // runs the 587/STARTTLS submission listener next to the primary 465 one
// (architecture.md В§ Mail path). The panel only reports it on the domain // (architecture.md § Mail path). The panel only reports it on the domain
// page's connection settings; it is a deploy-time flag, not something the // page's connection settings; it is a deploy-time flag, not something the
// panel can verify. // panel can verify.
SubmissionEnabled bool SubmissionEnabled bool
// MailLogPath is where Postfix's delivery log lives, read by the mail.log // MailLogPath is where Postfix's delivery log lives, read by the mail.log
// monitoring view (architecture.md В§ Panel HTTP surface). It is the same path // monitoring view (architecture.md § Panel HTTP surface). It is the same path
// the log-tailer role follows in cmd/panel. // the log-tailer role follows in cmd/panel.
MailLogPath string MailLogPath string
// DataDir and DBPath locate the persistent state a full backup archives // DataDir and DBPath locate the persistent state a full backup archives
// (architecture.md В§ Persistence); Version is stamped into the backup // (architecture.md § Persistence); Version is stamped into the backup
// manifest. They mirror the panel's own configuration. // manifest. They mirror the panel's own configuration.
DataDir string DataDir string
DBPath string DBPath string
@@ -53,7 +51,7 @@ type Config struct {
// honoured, so the header can't be spoofed by anyone but a trusted proxy. // honoured, so the header can't be spoofed by anyone but a trusted proxy.
// Empty (the default) keeps rate-limiting keyed on RemoteAddr only. // Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
TrustedProxyCIDRs []*net.IPNet TrustedProxyCIDRs []*net.IPNet
// TLSCertFile is the certificate Postfix serves on 465/587 (guide В§ // TLSCertFile is the certificate Postfix serves on 465/587 (guide §
// Environment variables), read read-only by the status page to report how // Environment variables), read read-only by the status page to report how
// much validity is left. // much validity is left.
TLSCertFile string TLSCertFile string
@@ -69,156 +67,107 @@ type Config struct {
SessionIdleDays int SessionIdleDays int
// DNSResolvers are the recursive resolvers the deliverability checks query // DNSResolvers are the recursive resolvers the deliverability checks query
// (env SELFPOST_DNS_RESOLVERS). Empty uses dnscheck.DefaultResolvers. The // (env SELFPOST_DNS_RESOLVERS). Empty uses dnscheck.DefaultResolvers. The
// checks must not go through the system resolver — see dnscheck's // checks must not go through the system resolver see dnscheck's
// externalResolver — so this is how a closed network points them at its own. // externalResolver so this is how a closed network points them at its own.
DNSResolvers []string DNSResolvers []string
} }
// Server is the panel HTTP application. // Server is the panel HTTP application.
type Server struct { type Server struct {
store *store.Store
domains *domain.Service
apps *app.Service
cfg Config cfg Config
tmpl *templates auth *auth.Module
sessions *sessionStore handlers *handlers.Handlers
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
} }
// New builds the panel server. setupTokenPath is where the current setup token // New builds the panel server. setupTokenPath is where the current setup token
// is mirrored on disk (security.md); domains is the sending-domain service // is mirrored on disk (security.md); domains is the sending-domain service
// that owns DKIM keys and the OpenDKIM tables (architecture.md В§ OpenDKIM); // that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
// apps owns application SASL accounts and the Postfix sender map // apps owns application SASL accounts and the Postfix sender map
// (architecture.md В§ Mail path). // (architecture.md § Mail path).
func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) { 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 { if err != nil {
return nil, err return nil, err
} }
idleDays := cfg.SessionIdleDays a := auth.New(st, auth.Config{
if idleDays <= 0 { CookieSecure: cfg.CookieSecure,
idleDays = 7 Hostname: cfg.Hostname,
} SessionIdleDays: cfg.SessionIdleDays,
s := &Server{ TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
store: st, }, v, setupTokenPath)
domains: domains, h := handlers.New(st, domains, apps, handlers.Config{
apps: apps, Hostname: cfg.Hostname,
cfg: cfg, SubmissionEnabled: cfg.SubmissionEnabled,
tmpl: tmpl, MailLogPath: cfg.MailLogPath,
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour), DataDir: cfg.DataDir,
// Published-DNS checks for the status page and the domain pages. The DBPath: cfg.DBPath,
// checker caches its own results, so page views do not each pay for a Version: cfg.Version,
// round of lookups. TLSCertFile: cfg.TLSCertFile,
dns: dnscheck.New(cfg.DNSResolvers), OpenDKIMSocket: cfg.OpenDKIMSocket,
// Setup: a handful of attempts per minute per IP is plenty for a JournalSocket: cfg.JournalSocket,
// legitimate admin and blunts automated probing (security.md). }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
setupLimiter: newRateLimiter(10, time.Minute), return &Server{cfg: cfg, auth: a, handlers: h}, nil
// 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
} }
// Start performs first-run bootstrapping: if there is no administrator yet, it // 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 // generates and announces the setup link (security.md). Safe to call once at
// server startup. // server startup.
func (s *Server) Start() error { func (s *Server) Start() error {
return s.setup.bootstrap() return s.auth.Bootstrap()
} }
// Handler returns the panel's HTTP handler (router). // Handler returns the panel's HTTP handler (router).
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
h := s.handlers
// Health check stays unauthenticated for the container/orchestrator.
mux.HandleFunc("/healthz", handleHealth) 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)
// AGPL Appropriate Legal Notices: the licence text itself, reachable
// without a session so the login and setup footers can link to it.
mux.HandleFunc("/license", s.handleLicense)
// 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() 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 /{$}", redirectToStatus)
authed.HandleFunc("GET /status", s.handleStatus) authed.HandleFunc("GET /status", h.HandleStatus)
authed.HandleFunc("GET /status/fragment", s.handleStatusFragment) authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment)
authed.HandleFunc("POST /status/recheck", s.handleStatusRecheck) authed.HandleFunc("POST /status/recheck", h.HandleStatusRecheck)
authed.HandleFunc("GET /domains", s.handleDashboard) authed.HandleFunc("GET /domains", h.HandleDashboard)
authed.HandleFunc("POST /domains", s.handleAddDomain) authed.HandleFunc("POST /domains", h.HandleAddDomain)
authed.HandleFunc("POST /domains/import", s.handleImportDomain) authed.HandleFunc("POST /domains/import", h.HandleImportDomain)
authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail) authed.HandleFunc("GET /domains/{id}", h.HandleDomainDetail)
authed.HandleFunc("POST /domains/{id}/dns-recheck", s.handleDomainDNSRecheck) authed.HandleFunc("POST /domains/{id}/dns-recheck", h.HandleDomainDNSRecheck)
authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm) authed.HandleFunc("GET /domains/{id}/delete", h.HandleDeleteConfirm)
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain) authed.HandleFunc("POST /domains/{id}/delete", h.HandleDeleteDomain)
authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication) authed.HandleFunc("POST /domains/{id}/applications", h.HandleAddApplication)
authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit) authed.HandleFunc("POST /domains/{id}/ratelimit", h.HandleDomainRateLimit)
authed.HandleFunc("POST /domains/{id}/dmarc", s.handleDomainDMARC) authed.HandleFunc("POST /domains/{id}/dmarc", h.HandleDomainDMARC)
authed.HandleFunc("POST /domains/{id}/export", s.handleExportDomain) authed.HandleFunc("POST /domains/{id}/export", h.HandleExportDomain)
authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode) authed.HandleFunc("POST /applications/{aid}/mode", h.HandleUpdateAppMode)
authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword) authed.HandleFunc("POST /applications/{aid}/password", h.HandleRegenPassword)
authed.HandleFunc("POST /applications/{aid}/ratelimit", s.handleAppRateLimit) authed.HandleFunc("POST /applications/{aid}/ratelimit", h.HandleAppRateLimit)
authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication) authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
authed.HandleFunc("POST /reload", s.handleReload) authed.HandleFunc("POST /reload", h.HandleReload)
// Administrator's own panel credentials. authed.HandleFunc("/account", h.HandleAccount)
authed.HandleFunc("/account", s.handleAccount)
// Backup and migration: the page with both actions (architecture.md В§ authed.HandleFunc("GET /backup", h.HandleBackupPage)
// Persistence-B), and the full-server backup download itself. authed.HandleFunc("POST /backup", h.HandleBackup)
authed.HandleFunc("GET /backup", s.handleBackupPage)
authed.HandleFunc("POST /backup", s.handleBackup)
// Monitoring screens (architecture.md В§ Panel HTTP surface): each page and authed.HandleFunc("GET /deliveries", h.HandleDeliveries)
// its HTMX polling fragment (architecture.md § Panel HTTP surface — the /rows authed.HandleFunc("GET /deliveries/rows", h.HandleDeliveriesRows)
// and /body endpoints return HTML, not JSON). authed.HandleFunc("GET /deliveries/{id}", h.HandleDelivery)
authed.HandleFunc("GET /deliveries", s.handleDeliveries) authed.HandleFunc("GET /mail-queue", h.HandleMailQueue)
authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows) authed.HandleFunc("GET /mail-queue/body", h.HandleMailQueueBody)
authed.HandleFunc("GET /deliveries/{id}", s.handleDelivery) authed.HandleFunc("GET /system-log", h.HandleSystemLog)
authed.HandleFunc("GET /mail-queue", s.handleMailQueue) authed.HandleFunc("GET /system-log/body", h.HandleSystemLogBody)
authed.HandleFunc("GET /mail-queue/body", s.handleMailQueueBody)
authed.HandleFunc("GET /system-log", s.handleSystemLog)
authed.HandleFunc("GET /system-log/body", s.handleSystemLogBody)
mux.Handle("/", s.requireAuth(authed)) mux.Handle("/", s.auth.RequireAuth(authed))
// Security headers and the origin check wrap everything, including the
// unauthenticated login and setup routes.
return s.secure(mux) 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) { func redirectToStatus(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/status", http.StatusSeeOther) http.Redirect(w, r, "/status", http.StatusSeeOther)
} }
@@ -233,44 +182,20 @@ func handleHealth(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok\n")) _, _ = w.Write([]byte("ok\n"))
} }
// clientIP extracts the peer IP for rate-limiting. By default it is the func handleLicense(w http.ResponseWriter, r *http.Request) {
// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches if r.Method != http.MethodGet && r.Method != http.MethodHead {
// one of trustedProxies, the last entry of X-Forwarded-For is used instead — http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
// that is the address the trusted proxy itself appended, so a client can't return
// 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
} }
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if len(trustedProxies) > 0 { w.Header().Set("X-Content-Type-Options", "nosniff")
if peer := net.ParseIP(host); peer != nil && ipInAny(peer, trustedProxies) { w.WriteHeader(http.StatusOK)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if r.Method == http.MethodHead {
parts := strings.Split(xff, ",") return
if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil {
return ip.String()
} }
} _, _ = w.Write(legal.License)
}
}
return host
} }
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) { func logf(format string, args ...any) {
log.Printf(format, args...) log.Printf(format, args...)
} }