Phase 1: Control Service skeleton (Go, SQLite, session login, Docker, Caddy)

- Go service with its own multi-stage Dockerfile and docker-compose.yml; runs
  as container webrestrict-control on port 8791.
- SQLite schema for the full AGENTS.md §7 data model via versioned migrations
  (PRAGMA user_version); audit log is append-only.
- Configuration from the process environment; the real env file is gitignored
  and a template is committed. No secrets in the repo.
- Separate session login (signed HMAC cookie + CSRF) for Sam.
- GET /health; HTMX + server-rendered dashboard styled with the Family Home Lab
  tokens.css (copied) and Inter fonts; dark mode; phone-friendly.
- Caddy vhost for webrestrict.lab.audasmedia.com.au applied on .35 (Let's
  Encrypt cert issued); route recorded in deploy/caddy/webrestrict.caddy.
- Unit tests for config, auth and store.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 14:11:38 +11:00
parent 5368225f23
commit c83c9df0a3
29 changed files with 2119 additions and 0 deletions

207
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,207 @@
// Package auth implements the separate session login for the Control Service
// (AGENTS.md §9 Auth). Sessions are stateless, signed cookies. A random CSRF
// cookie uses the double-submit pattern for forms.
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "control_session"
csrfCookie = "control_csrf"
)
// Manager issues and validates sessions.
type Manager struct {
// Secret signs every cookie. Required.
Secret []byte
// TTL is the session lifetime.
TTL time.Duration
// Secure sets the Secure cookie attribute. Keep true behind HTTPS.
Secure bool
// Username is the single allowed login name.
Username string
// passwordHash is a bcrypt hash, or nil when only a plaintext password is set.
passwordHash []byte
// plaintext is the fallback password, or empty when a hash is set.
plaintext string
}
// New builds a Manager. Exactly one of bcryptHash or plaintext must be set.
func New(secret []byte, ttl time.Duration, secure bool, username, bcryptHash, plaintext string) (*Manager, error) {
if len(secret) < 16 {
return nil, errors.New("session secret too short")
}
m := &Manager{Secret: secret, TTL: ttl, Secure: secure, Username: username}
switch {
case bcryptHash != "":
if _, err := bcrypt.Cost([]byte(bcryptHash)); err != nil {
return nil, fmt.Errorf("CONTROL_ADMIN_PASSWORD_HASH is not a valid bcrypt hash: %w", err)
}
m.passwordHash = []byte(bcryptHash)
case plaintext != "":
m.plaintext = plaintext
default:
return nil, errors.New("no admin password configured")
}
return m, nil
}
// Hashed reports whether the manager verifies against a bcrypt hash.
func (m *Manager) Hashed() bool { return m.passwordHash != nil }
// CheckCredentials validates a username and password in constant time where
// possible. It never reveals which part was wrong.
func (m *Manager) CheckCredentials(username, password string) bool {
userOK := subtle.ConstantTimeCompare([]byte(username), []byte(m.Username)) == 1
passOK := false
if m.passwordHash != nil {
passOK = bcrypt.CompareHashAndPassword(m.passwordHash, []byte(password)) == nil
} else {
passOK = subtle.ConstantTimeCompare([]byte(password), []byte(m.plaintext)) == 1
}
return userOK && passOK
}
// Issue writes a signed session cookie and the matching CSRF cookie.
func (m *Manager) Issue(w http.ResponseWriter) {
csrf := randomToken()
expires := time.Now().Add(m.TTL).Unix()
payload := strings.Join([]string{m.Username, csrf, strconv.FormatInt(expires, 10)}, "\n")
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: m.sign(payload),
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(expires, 0),
MaxAge: int(m.TTL.Seconds()),
})
http.SetCookie(w, &http.Cookie{
Name: csrfCookie,
Value: csrf,
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(expires, 0),
MaxAge: int(m.TTL.Seconds()),
})
}
// Clear removes the session and CSRF cookies.
func (m *Manager) Clear(w http.ResponseWriter) {
for _, name := range []string{sessionCookie, csrfCookie} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
}
// Authenticated reports whether the request carries a valid, unexpired session.
func (m *Manager) Authenticated(r *http.Request) bool {
c, err := r.Cookie(sessionCookie)
if err != nil {
return false
}
payload, ok := m.verify(c.Value)
if !ok {
return false
}
parts := strings.Split(payload, "\n")
if len(parts) != 3 || parts[0] != m.Username {
return false
}
expires, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil || time.Now().Unix() > expires {
return false
}
return true
}
// EnsureCSRF returns the CSRF token for a form, setting the cookie if absent.
func (m *Manager) EnsureCSRF(w http.ResponseWriter, r *http.Request) string {
if c, err := r.Cookie(csrfCookie); err == nil && c.Value != "" {
return c.Value
}
token := randomToken()
http.SetCookie(w, &http.Cookie{
Name: csrfCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(m.TTL.Seconds()),
})
return token
}
// CheckCSRF compares the submitted form token with the CSRF cookie.
func (m *Manager) CheckCSRF(r *http.Request) bool {
c, err := r.Cookie(csrfCookie)
if err != nil || c.Value == "" {
return false
}
form := r.PostFormValue("csrf")
return subtle.ConstantTimeCompare([]byte(form), []byte(c.Value)) == 1
}
func (m *Manager) sign(payload string) string {
mac := hmac.New(sha256.New, m.Secret)
mac.Write([]byte(payload))
sig := mac.Sum(nil)
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
base64.RawURLEncoding.EncodeToString(sig)
}
func (m *Manager) verify(token string) (string, bool) {
encoded, sigEncoded, ok := strings.Cut(token, ".")
if !ok {
return "", false
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", false
}
gotSig, err := base64.RawURLEncoding.DecodeString(sigEncoded)
if err != nil {
return "", false
}
mac := hmac.New(sha256.New, m.Secret)
mac.Write(payload)
if !hmac.Equal(gotSig, mac.Sum(nil)) {
return "", false
}
return string(payload), true
}
func randomToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("auth: random source failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(b)
}