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.mixfed.ru: 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:
2026-07-11 21:24:09 +03:00
parent 39b74e204e
commit 2b08a947b2
25 changed files with 1248 additions and 50 deletions
+8 -6
View File
@@ -13,9 +13,9 @@ WORKDIR /src
# Version stamped into both binaries; MUST match the image tag (spec 7.5.A). # Version stamped into both binaries; MUST match the image tag (spec 7.5.A).
ARG VERSION=dev ARG VERSION=dev
# Module metadata first for layer caching. No go.sum yet — Phase 1 has no # Module metadata first for layer caching. go.sum arrived in Phase 2 with the
# third-party dependencies. # SQLite driver and bcrypt.
COPY go.mod ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY cmd ./cmd 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/opendkim.conf /etc/opendkim.conf
COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh COPY build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
COPY build/crashexit.py /usr/local/bin/crashexit.py 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 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). # 8080 panel; 25 outbound; 465/587 inbound submission (used from Phase 5).
EXPOSE 8080 25 465 587 EXPOSE 8080 25 465 587
# supervisord is PID 1 and owns process supervision + ordering (spec 4). # The entrypoint fixes /data ownership (bind mount) as root, then execs
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"] # supervisord, which becomes PID 1 and owns process supervision (spec 4).
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+17
View File
@@ -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
View File
@@ -6,19 +6,35 @@ import (
"log" "log"
"net/http" "net/http"
"time" "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 // serveHTTP opens the panel database and runs the control-panel HTTP server
// only a placeholder page and a health check; the login flow and real UI arrive // until ctx is cancelled. From Phase 2 this serves the real setup, login and
// in Phase 2. // authenticated panel surface (spec 7.6).
func serveHTTP(ctx context.Context, addr string) error { func serveHTTP(ctx context.Context, cfg config) error {
mux := http.NewServeMux() st, err := store.Open(cfg.dbPath)
mux.HandleFunc("/healthz", handleHealth) if err != nil {
mux.HandleFunc("/", handleIndex) 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{ srv := &http.Server{
Addr: addr, Addr: cfg.httpAddr,
Handler: mux, Handler: srvApp.Handler(),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }
@@ -30,38 +46,9 @@ func serveHTTP(ctx context.Context, addr string) error {
_ = srv.Shutdown(shutdownCtx) _ = 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) { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err return err
} }
return nil 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
View File
@@ -14,6 +14,7 @@ import (
"log" "log"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"sync" "sync"
"syscall" "syscall"
@@ -43,13 +44,28 @@ type config struct {
httpAddr string httpAddr string
journalSocket string journalSocket string
mailLog string mailLog string
dataDir string
dbPath string
setupTokenPath string
hostname string
cookieSecure bool
} }
func loadConfig() config { func loadConfig() config {
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
return config{ return config{
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"), httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"), journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
mailLog: envDefault("MAIL_LOG", "/var/log/mail.log"), 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 name string
fn func(context.Context) error 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) }}, {"journal-milter", func(ctx context.Context) error { return serveJournalStub(ctx, cfg.journalSocket) }},
{"log-tailer", func(ctx context.Context) error { return tailMailLog(ctx, cfg.mailLog) }}, {"log-tailer", func(ctx context.Context) error { return tailMailLog(ctx, cfg.mailLog) }},
} }
+14 -4
View File
@@ -46,10 +46,19 @@
## Текущее состояние ## Текущее состояние
- **Текущая фаза:** 1 ✅ закрыта → следующая **Фаза 2** (SQLite + setup-link + вход админа) - **Текущая фаза:** 2 ✅ закрыта → следующая **Фаза 3** (домены + OpenDKIM)
- **Модель для Фазы 2:** Opus (безопасность 7.6: крипто-токен, сессии, bcrypt) - **Модель для Фазы 3:** Opus (генерация конфигов + exec-safety 7.6.34, валидация имени домена 7.6.2)
- **Статус:** образ собирается и проверен на сервере; три процесса живы, холодный старт и crashexit подтверждены - **Статус:** SQLite-персистентность, setup secret-link и вход админа реализованы и проверены на сервере (`go vet`/`build`/`test` зелёные, docker-образ собирается, контейнер поднимает три процесса и создаёт БД под `panel`)
- **Следующий шаг (Фаза 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, вход/выход работают. - **Следующий шаг (Фаза 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.mixfed.ru): `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 ### Сделано в Фазе 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/ не попадают в контекст). - **Образ** `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` (план). - **Фаза 0** (2026-07-11, Opus) — каркас проекта + build-пайплайн + спайк go-milter (риск ТЗ 7.3 снят). Коммиты `4e589e1` (каркас), `87388b4` (план).
- **Фаза 1** (2026-07-11, Opus) — Docker-образ + supervisord + три процесса, холодный старт (обёртка ждёт milter-сокеты) и crashexit проверены на сервере. Коммит `ed9e942`. - **Фаза 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).
+17
View File
@@ -1,3 +1,20 @@
module codeberg.org/mix/selfpost module codeberg.org/mix/selfpost
go 1.26 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
)
+53
View File
@@ -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=
+61
View File
@@ -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
}
+76
View File
@@ -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)
);
+100
View File
@@ -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
}
+113
View File
@@ -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)
}
+98
View File
@@ -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)
}
+51
View File
@@ -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),
})
}
+62
View File
@@ -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)
}
}
}
+62
View File
@@ -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)
}
+136
View File
@@ -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)
}
}
File diff suppressed because one or more lines are too long
+54
View File
@@ -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)
}
+15
View File
@@ -0,0 +1,15 @@
{{define "content"}}
<div class="topbar">
<h1>SelfPost</h1>
<div class="muted">
{{.User}} &middot;
<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}}
+50
View File
@@ -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}}
+20
View File
@@ -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}}
+20
View File
@@ -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}}
+20
View File
@@ -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)
}
+42
View File
@@ -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')
}
+115
View File
@@ -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...)
}