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:
114
internal/config/config.go
Normal file
114
internal/config/config.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Package config loads the Control Service configuration from the process
|
||||
// environment. Every secret comes from the environment (environment.d); nothing
|
||||
// is compiled into the binary and nothing is read from a committed file.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is the fully resolved runtime configuration.
|
||||
type Config struct {
|
||||
// Addr is the listen address, for example ":8791".
|
||||
Addr string
|
||||
// DBPath is the SQLite database file, for example "/data/control.db".
|
||||
DBPath string
|
||||
// SessionSecret signs the session cookie. Required, >= 16 bytes.
|
||||
SessionSecret []byte
|
||||
// AdminUsername is the single dashboard login name (Sam).
|
||||
AdminUsername string
|
||||
// AdminPassword is the plaintext password. Optional if AdminPasswordHash is set.
|
||||
AdminPassword string
|
||||
// AdminPasswordHash is a bcrypt hash. Optional if AdminPassword is set.
|
||||
AdminPasswordHash string
|
||||
// CookieSecure sets the Secure attribute on cookies. Keep true behind HTTPS.
|
||||
CookieSecure bool
|
||||
// SessionTTL is how long a session cookie stays valid.
|
||||
SessionTTL time.Duration
|
||||
// LogLevel is the slog level.
|
||||
LogLevel slog.Level
|
||||
// AppEnv is a label such as "production" or "development".
|
||||
AppEnv string
|
||||
// Version is injected at build time.
|
||||
Version string
|
||||
}
|
||||
|
||||
// Load reads and validates the configuration.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Addr: env("CONTROL_ADDR", ":8791"),
|
||||
DBPath: env("CONTROL_DB_PATH", "/data/control.db"),
|
||||
AdminUsername: env("CONTROL_ADMIN_USERNAME", "sam"),
|
||||
AdminPassword: os.Getenv("CONTROL_ADMIN_PASSWORD"),
|
||||
AdminPasswordHash: os.Getenv("CONTROL_ADMIN_PASSWORD_HASH"),
|
||||
CookieSecure: envBool("CONTROL_COOKIE_SECURE", true),
|
||||
AppEnv: env("CONTROL_APP_ENV", "production"),
|
||||
}
|
||||
|
||||
secret := os.Getenv("CONTROL_SESSION_SECRET")
|
||||
if len(secret) < 16 {
|
||||
return nil, errors.New("CONTROL_SESSION_SECRET must be set and at least 16 characters")
|
||||
}
|
||||
cfg.SessionSecret = []byte(secret)
|
||||
|
||||
if cfg.AdminPassword == "" && cfg.AdminPasswordHash == "" {
|
||||
return nil, errors.New("set CONTROL_ADMIN_PASSWORD or CONTROL_ADMIN_PASSWORD_HASH")
|
||||
}
|
||||
if cfg.AdminUsername == "" {
|
||||
return nil, errors.New("CONTROL_ADMIN_USERNAME must not be empty")
|
||||
}
|
||||
|
||||
ttl, err := time.ParseDuration(env("CONTROL_SESSION_TTL", "12h"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CONTROL_SESSION_TTL: %w", err)
|
||||
}
|
||||
cfg.SessionTTL = ttl
|
||||
|
||||
lvl, err := parseLevel(env("CONTROL_LOG_LEVEL", "info"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.LogLevel = lvl
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func parseLevel(s string) (slog.Level, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "debug":
|
||||
return slog.LevelDebug, nil
|
||||
case "info", "":
|
||||
return slog.LevelInfo, nil
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn, nil
|
||||
case "error":
|
||||
return slog.LevelError, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("CONTROL_LOG_LEVEL: unknown level %q", s)
|
||||
}
|
||||
}
|
||||
52
internal/config/config_test.go
Normal file
52
internal/config/config_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadDefaultsAndValidation(t *testing.T) {
|
||||
t.Setenv("CONTROL_SESSION_SECRET", "0123456789abcdef")
|
||||
t.Setenv("CONTROL_ADMIN_PASSWORD", "hunter2")
|
||||
t.Setenv("CONTROL_ADDR", "")
|
||||
t.Setenv("CONTROL_DB_PATH", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Addr != ":8791" {
|
||||
t.Errorf("Addr = %q, want :8791", cfg.Addr)
|
||||
}
|
||||
if cfg.DBPath != "/data/control.db" {
|
||||
t.Errorf("DBPath = %q, want /data/control.db", cfg.DBPath)
|
||||
}
|
||||
if cfg.AdminUsername != "sam" {
|
||||
t.Errorf("AdminUsername = %q, want sam", cfg.AdminUsername)
|
||||
}
|
||||
if cfg.SessionTTL != 12*time.Hour {
|
||||
t.Errorf("SessionTTL = %v, want 12h", cfg.SessionTTL)
|
||||
}
|
||||
if cfg.LogLevel != slog.LevelInfo {
|
||||
t.Errorf("LogLevel = %v, want info", cfg.LogLevel)
|
||||
}
|
||||
if !cfg.CookieSecure {
|
||||
t.Error("CookieSecure = false, want true by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresSecretAndPassword(t *testing.T) {
|
||||
t.Setenv("CONTROL_SESSION_SECRET", "")
|
||||
t.Setenv("CONTROL_ADMIN_PASSWORD", "hunter2")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Error("expected error when session secret is missing")
|
||||
}
|
||||
|
||||
t.Setenv("CONTROL_SESSION_SECRET", "0123456789abcdef")
|
||||
t.Setenv("CONTROL_ADMIN_PASSWORD", "")
|
||||
t.Setenv("CONTROL_ADMIN_PASSWORD_HASH", "")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Error("expected error when no admin password is set")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user