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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user