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>
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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=<public@example.ru>, status=sent (250 OK)") {
|
if !strings.Contains(out, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, 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,
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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 ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 ""
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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,
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
// which is an acceptable backstop for a single-admin panel.
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
func clientIP(r *http.Request, trustedProxies []*net.IPNet) string {
|
w.WriteHeader(http.StatusOK)
|
||||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
if r.Method == http.MethodHead {
|
||||||
if err != nil {
|
return
|
||||||
host = r.RemoteAddr
|
}
|
||||||
|
_, _ = w.Write(legal.License)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||