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:
+8
-6
@@ -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"]
|
||||||
|
|||||||
@@ -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"
|
"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
@@ -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
@@ -46,10 +46,19 @@
|
|||||||
|
|
||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
|
|
||||||
- **Текущая фаза:** 1 ✅ закрыта → следующая **Фаза 2** (SQLite + setup-link + вход админа)
|
- **Текущая фаза:** 2 ✅ закрыта → следующая **Фаза 3** (домены + OpenDKIM)
|
||||||
- **Модель для Фазы 2:** Opus (безопасность 7.6: крипто-токен, сессии, bcrypt)
|
- **Модель для Фазы 3:** Opus (генерация конфигов + exec-safety 7.6.3–4, валидация имени домена 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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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