Phase 2: SQLite persistence, admin setup-link, login/sessions
Implements the secure single-admin panel entry (spec 7.6). - internal/store: modernc.org/sqlite (pure Go, static build), WAL + foreign keys, embedded PRAGMA user_version migrations; schema 0001 covers admin/settings/domains/applications/send_log/rate_limits (spec 9). - Setup secret-link (spec 7.6.1): 128-bit crypto/rand token, printed to log + /data/setup-token (0600), 10-min TTL with regeneration, per-IP rate limit, subtle.ConstantTimeCompare, failures don't invalidate, one-time admin form, permanent invalidation once admin exists (/setup 404). - bcrypt admin password; server-side username/password validation. - Login + in-memory sessions, crypto-random token, cookie HttpOnly/Secure/SameSite (Secure toggleable for dev HTTP), login rate limit, auth middleware. - html/template base layout + setup/login/dashboard, vendored htmx 2.0.4. - build/entrypoint.sh: fix bind-mounted /data ownership as root before supervisord drops to the unprivileged panel user (found via container test). Verified on selfpost.example.com: go vet/build/test/gofmt clean; e2e curl of setup+login flows; docker build + run with -v ./data:/data creates the DB and 0600 token owned by panel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+8
-6
@@ -13,9 +13,9 @@ WORKDIR /src
|
||||
# Version stamped into both binaries; MUST match the image tag (spec 7.5.A).
|
||||
ARG VERSION=dev
|
||||
|
||||
# Module metadata first for layer caching. No go.sum yet — Phase 1 has no
|
||||
# third-party dependencies.
|
||||
COPY go.mod ./
|
||||
# Module metadata first for layer caching. go.sum arrived in Phase 2 with the
|
||||
# SQLite driver and bcrypt.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd ./cmd
|
||||
@@ -69,11 +69,13 @@ COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup
|
||||
COPY build/opendkim.conf /etc/opendkim.conf
|
||||
COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
|
||||
COPY build/crashexit.py /usr/local/bin/crashexit.py
|
||||
COPY build/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY build/supervisord.conf /etc/supervisor/supervisord.conf
|
||||
RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/crashexit.py
|
||||
RUN chmod +x /usr/local/bin/postfix-wrapper.sh /usr/local/bin/crashexit.py /usr/local/bin/entrypoint.sh
|
||||
|
||||
# 8080 panel; 25 outbound; 465/587 inbound submission (used from Phase 5).
|
||||
EXPOSE 8080 25 465 587
|
||||
|
||||
# supervisord is PID 1 and owns process supervision + ordering (spec 4).
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
# The entrypoint fixes /data ownership (bind mount) as root, then execs
|
||||
# supervisord, which becomes PID 1 and owns process supervision (spec 4).
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Container entrypoint (runs as root, PID 1 until it execs supervisord).
|
||||
#
|
||||
# The persistent root /data is a host bind mount (spec 9), so it arrives owned
|
||||
# by the host user (typically root), not by the unprivileged panel user that
|
||||
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8).
|
||||
# Fix its ownership here — the one place still running as root — before handing
|
||||
# off to supervisord, which starts the panel as the panel user.
|
||||
set -e
|
||||
|
||||
chown panel:panel /data
|
||||
# Restored backups or previously-created state may contain panel-owned files
|
||||
# under /data; make sure they stay writable without disturbing anything that a
|
||||
# later phase deliberately hands to another service.
|
||||
find /data -mindepth 1 -maxdepth 1 ! -user panel -exec chown -R panel:panel {} +
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||
+26
-39
@@ -6,19 +6,35 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
"codeberg.org/mix/selfpost/internal/web"
|
||||
)
|
||||
|
||||
// serveHTTP runs the panel's HTTP server until ctx is cancelled. Phase 1 serves
|
||||
// only a placeholder page and a health check; the login flow and real UI arrive
|
||||
// in Phase 2.
|
||||
func serveHTTP(ctx context.Context, addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", handleHealth)
|
||||
mux.HandleFunc("/", handleIndex)
|
||||
// serveHTTP opens the panel database and runs the control-panel HTTP server
|
||||
// until ctx is cancelled. From Phase 2 this serves the real setup, login and
|
||||
// authenticated panel surface (spec 7.6).
|
||||
func serveHTTP(ctx context.Context, cfg config) error {
|
||||
st, err := store.Open(cfg.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
srvApp, err := web.New(st, web.Config{
|
||||
Hostname: cfg.hostname,
|
||||
CookieSecure: cfg.cookieSecure,
|
||||
}, cfg.setupTokenPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := srvApp.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
Addr: cfg.httpAddr,
|
||||
Handler: srvApp.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
@@ -30,38 +46,9 @@ func serveHTTP(ctx context.Context, addr string) error {
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
log.Printf("http panel listening on %s", addr)
|
||||
log.Printf("http panel listening on %s", cfg.httpAddr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
}
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(indexHTML))
|
||||
}
|
||||
|
||||
const indexHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SelfPost</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>SelfPost</h1>
|
||||
<p>The control panel is starting up. Administrator setup and login arrive in a later build.</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
+17
-1
@@ -14,6 +14,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
@@ -43,13 +44,28 @@ type config struct {
|
||||
httpAddr string
|
||||
journalSocket string
|
||||
mailLog string
|
||||
|
||||
dataDir string
|
||||
dbPath string
|
||||
setupTokenPath string
|
||||
hostname string
|
||||
cookieSecure bool
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
||||
return config{
|
||||
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
|
||||
journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
|
||||
mailLog: envDefault("MAIL_LOG", "/var/log/mail.log"),
|
||||
|
||||
dataDir: dataDir,
|
||||
dbPath: envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")),
|
||||
setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
|
||||
hostname: os.Getenv("SELFPOST_HOSTNAME"),
|
||||
// Secure cookies by default (spec 7.6.6); PANEL_COOKIE_SECURE=false is a
|
||||
// development-only escape hatch for testing over plain HTTP.
|
||||
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +95,7 @@ func run() error {
|
||||
name string
|
||||
fn func(context.Context) error
|
||||
}{
|
||||
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg.httpAddr) }},
|
||||
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg) }},
|
||||
{"journal-milter", func(ctx context.Context) error { return serveJournalStub(ctx, cfg.journalSocket) }},
|
||||
{"log-tailer", func(ctx context.Context) error { return tailMailLog(ctx, cfg.mailLog) }},
|
||||
}
|
||||
|
||||
+14
-4
@@ -46,10 +46,19 @@
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
- **Текущая фаза:** 1 ✅ закрыта → следующая **Фаза 2** (SQLite + setup-link + вход админа)
|
||||
- **Модель для Фазы 2:** Opus (безопасность 7.6: крипто-токен, сессии, bcrypt)
|
||||
- **Статус:** образ собирается и проверен на сервере; три процесса живы, холодный старт и crashexit подтверждены
|
||||
- **Следующий шаг (Фаза 2):** SQLite-схема + миграции (домены, приложения, админ, `send_log`, лимиты, настройки, флаг «настройка завершена»/setup-токен), единый корень `/data`; **setup secret-link** (токен ≥128 бит `crypto/rand`, TTL 10 мин с перегенерацией, rate-limit маршрута, `subtle.ConstantTimeCompare`, неудачи НЕ инвалидируют токен, одноразовая форма создания админа, инвалидация навсегда после успеха → `/setup/*` 404); bcrypt-хэш пароля админа; логин + сессии (крипто-токен, cookie `HttpOnly`/`Secure`/`SameSite`), rate-limit логина; базовый layout `html/template` + вендоренный HTMX + auth-middleware. Выбрать драйвер `modernc.org/sqlite` (первая сторонняя зависимость — появится `go.sum`). Проверка: первый запуск печатает setup-ссылку, админ создаётся один раз, повторный `/setup` → 404, вход/выход работают.
|
||||
- **Текущая фаза:** 2 ✅ закрыта → следующая **Фаза 3** (домены + OpenDKIM)
|
||||
- **Модель для Фазы 3:** Opus (генерация конфигов + exec-safety 7.6.3–4, валидация имени домена 7.6.2)
|
||||
- **Статус:** SQLite-персистентность, setup secret-link и вход админа реализованы и проверены на сервере (`go vet`/`build`/`test` зелёные, docker-образ собирается, контейнер поднимает три процесса и создаёт БД под `panel`)
|
||||
- **Следующий шаг (Фаза 3):** добавить/список/удалить домен (при удалении — предупреждение о каскаде приложений, ТЗ 7.2.4); генерация DKIM-ключа + селектор per-domain (дефолт из `DKIM_SELECTOR_DEFAULT`), ключи в `/data/...` переживают рестарт (ТЗ 6, 9); `KeyTable`/`SigningTable` + reload OpenDKIM (сейчас `opendkim.conf` в Mode `v` без ключей — перевести в `s` + KeyTable); показ DKIM TXT-записи per-domain (ТЗ 7.2.10). **Безопасность:** строгая валидация имени домена (whitelist, 7.6.2), безопасная запись конфигов с экранированием (7.6.4), `os/exec` без shell и без интерполяции ввода (7.6.3). Таблицы `domains`/`applications`/`application_addresses` уже в схеме (миграция 0001).
|
||||
|
||||
### Сделано в Фазе 2
|
||||
- **SQLite-персистентность** (`internal/store`): драйвер `modernc.org/sqlite` (чистый Go, без cgo — статик-бинарник сохранён; первые сторонние зависимости → появились `go.mod` require + `go.sum`), WAL + `foreign_keys(ON)` + `busy_timeout` через DSN `_pragma`, `MaxOpenConns(1)`. Встроенные (`embed`) нумерованные миграции с версионированием через `PRAGMA user_version`; миграция `0001_init.sql` заводит всю схему ТЗ 9: `admin` (одна строка, `CHECK id=1`), `settings`, `domains`, `applications`, `application_addresses`, `send_log` (+индексы), `rate_limits`. Запросы `AdminExists/CreateAdmin/GetAdmin`.
|
||||
- **Setup secret-link** (`internal/web/setup.go`, ТЗ 7.6.1): токен 128 бит из `crypto/rand` (base64url), ссылка `https://<SELFPOST_HOSTNAME>/setup/<token>` печатается в лог **и** пишется в `/data/setup-token` (0600); TTL 10 мин с перегенерацией при истечении/рестарте (пока нет админа); сравнение `subtle.ConstantTimeCompare`; **неудачи НЕ инвалидируют токен**; «настройка завершена» = наличие строки `admin` (источник истины), поэтому после успеха токен сгорает навсегда и весь `/setup/*` → 404. Форма создания админа — одноразовая; гонка двух POST безопасна (`CHECK id=1` + проверка существования).
|
||||
- **Пароль админа** — только `bcrypt` (`golang.org/x/crypto/bcrypt`, DefaultCost); серверная валидация (username whitelist 7.6.2, пароль ≥12).
|
||||
- **Логин + сессии** (`internal/web/handlers_auth.go`, `session.go`): вход сверяет username + bcrypt (bcrypt считается всегда — timing-инвариантно), сессии в памяти (не в списке ТЗ 9 на персист — рестарт просто разлогинивает), крипто-токен 256 бит; cookie `HttpOnly`/`Secure`/`SameSite=Lax` (`Secure` по умолчанию, отключается `PANEL_COOKIE_SECURE=false` только для dev-HTTP); rate-limit логина (`ratelimit.go`, 10/15мин по IP) и отдельный на `/setup` (10/мин); auth-middleware защищает панель.
|
||||
- **Front-end**: базовый layout `html/template` (автоэкранирование, 7.6.7) + страницы setup/login/dashboard (`embed`); вендоренный `htmx.min.js` 2.0.4 (`internal/web/static`, отдаётся с `/static/`).
|
||||
- **Entrypoint для bind-mount** (`build/entrypoint.sh`): `/data` — host bind mount → приходит от root, а панель работает под непривилегированным `panel` (uid 999). Точка входа под root чинит владельца `/data` перед `exec supervisord` (иначе SQLite `unable to open database file`). Найдено и исправлено при контейнерной проверке.
|
||||
- **Проверено на сервере** (selfpost.example.com): `go vet`/`go build`/`go test`/`gofmt -l` чисто; функциональный e2e (curl): setup-ссылка печатается+в файл, `GET /setup/<token>`→200, неверный/просроченный→404, `POST /setup` создаёт админа→303, повторный `/setup`→404, файл токена удалён; плохой логин→401, хороший→303 с cookie `HttpOnly; Secure; SameSite=Lax`, `/`→200, logout→303, после logout `/`→303; рестарт с существующим админом не печатает setup. Docker-образ собирается; контейнер с `-v ./data:/data`: три процесса, БД+токен создаются под `panel`, токен 0600.
|
||||
|
||||
### Сделано в Фазе 1
|
||||
- **Образ** `build/Dockerfile` (bookworm-slim): многостадийная статическая сборка Go (`CGO_ENABLED=0`, `go vet` в сборке); рантайм — postfix, opendkim(+tools), cyrus-sasl (`sasl2-bin`, `libsasl2-modules`), supervisor, logrotate, ca-certificates; `maillog_file=/var/log/mail.log`; непривилегированный пользователь `panel` (ТЗ 7.6.8); `.dockerignore` (dev/ и docs/ не попадают в контекст).
|
||||
@@ -82,3 +91,4 @@
|
||||
|
||||
- **Фаза 0** (2026-07-11, Opus) — каркас проекта + build-пайплайн + спайк go-milter (риск ТЗ 7.3 снят). Коммиты `4e589e1` (каркас), `87388b4` (план).
|
||||
- **Фаза 1** (2026-07-11, Opus) — Docker-образ + supervisord + три процесса, холодный старт (обёртка ждёт milter-сокеты) и crashexit проверены на сервере. Коммит `ed9e942`.
|
||||
- **Фаза 2** (2026-07-11, Opus) — SQLite (`modernc.org/sqlite`, миграции, схема ТЗ 9), setup secret-link (128-бит токен, TTL 10м, const-time, одноразово), bcrypt-админ, логин/сессии/cookie-флаги, rate-limit setup+логина, html/template + вендоренный HTMX, auth-middleware; entrypoint чинит владельца bind-mount `/data`. Проверено на сервере (e2e curl + docker run).
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
module codeberg.org/mix/selfpost
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.54.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,61 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet.
|
||||
var ErrNoAdmin = errors.New("no administrator account")
|
||||
|
||||
// Admin is the single panel administrator (spec 7.6.1).
|
||||
type Admin struct {
|
||||
Username string
|
||||
PasswordHash string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AdminExists reports whether the administrator account has been created. This
|
||||
// doubles as the "primary setup complete" flag: once true, the /setup route is
|
||||
// permanently gone (spec 7.6.1).
|
||||
func (s *Store) AdminExists() (bool, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil {
|
||||
return false, fmt.Errorf("count admin: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// CreateAdmin inserts the administrator row. It fails if one already exists,
|
||||
// which — combined with the id=1 constraint — makes admin creation one-shot
|
||||
// even under a race between two setup submissions.
|
||||
func (s *Store) CreateAdmin(username, passwordHash string) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO admin (id, username, password_hash, created_at) VALUES (1, ?, ?, ?)",
|
||||
username, passwordHash, time.Now().UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create admin: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending.
|
||||
func (s *Store) GetAdmin() (Admin, error) {
|
||||
var (
|
||||
a Admin
|
||||
createdAt string
|
||||
)
|
||||
err := s.db.QueryRow("SELECT username, password_hash, created_at FROM admin WHERE id = 1").
|
||||
Scan(&a.Username, &a.PasswordHash, &createdAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Admin{}, ErrNoAdmin
|
||||
}
|
||||
if err != nil {
|
||||
return Admin{}, fmt.Errorf("get admin: %w", err)
|
||||
}
|
||||
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Initial SelfPost schema (spec 9). One SQLite file under /data holds the whole
|
||||
-- panel state so a single directory backup/restore is sufficient (spec 7.5.A).
|
||||
|
||||
-- Single administrator account (spec 7.6.1). Exactly one row is allowed; the
|
||||
-- presence of that row is what marks primary setup as complete, which is why
|
||||
-- the /setup route disappears once it exists.
|
||||
CREATE TABLE admin (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Free-form key/value panel settings (retention overrides, misc flags).
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Sending domains managed through the panel (spec 4.1). DKIM keys themselves
|
||||
-- live on disk under /data; this row records the selector and metadata.
|
||||
CREATE TABLE domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dkim_selector TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Applications bound to a domain (spec 4.1). address_mode is either the domain
|
||||
-- wildcard or an explicit address list; the SASL login is globally unique.
|
||||
CREATE TABLE applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Explicit sender addresses for applications in 'list' mode. Each address must
|
||||
-- belong to the application's domain (validated in the panel, spec 7.6.2).
|
||||
CREATE TABLE application_addresses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
address TEXT NOT NULL,
|
||||
UNIQUE (application_id, address)
|
||||
);
|
||||
|
||||
-- Structured send log (spec 7.3). One row per (queue-id, recipient); the
|
||||
-- log-tailer advances status from queued to a final state.
|
||||
CREATE TABLE send_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
queue_id TEXT,
|
||||
domain TEXT,
|
||||
app_login TEXT,
|
||||
from_addr TEXT,
|
||||
to_addr TEXT,
|
||||
subject TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_send_log_queue_id ON send_log (queue_id);
|
||||
CREATE INDEX idx_send_log_domain ON send_log (domain);
|
||||
CREATE INDEX idx_send_log_created_at ON send_log (created_at);
|
||||
|
||||
-- Differentiated rate limits per domain/application (spec 7.4). Both the IP
|
||||
-- binding and the message limit are optional.
|
||||
CREATE TABLE rate_limits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')),
|
||||
ref_id INTEGER NOT NULL,
|
||||
allowed_ips TEXT,
|
||||
max_messages INTEGER,
|
||||
window_seconds INTEGER,
|
||||
UNIQUE (scope, ref_id)
|
||||
);
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package store owns the SelfPost SQLite database: the single file under /data
|
||||
// that persists the administrator account, sending domains and applications,
|
||||
// the send log and rate-limit settings (spec 9). It exposes typed queries so
|
||||
// the rest of the panel never builds SQL by hand.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo), keeps the static build
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Store wraps the database connection pool.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the SQLite database at path, enables WAL and
|
||||
// foreign keys, and applies any pending migrations. The caller owns Close.
|
||||
func Open(path string) (*Store, error) {
|
||||
// _pragma parameters are applied on every pooled connection by the driver,
|
||||
// so foreign-key enforcement and WAL survive connection churn.
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
// modernc's driver serializes writes anyway; a small pool avoids
|
||||
// "database is locked" surprises under WAL.
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// migrate applies embedded migrations in filename order, tracking progress via
|
||||
// SQLite's PRAGMA user_version so each migration runs at most once.
|
||||
func (s *Store) migrate() error {
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
var version int
|
||||
if err := s.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||||
return fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
|
||||
for i, name := range names {
|
||||
target := i + 1
|
||||
if target <= version {
|
||||
continue
|
||||
}
|
||||
sqlBytes, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(string(sqlBytes)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
// PRAGMA does not accept a bound parameter, and target is a trusted
|
||||
// loop index, so formatting it in is safe.
|
||||
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", target)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("bump schema version for %s: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// sessionCookie is the name of the panel session cookie.
|
||||
const sessionCookie = "selfpost_session"
|
||||
|
||||
// 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",
|
||||
"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) {
|
||||
s.render(w, status, "login", map[string]any{
|
||||
"Title": "SelfPost — Sign in",
|
||||
"Error": formErr,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// Brute-force throttle by client IP (spec 7.6.5).
|
||||
if !s.loginLimiter.Allow(clientIP(r)) {
|
||||
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)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.CookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
s.sessions.Destroy(c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.cfg.CookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// handleSetup serves the one-time administrator creation flow at
|
||||
// /setup/<token> (spec 7.6.1). 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 (spec 7.6.1).
|
||||
if !s.setupLimiter.Allow(clientIP(r)) {
|
||||
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",
|
||||
"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 (spec 7.6.1).
|
||||
s.setup.complete()
|
||||
logf("panel: administrator %q created; setup link is now disabled", username)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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.
|
||||
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
username, ok := s.sessions.Lookup(c.Value)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), usernameKey, username)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
|
||||
// handleDashboard is the authenticated landing page. Phase 2 shows a minimal
|
||||
// shell; domains, applications and the send log arrive in later phases.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "dashboard", map[string]any{
|
||||
"Title": "SelfPost",
|
||||
"User": currentUser(r),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter is a simple fixed-window per-key counter used to throttle the
|
||||
// setup and login routes (spec 7.6.1, 7.6.5). Keys are client IPs. It is not a
|
||||
// precise sliding window — a coarse backstop against brute-force and log noise
|
||||
// is all these routes need.
|
||||
type rateLimiter struct {
|
||||
max int
|
||||
window time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]*rlBucket
|
||||
}
|
||||
|
||||
type rlBucket struct {
|
||||
count int
|
||||
windowEnds time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter(max int, window time.Duration) *rateLimiter {
|
||||
return &rateLimiter{
|
||||
max: max,
|
||||
window: window,
|
||||
buckets: make(map[string]*rlBucket),
|
||||
}
|
||||
}
|
||||
|
||||
// Allow records an attempt for key and reports whether it is within the limit.
|
||||
// The current window is reset lazily once it elapses.
|
||||
func (r *rateLimiter) Allow(key string) bool {
|
||||
now := time.Now()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
b := r.buckets[key]
|
||||
if b == nil || now.After(b.windowEnds) {
|
||||
r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)}
|
||||
r.sweep(now)
|
||||
return true
|
||||
}
|
||||
if b.count >= r.max {
|
||||
return false
|
||||
}
|
||||
b.count++
|
||||
return true
|
||||
}
|
||||
|
||||
// sweep drops expired buckets so the map cannot grow without bound. Called
|
||||
// under the lock while a window is being reset, which is often enough given the
|
||||
// low request volume of these routes.
|
||||
func (r *rateLimiter) sweep(now time.Time) {
|
||||
for k, b := range r.buckets {
|
||||
if now.After(b.windowEnds) {
|
||||
delete(r.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sessionTTL bounds how long a login lasts before re-authentication is needed.
|
||||
const sessionTTL = 12 * time.Hour
|
||||
|
||||
// sessionStore keeps active sessions in memory. Sessions are deliberately not
|
||||
// persisted (spec 9 lists what must survive restart; sessions are not on it):
|
||||
// a restart simply logs the admin out, which is acceptable and avoids storing
|
||||
// bearer tokens on disk. Tokens are crypto-random (spec 7.6.6).
|
||||
type sessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]session
|
||||
}
|
||||
|
||||
type session struct {
|
||||
username string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newSessionStore() *sessionStore {
|
||||
return &sessionStore{sessions: make(map[string]session)}
|
||||
}
|
||||
|
||||
// Create issues a new session for username and returns its token.
|
||||
func (s *sessionStore) Create(username string) string {
|
||||
token := randomToken(32)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[token] = session{username: username, expiresAt: time.Now().Add(sessionTTL)}
|
||||
return token
|
||||
}
|
||||
|
||||
// Lookup returns the session username for a token if it exists and is unexpired.
|
||||
func (s *sessionStore) Lookup(token string) (string, bool) {
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.sessions[token]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if now.After(sess.expiresAt) {
|
||||
delete(s.sessions, token)
|
||||
return "", false
|
||||
}
|
||||
return sess.username, true
|
||||
}
|
||||
|
||||
// Destroy invalidates a session token (logout).
|
||||
func (s *sessionStore) Destroy(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// setupTokenTTL is the lifetime of a setup token (spec 7.6.1). After it
|
||||
// elapses the token is regenerated and re-announced on the next /setup hit.
|
||||
const setupTokenTTL = 10 * time.Minute
|
||||
|
||||
// setupManager owns the one-time administrator setup token. The token itself is
|
||||
// ephemeral (regenerated on restart or expiry) and lives only in memory; the
|
||||
// persistent "setup complete" fact is the presence of the admin row in the
|
||||
// store, so once that exists the token is gone for good (spec 7.6.1).
|
||||
type setupManager struct {
|
||||
store *store.Store
|
||||
hostname string
|
||||
tokenPath string
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager {
|
||||
return &setupManager{store: st, hostname: hostname, tokenPath: tokenPath}
|
||||
}
|
||||
|
||||
// bootstrap runs once at startup. If setup is already complete it clears any
|
||||
// stale token file; otherwise it mints and announces the first token.
|
||||
func (m *setupManager) bootstrap() error {
|
||||
done, err := m.store.AdminExists()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
m.clearTokenFile()
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.regenerateLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// activeToken returns the current valid setup token, regenerating and
|
||||
// re-announcing it if none exists or it has expired. It returns ("", false)
|
||||
// once setup is complete — callers must treat that as "route gone" (404).
|
||||
func (m *setupManager) activeToken() (string, bool) {
|
||||
done, err := m.store.AdminExists()
|
||||
if err != nil {
|
||||
logf("panel: setup: admin check failed: %v", err)
|
||||
return "", false
|
||||
}
|
||||
if done {
|
||||
return "", false
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.token == "" || time.Now().After(m.expiresAt) {
|
||||
m.regenerateLocked()
|
||||
}
|
||||
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
|
||||
// (spec 7.6.1). A mismatch does NOT regenerate or invalidate the token: failed
|
||||
// attempts must not let an attacker DoS a legitimate setup (spec 7.6.1).
|
||||
func (m *setupManager) validate(provided string) bool {
|
||||
token, ok := m.activeToken()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
|
||||
}
|
||||
|
||||
// complete marks setup as finished: the admin row now exists, so drop the
|
||||
// in-memory token and remove the on-disk copy.
|
||||
func (m *setupManager) complete() {
|
||||
m.mu.Lock()
|
||||
m.token = ""
|
||||
m.expiresAt = time.Time{}
|
||||
m.mu.Unlock()
|
||||
m.clearTokenFile()
|
||||
}
|
||||
|
||||
// regenerateLocked mints a fresh token, announces it and mirrors it to disk.
|
||||
// Caller holds m.mu.
|
||||
func (m *setupManager) regenerateLocked() {
|
||||
m.token = randomToken(16) // 128 bits of entropy (spec 7.6.1)
|
||||
m.expiresAt = time.Now().Add(setupTokenTTL)
|
||||
m.announce(m.token)
|
||||
}
|
||||
|
||||
// announce prints the setup link to the container log and writes it to the
|
||||
// token file so it can be read either way (spec 7.6.1).
|
||||
func (m *setupManager) announce(token string) {
|
||||
url := m.setupURL(token)
|
||||
logf("panel: ==================================================================")
|
||||
logf("panel: SelfPost first-run setup — open this one-time link within %s:", setupTokenTTL)
|
||||
logf("panel: %s", url)
|
||||
logf("panel: (also written to %s)", m.tokenPath)
|
||||
logf("panel: ==================================================================")
|
||||
|
||||
if m.tokenPath == "" {
|
||||
return
|
||||
}
|
||||
// 0600: the token is a bearer secret for creating the admin.
|
||||
if err := os.WriteFile(m.tokenPath, []byte(url+"\n"), 0o600); err != nil {
|
||||
logf("panel: setup: could not write token file %s: %v", m.tokenPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *setupManager) setupURL(token string) string {
|
||||
host := m.hostname
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
return fmt.Sprintf("https://%s/setup/%s", host, token)
|
||||
}
|
||||
|
||||
func (m *setupManager) clearTokenFile() {
|
||||
if m.tokenPath == "" {
|
||||
return
|
||||
}
|
||||
if err := os.Remove(m.tokenPath); err != nil && !os.IsNotExist(err) {
|
||||
logf("panel: setup: could not remove token file %s: %v", m.tokenPath, err)
|
||||
}
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,54 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// templates holds the parsed page templates. Each page is parsed together with
|
||||
// the shared base layout so {{ template "base" . }} works. Rendering goes
|
||||
// through html/template, which auto-escapes all interpolated data (spec 7.6.7).
|
||||
type templates struct {
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
// pageFiles maps a logical page name to its template file. Every page composes
|
||||
// with layout.html.
|
||||
var pageFiles = map[string]string{
|
||||
"setup": "templates/setup.html",
|
||||
"login": "templates/login.html",
|
||||
"dashboard": "templates/dashboard.html",
|
||||
}
|
||||
|
||||
func loadTemplates() (*templates, error) {
|
||||
t := &templates{pages: make(map[string]*template.Template)}
|
||||
for name, file := range pageFiles {
|
||||
tmpl, err := template.New("layout.html").ParseFS(assetsFS, "templates/layout.html", file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
||||
}
|
||||
t.pages[name] = tmpl
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// render writes a page using the base layout. Rendering to a buffer first means
|
||||
// a template error yields a clean 500 instead of a half-written page.
|
||||
func (s *Server) render(w http.ResponseWriter, status int, page string, data any) {
|
||||
tmpl, ok := s.tmpl.pages[page]
|
||||
if !ok {
|
||||
http.Error(w, "template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||
logf("panel: render %s: %v", page, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = buf.WriteTo(w)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{define "content"}}
|
||||
<div class="topbar">
|
||||
<h1>SelfPost</h1>
|
||||
<div class="muted">
|
||||
{{.User}} ·
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<p>You are signed in. Domains, applications and the send log arrive in the
|
||||
next phases.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,50 @@
|
||||
{{define "layout.html"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
margin: 0; padding: 2rem 1rem; background: #f6f7f9; color: #1b1f24;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #14171a; color: #e6e8eb; }
|
||||
.card { background: #1d2125 !important; border-color: #2b3138 !important; }
|
||||
input { background: #14171a !important; color: inherit !important; border-color: #2b3138 !important; }
|
||||
}
|
||||
main { max-width: 42rem; margin: 0 auto; }
|
||||
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
|
||||
.card {
|
||||
background: #fff; border: 1px solid #e2e5e9; border-radius: 10px;
|
||||
padding: 1.5rem; margin: 0 auto;
|
||||
}
|
||||
.card.narrow { max-width: 24rem; }
|
||||
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
|
||||
input {
|
||||
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
|
||||
border: 1px solid #cfd4da; border-radius: 6px; background: #fff;
|
||||
}
|
||||
button {
|
||||
margin-top: 1.2rem; padding: 0.6rem 1.1rem; font-size: 1rem; font-weight: 600;
|
||||
color: #fff; background: #2563eb; border: 0; border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
button:hover { background: #1d4ed8; }
|
||||
.error { color: #b42318; margin: 0.6rem 0 0; font-weight: 600; }
|
||||
.muted { color: #6b7280; }
|
||||
.topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; }
|
||||
form.inline { display: inline; margin: 0; }
|
||||
form.inline button { background: none; color: #2563eb; padding: 0; margin: 0; font-weight: 600; }
|
||||
form.inline button:hover { text-decoration: underline; background: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{define "content"}}
|
||||
<h1>Sign in</h1>
|
||||
<div class="card narrow">
|
||||
{{if .SetupHint}}
|
||||
<p class="muted">No administrator has been created yet. Open the one-time
|
||||
setup link printed in the container log to get started.</p>
|
||||
{{else}}
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username" autofocus required>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{define "content"}}
|
||||
<h1>Create administrator</h1>
|
||||
<div class="card narrow">
|
||||
<p class="muted">This one-time link creates the single panel administrator.
|
||||
After you submit, the link stops working for good.</p>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/setup/{{.Token}}">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username" autofocus required>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" required>
|
||||
|
||||
<label for="password_confirm">Confirm password</label>
|
||||
<input id="password_confirm" name="password_confirm" type="password" autocomplete="new-password" required>
|
||||
|
||||
<button type="submit">Create administrator</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,20 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// randomToken returns a URL-safe token with at least nBytes*8 bits of entropy
|
||||
// drawn from crypto/rand. Setup and session tokens both use this; the setup
|
||||
// token needs >=128 bits (spec 7.6.1), so callers pass nBytes >= 16.
|
||||
//
|
||||
// It panics if the system RNG fails: that is unrecoverable and must never be
|
||||
// papered over with a weak fallback for a security token.
|
||||
func randomToken(nBytes int) string {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// minAdminPasswordLen is the floor for the administrator password. The panel is
|
||||
// public (spec 7.6), so this is deliberately not tiny.
|
||||
const minAdminPasswordLen = 12
|
||||
|
||||
const (
|
||||
minUsernameLen = 3
|
||||
maxUsernameLen = 64
|
||||
)
|
||||
|
||||
// validateUsername enforces a strict server-side whitelist (spec 7.6.2):
|
||||
// letters, digits, dot, dash, underscore. Client validation is never trusted.
|
||||
func validateUsername(u string) error {
|
||||
if len(u) < minUsernameLen || len(u) > maxUsernameLen {
|
||||
return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen)
|
||||
}
|
||||
for _, r := range u {
|
||||
if r > unicode.MaxASCII || (!isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_') {
|
||||
return fmt.Errorf("username may contain only letters, digits, '.', '-' and '_'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAdminPassword enforces a minimum length. Composition rules beyond
|
||||
// length tend to reduce entropy in practice, so length is the sole gate.
|
||||
func validateAdminPassword(p string) error {
|
||||
if len(p) < minAdminPasswordLen {
|
||||
return fmt.Errorf("password must be at least %d characters", minAdminPasswordLen)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isASCIILetterOrDigit(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package web implements the SelfPost control panel's HTTP surface: the
|
||||
// one-time administrator setup flow (spec 7.6.1), login/session handling
|
||||
// (spec 7.6.5-6) and the authenticated shell the later phases build on.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
var assetsFS embed.FS
|
||||
|
||||
// Config holds the panel's HTTP-facing configuration.
|
||||
type Config struct {
|
||||
// Hostname is the server's external hostname, used to build the absolute
|
||||
// setup link shown in the logs (spec 7.6.1, 8: SELFPOST_HOSTNAME).
|
||||
Hostname string
|
||||
// CookieSecure sets the Secure attribute on the session cookie. It defaults
|
||||
// to true (spec 7.6.6); it exists as a knob only so the panel can be tested
|
||||
// over plain HTTP in development, never for production.
|
||||
CookieSecure bool
|
||||
}
|
||||
|
||||
// Server is the panel HTTP application.
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
cfg Config
|
||||
tmpl *templates
|
||||
sessions *sessionStore
|
||||
setup *setupManager
|
||||
|
||||
loginLimiter *rateLimiter
|
||||
setupLimiter *rateLimiter
|
||||
}
|
||||
|
||||
// New builds the panel server. setupTokenPath is where the current setup token
|
||||
// is mirrored on disk (spec 7.6.1).
|
||||
func New(st *store.Store, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{
|
||||
store: st,
|
||||
cfg: cfg,
|
||||
tmpl: tmpl,
|
||||
sessions: newSessionStore(),
|
||||
// Setup: a handful of attempts per minute per IP is plenty for a
|
||||
// legitimate admin and blunts automated probing (spec 7.6.1).
|
||||
setupLimiter: newRateLimiter(10, time.Minute),
|
||||
// Login: throttle brute-force by IP (spec 7.6.5).
|
||||
loginLimiter: newRateLimiter(10, 15*time.Minute),
|
||||
}
|
||||
s.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Start performs first-run bootstrapping: if there is no administrator yet, it
|
||||
// generates and announces the setup link (spec 7.6.1). Safe to call once at
|
||||
// server startup.
|
||||
func (s *Server) Start() error {
|
||||
return s.setup.bootstrap()
|
||||
}
|
||||
|
||||
// Handler returns the panel's HTTP handler (router).
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health check stays unauthenticated for the container/orchestrator.
|
||||
mux.HandleFunc("/healthz", handleHealth)
|
||||
|
||||
// Vendored static assets (HTMX). Served from the embedded FS.
|
||||
mux.Handle("/static/", http.FileServer(http.FS(assetsFS)))
|
||||
|
||||
// One-time administrator setup (spec 7.6.1).
|
||||
mux.HandleFunc("/setup/", s.handleSetup)
|
||||
|
||||
// Authentication.
|
||||
mux.HandleFunc("/login", s.handleLogin)
|
||||
mux.HandleFunc("/logout", s.handleLogout)
|
||||
|
||||
// Authenticated panel.
|
||||
mux.Handle("/", s.requireAuth(http.HandlerFunc(s.handleDashboard)))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
}
|
||||
|
||||
// clientIP extracts the peer IP for rate-limiting. It uses the transport peer
|
||||
// (RemoteAddr), not client-supplied headers, so it cannot be spoofed; behind a
|
||||
// reverse proxy this is the proxy address, which is an acceptable backstop for
|
||||
// a single-admin panel.
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// logf is a thin wrapper so handlers log with a consistent prefix.
|
||||
func logf(format string, args ...any) {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
Reference in New Issue
Block a user