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,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