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

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.gitignore
.pi
bin/
*.md
*.db
*.db-shm
*.db-wal
environment.d
deploy

7
.gitignore vendored
View File

@@ -3,6 +3,13 @@
.env.*
*.local
*.keystore
# Real Control Service env file (keep the .example template)
environment.d/*.env
# Local build caches / runtime data
.gocache/
.gobuild/
data/
# Go
bin/

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
# ---- build stage ----------------------------------------------------------
# Fix project name: Internet Use Control — its own image, built from source.
FROM golang:1.25-alpine AS build
WORKDIR /src
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/control ./cmd/control
# ---- runtime stage --------------------------------------------------------
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata wget \
&& addgroup -g 10001 -S control \
&& adduser -S -D -H -u 10001 -G control control \
&& mkdir -p /data && chown control:control /data
USER control
WORKDIR /app
COPY --from=build /out/control /app/control
EXPOSE 8791
VOLUME ["/data"]
ENTRYPOINT ["/app/control"]

49
Makefile Normal file
View File

@@ -0,0 +1,49 @@
IMAGE ?= webrestrict/control:0.1.0-phase1
BUILD_VERSION ?= 0.1.0-phase1
.PHONY: help test vet build up down restart logs ps hash-password fmt
help:
@echo "make test run go test ./... in golang:1.25-alpine"
@echo "make vet run go vet ./... in golang:1.25-alpine"
@echo "make build build the container image ($(IMAGE))"
@echo "make up docker compose up -d --build"
@echo "make down docker compose down"
@echo "make logs follow container logs"
@echo "make hash-password generate a bcrypt hash (PASSWORD=...)"
test:
docker run --rm --network host \
-v "$$PWD":/src -w /src \
-v webrestrict-go-mod:/go/pkg/mod \
-v webrestrict-go-build:/root/.cache/go-build \
golang:1.25-alpine sh -c 'go test ./...'
vet:
docker run --rm --network host \
-v "$$PWD":/src -w /src \
-v webrestrict-go-mod:/go/pkg/mod \
-v webrestrict-go-build:/root/.cache/go-build \
golang:1.25-alpine sh -c 'go vet ./...'
build:
docker build --build-arg VERSION=$(BUILD_VERSION) -t $(IMAGE) .
up:
docker compose up -d --build
down:
docker compose down
restart:
docker compose restart control
logs:
docker compose logs -f control
ps:
docker compose ps
hash-password:
@test -n "$(PASSWORD)" || (echo "usage: make hash-password PASSWORD=secret"; exit 2)
docker run --rm --entrypoint /app/control $(IMAGE) hash-password '$(PASSWORD)'

121
README.md Normal file
View File

@@ -0,0 +1,121 @@
# Internet Use Control — Control Service
The **Control Service** is the brain and the single source of truth for the
Internet Use Control project (`SYSTEM_MAP.md`). It holds schedules, blocklists,
per-device state and usage, and serves the web dashboard. Only it publishes
device config.
This repository currently contains **Phase 1 — Skeleton** (AGENTS.md §11):
- Go service with its own image (`Dockerfile`) and compose file.
- SQLite schema for the whole data model (§7), applied through versioned
migrations.
- Configuration from the process environment (`environment.d`); no secrets in
the repo.
- A separate session login for Sam.
- `GET /health`.
- HTMX + server-rendered dashboard styled with the Family Home Lab design
system (`tokens.css` copied in).
Enforcement, MQTT, devices, schedules and the clients arrive in later phases.
Nothing here touches the router, Pi-hole, or DNS.
## Quick start (on `.13`)
```bash
cd /home/sam/paseo/projects/internet_use_control
# 1. Create the env file from the template and set real values.
cp environment.d/control-service.env.example environment.d/control-service.env
$EDITOR environment.d/control-service.env
# 2. Build and start. Own image, own container, port 8791.
docker compose up -d --build
# 3. Prove it.
curl -s http://127.0.0.1:8791/health
# {"status":"ok","service":"control-service","version":"0.1.0-phase1",...}
```
Open <http://127.0.0.1:8791/> in a browser. It redirects to `/login`. Sign in
with `CONTROL_ADMIN_USERNAME` and `CONTROL_ADMIN_PASSWORD`.
## Configuration
All configuration is environment variables. Docker Compose loads them from
`environment.d/control-service.env` (gitignored). The variables can also be
exported from `~/.config/environment.d/10-secrets.conf` instead; the service
reads only the process environment.
| Variable | Default | Purpose |
|---|---|---|
| `CONTROL_ADDR` | `:8791` | Listen address |
| `CONTROL_DB_PATH` | `/data/control.db` | SQLite file |
| `CONTROL_SESSION_SECRET` | — (required) | Signs the session cookie, ≥ 16 chars |
| `CONTROL_SESSION_TTL` | `12h` | Session lifetime |
| `CONTROL_COOKIE_SECURE` | `true` | Secure cookie flag; keep true behind HTTPS |
| `CONTROL_ADMIN_USERNAME` | `sam` | Dashboard login |
| `CONTROL_ADMIN_PASSWORD` | — | Plaintext password (option A) |
| `CONTROL_ADMIN_PASSWORD_HASH` | — | bcrypt hash (option B, preferred) |
| `CONTROL_APP_ENV` | `production` | Label shown in the footer |
| `CONTROL_LOG_LEVEL` | `info` | `debug`/`info`/`warn`/`error` |
Generate a bcrypt hash without host Go:
```bash
docker run --rm --entrypoint /app/control webrestrict/control:0.1.0-phase1 \
hash-password 'your-password'
```
## Endpoints (Phase 1)
| Method | Path | Notes |
|---|---|---|
| `GET` | `/health` | JSON. `status: ok`, database check. No auth. |
| `GET` | `/login` | Session login form. |
| `POST` | `/login` | Verifies credentials, sets a signed session cookie. |
| `POST` | `/logout` | Clears the session. |
| `GET` | `/` | Dashboard. Redirects to `/login` when not signed in. |
| `GET` | `/partials/status` | HTMX fragment, polled every 10 s. |
## Development
There is no host Go toolchain on `.13`; use the cached builder image.
```bash
make test # go test ./... in golang:1.25-alpine
make vet # go vet ./...
make build # build the container image
make up # docker compose up -d --build
make logs # follow container logs
```
## Layout
```
cmd/control/ service entry point (+ hash-password helper)
internal/config/ environment configuration
internal/store/ SQLite open, migrations, audit
internal/store/migrations/001_init.sql
internal/auth/ signed-cookie session login + CSRF
internal/web/ HTTP handlers, templates, static assets
templates/ login, dashboard, HTMX partials
static/ tokens.css (Family Home Lab), app.css, htmx, Inter fonts
Dockerfile own image (multi-stage, CGO-free)
docker-compose.yml own container, port 8791
environment.d/ control-service.env.example (real file gitignored)
deploy/caddy/ Caddy vhost for webrestrict.lab.audasmedia.com.au
```
## Caddy
`deploy/caddy/webrestrict.caddy` holds the vhost for
`webrestrict.lab.audasmedia.com.au`. The terminating Caddy runs on `.35`
(sam-Ubuntu1); see that file for the two proxy patterns and the apply commands.
## Non-goals (Phase 1)
- No MQTT publishing (Phase 3).
- No device registry or lock/unlock UI (Phase 2).
- No admin API (Phase 8).
- No Hub contact (Phase 9).

146
cmd/control/main.go Normal file
View File

@@ -0,0 +1,146 @@
// Command control is the Internet Use Control Control Service.
//
// It is the single source of truth for the family's device rules
// (SYSTEM_MAP.md). Phase 1: HTTP skeleton, SQLite schema, config from the
// environment, and a separate session login.
package main
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/web"
)
// version is injected with -ldflags at build time.
var version = "dev"
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "hash-password":
hashPassword(os.Args[2:])
return
case "version":
fmt.Println(version)
return
}
}
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "control:", err)
os.Exit(1)
}
}
func run() error {
started := time.Now()
cfg, err := config.Load()
if err != nil {
return err
}
cfg.Version = version
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}))
slog.SetDefault(log)
st, err := store.Open(cfg.DBPath)
if err != nil {
return err
}
defer st.Close()
if err := st.Migrate(context.Background()); err != nil {
return err
}
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure,
cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword)
if err != nil {
return err
}
if !am.Hashed() {
log.Warn("admin password is plaintext; set CONTROL_ADMIN_PASSWORD_HASH to use bcrypt")
}
srv, err := web.New(cfg, st, am, log)
if err != nil {
return err
}
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
log.Info("control service listening",
"addr", cfg.Addr, "db", cfg.DBPath, "env", cfg.AppEnv, "version", version)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errCh:
return err
case sig := <-stop:
log.Info("shutting down", "signal", sig.String())
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
log.Info("stopped", "uptime", time.Since(started).Round(time.Second).String())
return nil
}
// hashPassword prints a bcrypt hash for the password given as an argument or
// read from stdin. It never prints the password itself.
func hashPassword(args []string) {
var password string
if len(args) > 0 {
password = args[0]
} else {
b, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "control:", err)
os.Exit(1)
}
password = strings.TrimRight(string(b), "\r\n")
}
if password == "" {
fmt.Fprintln(os.Stderr, "control: empty password")
os.Exit(2)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintln(os.Stderr, "control:", err)
os.Exit(1)
}
fmt.Println(string(hash))
}

View File

@@ -0,0 +1,29 @@
# Caddy route for the Control Service.
#
# APPLIED 2026-10-13 (AUD-78). This file records the live configuration.
#
# DNS: webrestrict.lab.audasmedia.com.au
# Owner: the Caddy container on .35 (sam-Ubuntu1), Caddyfile bind-mounted at
# /Docker/Containers/caddy/Caddyfile (owned by sam:sam).
# Result: https://webrestrict.lab.audasmedia.com.au -> 192.168.20.13:8791
# Let's Encrypt certificate issued via HTTP-01 on 2026-10-13.
# Backup: /Docker/Containers/caddy/Caddyfile.bak.aud78-<timestamp>
webrestrict.lab.audasmedia.com.au {
reverse_proxy 192.168.20.13:8791
}
# No basic-auth: the Control Service has its own session login (AGENTS.md §9),
# matching the paperclip.lab and mail.lab vhosts.
#
# .13:8791 is reachable from .35 through the Docker-published port without a
# firewall rule. If that ever stops working, add 8791 to
# networking.firewall.allowedTCPPorts on .13 and rebuild.
#
# To apply this block on another host:
# ssh sam@192.168.20.35
# cp /Docker/Containers/caddy/Caddyfile /Docker/Containers/caddy/Caddyfile.bak
# cat >> /Docker/Containers/caddy/Caddyfile # append the block above
# docker exec caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
# docker exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
# curl -s https://webrestrict.lab.audasmedia.com.au/health

35
docker-compose.yml Normal file
View File

@@ -0,0 +1,35 @@
# Internet Use Control — Control Service (own image, own container, own port).
# Deploy on .13: cd /home/sam/paseo/projects/internet_use_control && docker compose up -d --build
# The service is the single source of truth (SYSTEM_MAP.md). Only it publishes config.
name: webrestrict
services:
control:
build:
context: .
args:
VERSION: "0.1.0-phase1"
image: webrestrict/control:0.1.0-phase1
container_name: webrestrict-control
restart: unless-stopped
env_file:
- environment.d/control-service.env
ports:
- "8791:8791"
volumes:
- webrestrict-data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8791/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
webrestrict-data:

View File

@@ -0,0 +1,38 @@
# Control Service configuration — template.
#
# Copy to control-service.env and fill in real values. The real file is
# gitignored and must never be committed. docker compose loads it with env_file.
#
# Secrets in this lab also live in ~/.config/environment.d/10-secrets.conf; the
# same variable names can be exported there instead. The service reads only the
# process environment, so either source works.
# Listen address inside the container.
CONTROL_ADDR=:8791
# SQLite database on the persistent volume.
CONTROL_DB_PATH=/data/control.db
CONTROL_APP_ENV=production
CONTROL_LOG_LEVEL=info
# Signs the session cookie. Set to a long random string (>= 16 chars).
# Generate: openssl rand -hex 32
CONTROL_SESSION_SECRET=change-me-to-a-long-random-string
# Session lifetime (Go duration).
CONTROL_SESSION_TTL=12h
# Secure cookie flag. Keep true behind Caddy/HTTPS. Set false only for
# plain-HTTP local testing.
CONTROL_COOKIE_SECURE=true
# The single dashboard login (Sam).
CONTROL_ADMIN_USERNAME=sam
# Option A (simple): plaintext password. Constant-time compared at login.
CONTROL_ADMIN_PASSWORD=change-me
# Option B (preferred): a bcrypt hash; leave CONTROL_ADMIN_PASSWORD empty.
# Generate: docker run --rm --entrypoint /app/control webrestrict/control:0.1.0-phase1 hash-password 'your-password'
# CONTROL_ADMIN_PASSWORD_HASH=$2a$10$...

20
go.mod Normal file
View File

@@ -0,0 +1,20 @@
module gitea.lab.audasmedia.com.au/sam/internet_use_control
go 1.25
require (
golang.org/x/crypto v0.31.0
modernc.org/sqlite v1.34.5
)
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 v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.28.0 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
)

45
go.sum Normal file
View File

@@ -0,0 +1,45 @@
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-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
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/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 v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

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)
}

112
internal/auth/auth_test.go Normal file
View File

@@ -0,0 +1,112 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func testManager(t *testing.T) *Manager {
t.Helper()
m, err := New([]byte("0123456789abcdef0123456789abcdef"), time.Hour, false, "sam", "", "hunter2")
if err != nil {
t.Fatalf("New: %v", err)
}
return m
}
func TestCredentials(t *testing.T) {
m := testManager(t)
if !m.CheckCredentials("sam", "hunter2") {
t.Error("valid credentials rejected")
}
if m.CheckCredentials("sam", "wrong") {
t.Error("wrong password accepted")
}
if m.CheckCredentials("finn", "hunter2") {
t.Error("wrong username accepted")
}
}
func TestSessionRoundTripAndTamper(t *testing.T) {
m := testManager(t)
rec := httptest.NewRecorder()
m.Issue(rec)
res := rec.Result()
req := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range res.Cookies() {
req.AddCookie(c)
}
if !m.Authenticated(req) {
t.Fatal("issued session is not authenticated")
}
// Tamper with the session value: the HMAC must reject it.
tampered := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range res.Cookies() {
if c.Name == sessionCookie {
c.Value = c.Value[:len(c.Value)-2] + "xx"
}
tampered.AddCookie(c)
}
if m.Authenticated(tampered) {
t.Fatal("tampered session accepted")
}
}
func TestCSRF(t *testing.T) {
m := testManager(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/login", nil)
token := m.EnsureCSRF(rec, req)
if token == "" {
t.Fatal("empty csrf token")
}
// The token is delivered as a cookie; a matching form value passes.
form := httptest.NewRequest(http.MethodPost, "/login", nil)
form.PostForm = map[string][]string{"csrf": {token}}
for _, c := range rec.Result().Cookies() {
form.AddCookie(c)
}
if !m.CheckCSRF(form) {
t.Error("matching csrf rejected")
}
// A different form value fails.
bad := httptest.NewRequest(http.MethodPost, "/login", nil)
bad.PostForm = map[string][]string{"csrf": {"not-the-token"}}
for _, c := range rec.Result().Cookies() {
bad.AddCookie(c)
}
if m.CheckCSRF(bad) {
t.Error("mismatched csrf accepted")
}
// No cookie at all fails.
none := httptest.NewRequest(http.MethodPost, "/login", nil)
none.PostForm = map[string][]string{"csrf": {token}}
if m.CheckCSRF(none) {
t.Error("csrf accepted without cookie")
}
}
func TestExpiredSession(t *testing.T) {
m, err := New([]byte("0123456789abcdef0123456789abcdef"), -time.Minute, false, "sam", "", "hunter2")
if err != nil {
t.Fatalf("New: %v", err)
}
rec := httptest.NewRecorder()
m.Issue(rec)
req := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
if m.Authenticated(req) {
t.Error("expired session accepted")
}
}

114
internal/config/config.go Normal file
View 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)
}
}

View 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")
}
}

View File

@@ -0,0 +1,89 @@
-- Migration 001 — Phase 1 foundation (AGENTS.md §7).
-- Tables may grow in later phases. Never hard-delete devices or history.
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
child TEXT NOT NULL,
platform TEXT NOT NULL CHECK (platform IN ('android', 'windows')),
topic TEXT NOT NULL UNIQUE,
online INTEGER NOT NULL DEFAULT 0,
last_seen TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS schedules (
id INTEGER PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id),
weekday INTEGER NOT NULL CHECK (weekday BETWEEN 0 AND 6), -- 0 = Monday
start_time TEXT NOT NULL, -- HH:MM
end_time TEXT NOT NULL, -- HH:MM
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_schedules_device ON schedules(device_id);
CREATE TABLE IF NOT EXISTS blocklist (
id INTEGER PRIMARY KEY,
device_id INTEGER REFERENCES devices(id), -- NULL = applies to every device
kind TEXT NOT NULL CHECK (kind IN ('app', 'site')),
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_blocklist_device ON blocklist(device_id);
CREATE TABLE IF NOT EXISTS allowlist (
id INTEGER PRIMARY KEY,
device_id INTEGER REFERENCES devices(id), -- NULL = applies to every device
kind TEXT NOT NULL CHECK (kind IN ('app', 'site')),
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_allowlist_device ON allowlist(device_id);
-- Desired state per device. The Control Service is the only writer.
CREATE TABLE IF NOT EXISTS state (
device_id INTEGER PRIMARY KEY REFERENCES devices(id),
locked INTEGER NOT NULL DEFAULT 0,
until TEXT,
allow_json TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS telemetry (
id INTEGER PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id),
app TEXT,
domain TEXT,
seconds INTEGER NOT NULL DEFAULT 0,
day TEXT NOT NULL, -- YYYY-MM-DD, local day
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_telemetry_device_day ON telemetry(device_id, day);
CREATE TABLE IF NOT EXISTS overrides (
id INTEGER PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id),
actor TEXT NOT NULL,
action TEXT NOT NULL,
until TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_overrides_device ON overrides(device_id);
-- Append-only audit log. Size-capped by a later phase, never edited in place.
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY,
actor TEXT NOT NULL,
action TEXT NOT NULL,
device_id INTEGER REFERENCES devices(id),
detail TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit(created_at);

132
internal/store/store.go Normal file
View File

@@ -0,0 +1,132 @@
// Package store owns the SQLite database. It is the single source of truth for
// the Control Service (SYSTEM_MAP.md).
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo)
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// Store wraps the database handle.
type Store struct {
db *sql.DB
}
// Open opens (creating if needed) the SQLite database at path.
func Open(path string) (*Store, error) {
dsn := fmt.Sprintf(
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)",
path,
)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// modernc/sqlite serialises writes; one connection avoids lock churn.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
return &Store{db: db}, nil
}
// DB exposes the handle for read-only queries in later phases.
func (s *Store) DB() *sql.DB { return s.db }
// Ping verifies the database is reachable.
func (s *Store) Ping(ctx context.Context) error {
return s.db.PingContext(ctx)
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// Migrate applies every embedded migration that has not run yet. It tracks the
// applied version with PRAGMA user_version, so reruns are safe.
func (s *Store) Migrate(ctx context.Context) error {
files, err := migrationFiles()
if err != nil {
return err
}
var version int
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
return fmt.Errorf("read user_version: %w", err)
}
for i := version; i < len(files); i++ {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %d: %w", i+1, err)
}
if _, err := tx.ExecContext(ctx, files[i].sql); err != nil {
_ = tx.Rollback()
return fmt.Errorf("migration %s: %w", files[i].name, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("set user_version %d: %w", i+1, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", files[i].name, err)
}
}
return nil
}
type migration struct {
name string
sql string
}
func migrationFiles() ([]migration, error) {
entries, err := fs.ReadDir(migrationFS, "migrations")
if err != nil {
return nil, 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) // 001_, 002_, ... apply in order
out := make([]migration, 0, len(names))
for _, n := range names {
b, err := fs.ReadFile(migrationFS, "migrations/"+n)
if err != nil {
return nil, fmt.Errorf("read migration %s: %w", n, err)
}
out = append(out, migration{name: n, sql: string(b)})
}
return out, nil
}
// Audit appends one row to the append-only audit log.
func (s *Store) Audit(ctx context.Context, actor, action string, deviceID *int64, detail string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO audit (actor, action, device_id, detail) VALUES (?, ?, ?, ?)`,
actor, action, deviceID, detail,
)
if err != nil {
return fmt.Errorf("audit insert: %w", err)
}
return nil
}
// CountDevices returns the number of registered devices (0 in Phase 1).
func (s *Store) CountDevices(ctx context.Context) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM devices`).Scan(&n); err != nil {
return 0, fmt.Errorf("count devices: %w", err)
}
return n, nil
}

View File

@@ -0,0 +1,52 @@
package store
import (
"context"
"path/filepath"
"testing"
)
func TestMigrateAndFoundation(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "control.db")
st, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer st.Close()
if err := st.Migrate(ctx); err != nil {
t.Fatalf("Migrate: %v", err)
}
// Migrate is idempotent.
if err := st.Migrate(ctx); err != nil {
t.Fatalf("second Migrate: %v", err)
}
n, err := st.CountDevices(ctx)
if err != nil {
t.Fatalf("CountDevices: %v", err)
}
if n != 0 {
t.Errorf("CountDevices = %d, want 0", n)
}
if err := st.Audit(ctx, "sam", "test", nil, "phase1"); err != nil {
t.Fatalf("Audit: %v", err)
}
// Every §7 table must exist after migration.
want := []string{
"devices", "schedules", "blocklist", "allowlist",
"state", "telemetry", "overrides", "audit",
}
for _, table := range want {
var name string
err := st.DB().QueryRowContext(ctx,
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name)
if err != nil {
t.Errorf("table %q missing: %v", table, err)
}
}
}

284
internal/web/server.go Normal file
View File

@@ -0,0 +1,284 @@
// Package web is the HTTP surface of the Control Service: /health, the session
// login, and the server-rendered HTMX dashboard (AGENTS.md §5, §9).
package web
import (
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
//go:embed templates/*.html templates/partials/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
// Server holds the dependencies for the HTTP handlers.
type Server struct {
cfg *config.Config
store *store.Store
auth *auth.Manager
tmpl *template.Template
log *slog.Logger
}
// New builds a Server and parses the embedded templates.
func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger) (*Server, error) {
funcs := template.FuncMap{
"year": func() int { return time.Now().Year() },
"now": func() time.Time { return time.Now() },
"clock": func() string {
return time.Now().Format("15:04:05 MST")
},
}
tmpl, err := template.New("").Funcs(funcs).ParseFS(templateFS,
"templates/*.html", "templates/partials/*.html")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log}, nil
}
// Handler returns the fully wrapped HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
mux.HandleFunc("GET /partials/status", s.requireAuth(s.handleStatusPartial))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("web: embedded static dir missing: " + err.Error())
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
return s.recoverer(s.securityHeaders(s.requestLogger(mux)))
}
// ---------- middleware ----------
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (s *Server) requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.log.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"remote", r.RemoteAddr,
"duration", time.Since(start).String(),
)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic", "err", rec, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.auth.Authenticated(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
// ---------- handlers ----------
type healthResponse struct {
Status string `json:"status"`
Service string `json:"service"`
Version string `json:"version"`
Database string `json:"database"`
Time string `json:"time"`
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{
Status: "ok",
Service: "control-service",
Version: s.cfg.Version,
Database: "ok",
Time: time.Now().UTC().Format(time.RFC3339),
}
code := http.StatusOK
if err := s.store.Ping(r.Context()); err != nil {
resp.Status = "error"
resp.Database = "error"
code = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(resp)
}
type loginData struct {
CSRF string
Error string
Username string
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if s.auth.Authenticated(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
s.render(w, r, "login.html", loginData{CSRF: s.auth.EnsureCSRF(w, r)})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
if !s.auth.CheckCSRF(r) {
s.log.Warn("login rejected: bad csrf token", "remote", r.RemoteAddr)
_ = s.store.Audit(r.Context(), username, "login.csrf_rejected", nil, r.RemoteAddr)
s.render(w, r, "login.html", loginData{
CSRF: s.auth.EnsureCSRF(w, r),
Error: "Session expired. Please try again.",
Username: username,
})
return
}
if !s.auth.CheckCredentials(username, r.PostFormValue("password")) {
s.log.Warn("login failed", "user", username, "remote", r.RemoteAddr)
_ = s.store.Audit(r.Context(), username, "login.failed", nil, r.RemoteAddr)
w.WriteHeader(http.StatusUnauthorized)
s.render(w, r, "login.html", loginData{
CSRF: s.auth.EnsureCSRF(w, r),
Error: "Incorrect username or password.",
Username: username,
})
return
}
s.auth.Issue(w)
_ = s.store.Audit(r.Context(), username, "login.success", nil, r.RemoteAddr)
s.log.Info("login success", "user", username, "remote", r.RemoteAddr)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if s.auth.Authenticated(r) && s.auth.CheckCSRF(r) {
_ = s.store.Audit(r.Context(), s.cfg.AdminUsername, "logout", nil, r.RemoteAddr)
}
s.auth.Clear(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
type dashboardData struct {
Title string
Username string
CSRF string
Version string
AppEnv string
DBPath string
DeviceCount int
DBHealthy bool
ServerTime string
ServerUp string
}
var serverStarted = time.Now()
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
data := s.dashboardData(w, r)
s.render(w, r, "dashboard.html", data)
}
func (s *Server) handleStatusPartial(w http.ResponseWriter, r *http.Request) {
data := s.dashboardData(w, r)
s.renderFragment(w, r, "status.html", data)
}
func (s *Server) dashboardData(w http.ResponseWriter, r *http.Request) dashboardData {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
count, err := s.store.CountDevices(ctx)
return dashboardData{
Title: "Dashboard",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
DBPath: s.cfg.DBPath,
DeviceCount: count,
DBHealthy: err == nil,
ServerTime: time.Now().Format("Mon 02 Jan 2006 15:04:05"),
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
}
}
func (s *Server) render(w http.ResponseWriter, _ *http.Request, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error("render", "template", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func (s *Server) renderFragment(w http.ResponseWriter, _ *http.Request, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error("render fragment", "template", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}

308
internal/web/static/app.css Normal file
View File

@@ -0,0 +1,308 @@
/* Internet Use Control — Control Service UI
Built on the Family Home Lab design tokens (tokens.css).
Rules: canvas-soft app background, white cards, hairlines, one action blue,
pill CTAs, 8px grid, touch targets >= 44px, dark mode from the tokens. */
*,
*::before,
*::after { box-sizing: border-box; }
html { font-size: 16px; }
body {
margin: 0;
background: var(--canvas-soft);
color: var(--ink-secondary);
font: var(--type-body-md);
-webkit-font-smoothing: antialiased;
min-height: 100dvh;
}
h1, h2, h3 { margin: 0; color: var(--ink); }
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
code {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
background: var(--surface-2);
padding: 1px 5px;
border-radius: var(--rounded-xs);
}
/* ---------- Login ---------- */
.login-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-lg);
padding: var(--space-lg);
background:
radial-gradient(1200px 600px at 15% 0%, #2b3d93 0%, transparent 60%),
var(--secondary);
}
.login-band {
width: 100%;
max-width: 960px;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: var(--space-xxl);
align-items: center;
padding: var(--space-xxl) 0;
}
.login-eyebrow {
margin: 0 0 var(--space-sm);
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgba(255, 255, 255, 0.72);
}
.login-headline {
margin: 0;
font: var(--type-display-1);
letter-spacing: -1.5px;
color: #ffffff;
}
.login-sub {
margin: var(--space-md) 0 0;
font: var(--type-body-md);
color: rgba(255, 255, 255, 0.78);
}
.auth-card {
background: var(--surface);
border-radius: var(--rounded-lg);
box-shadow: var(--shadow-level-2);
padding: var(--space-xl);
}
.auth-card__title {
font: var(--type-heading-2);
margin-bottom: var(--space-lg);
}
.auth-form { display: grid; gap: var(--space-md); }
.auth-error {
margin: 0 0 var(--space-md);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--sticker-red);
border-left-width: 3px;
border-radius: var(--rounded-sm);
color: var(--sticker-red);
background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface));
font: var(--type-body-sm);
}
.field { display: grid; gap: var(--space-xs); }
.field__label {
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
}
.text-input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
font: var(--type-body-md);
color: var(--ink);
background: var(--surface);
border: 1px solid #dddddd;
border-radius: var(--rounded-xs);
}
[data-theme="dark"] .text-input { border-color: var(--hairline); }
.text-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: var(--shadow-level-1), 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent);
}
/* ---------- Buttons ---------- */
.button-primary,
.button-utility {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 10px 20px;
font: var(--type-button);
cursor: pointer;
transition: background 120ms ease, transform 80ms ease, box-shadow 120ms ease;
}
.button-primary {
border: 0;
border-radius: var(--rounded-full);
background: var(--primary);
color: var(--on-primary);
}
.button-primary:active { background: var(--primary-active); transform: scale(0.97); }
.button-primary:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); outline-offset: 2px; }
.button-utility {
border: 1px solid var(--hairline);
border-radius: var(--rounded-md);
background: var(--surface);
color: var(--ink);
padding: 8px 14px;
}
.button-utility:hover { box-shadow: var(--shadow-level-1); }
/* ---------- App shell ---------- */
.app { display: flex; flex-direction: column; min-height: 100dvh; }
.nav-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-sm) var(--space-lg);
background: var(--surface);
border-bottom: 1px solid var(--hairline);
}
.nav-bar__brand {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--ink);
font: var(--type-title);
}
.nav-bar__brand:hover { text-decoration: none; }
.nav-bar__mark {
width: 12px; height: 12px; border-radius: var(--rounded-full);
background: var(--primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
}
.nav-bar__right { display: flex; align-items: center; gap: var(--space-md); }
.nav-bar__user { font: var(--type-body-sm); color: var(--ink-muted); }
.nav-bar__logout { margin: 0; }
.container {
width: 100%;
max-width: var(--container-max);
margin: 0 auto;
padding: var(--space-xxl) var(--space-lg);
display: grid;
gap: var(--space-xxl);
flex: 1;
}
.page-head { display: grid; gap: var(--space-xs); }
.heading-1 { font: var(--type-heading-1); letter-spacing: -0.8px; }
.heading-2 { font: var(--type-heading-2); letter-spacing: -0.25px; }
.page-head__sub { margin: 0; color: var(--ink-muted); font: var(--type-body-md); }
/* ---------- Cards ---------- */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: var(--space-md);
}
.tool-card {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
padding: var(--space-lg);
display: grid;
gap: var(--space-xs);
align-content: start;
}
.tool-card__icon {
width: 40px; height: 40px;
display: grid; place-items: center;
border-radius: var(--rounded-full);
color: #ffffff;
font-size: 15px;
margin-bottom: var(--space-xs);
}
.tool-card__icon--green { background: var(--sticker-green); }
.tool-card__icon--sky { background: var(--sticker-sky); }
.tool-card__icon--purple { background: var(--sticker-purple); color: var(--sticker-purple-deep); }
.tool-card__icon--orange { background: var(--sticker-orange); }
.tool-card__title { font: var(--type-title); }
.tool-card__desc { margin: 0; color: var(--ink-muted); font: var(--type-body-sm); }
.status-pill {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: var(--space-xs);
padding: 4px 10px;
border: 1px solid var(--hairline);
border-radius: var(--rounded-full);
font: var(--type-eyebrow);
color: var(--ink-muted);
}
.status-pill--on { color: var(--sticker-green); border-color: color-mix(in srgb, var(--sticker-green) 40%, var(--hairline)); }
.status-pill--off { color: var(--sticker-red); border-color: color-mix(in srgb, var(--sticker-red) 40%, var(--hairline)); }
.dot { width: 8px; height: 8px; border-radius: var(--rounded-full); display: inline-block; }
.dot--on { background: var(--sticker-green); }
.dot--off { background: var(--sticker-red); }
.dot--faint { background: var(--ink-faint); }
.category-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-full);
font: var(--type-eyebrow);
color: var(--ink-muted);
}
/* ---------- Sections ---------- */
.section { display: grid; gap: var(--space-md); }
.section__head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
.empty-state {
background: var(--surface);
border: 1px dashed var(--hairline);
border-radius: var(--rounded-lg);
padding: var(--space-xl);
text-align: center;
}
.empty-state__title { margin: 0 0 var(--space-xs); font: var(--type-title); color: var(--ink); }
.empty-state__body { margin: 0 auto; max-width: 52ch; color: var(--ink-muted); font: var(--type-body-sm); }
/* ---------- Footer ---------- */
.app-foot,
.login-foot {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-lg);
color: var(--ink-faint);
font: var(--type-caption);
}
.app-foot { justify-content: center; border-top: 1px solid var(--hairline); background: var(--surface); }
.login-foot { color: rgba(255, 255, 255, 0.6); }
.app-foot__sep { opacity: 0.5; }
/* ---------- Responsive ---------- */
@media (max-width: 760px) {
.login-band { grid-template-columns: 1fr; gap: var(--space-xl); text-align: left; }
.login-headline { font: var(--type-heading-1); letter-spacing: -0.8px; }
.container { padding: var(--space-lg) var(--space-md); gap: var(--space-xl); }
.nav-bar { padding: var(--space-sm) var(--space-md); }
.nav-bar__user { display: none; }
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
internal/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,110 @@
/* ============================================================
Family Home Lab — Design Tokens
Source: DESIGN.md (Notion design language)
Only structural/semantic tokens here. All values map 1:1.
============================================================ */
/* Inter (@fontsource) — self-hosted substitute for NotionInter */
@font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("/static/fonts/inter-400.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("/static/fonts/inter-500.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("/static/fonts/inter-600.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 700; font-display: swap; src: url("/static/fonts/inter-700.woff2") format("woff2"); }
:root {
/* --- Surface --- */
--canvas-soft: #f6f5f4;
--surface: #ffffff;
--surface-2: #f2f2f1;
--hairline: #e6e6e6;
/* --- Text --- */
--ink: #000000;
--ink-secondary: #31302e;
--ink-muted: #615d59;
--ink-faint: #a39e98;
/* --- Structural accents --- */
--primary: #0075de; /* the ONLY action colour */
--primary-active: #005bab;
--on-primary: #ffffff;
--secondary: #213183; /* deep indigo — login band + greeting only */
/* --- Sticker palette (decorative only) --- */
--sticker-sky: #62aef0; /* chat */
--sticker-purple: #d6b6f6; /* image */
--sticker-pink: #ff64c8; /* video */
--sticker-orange: #dd5b00; /* audio */
--sticker-teal: #2a9d99; /* docs */
--sticker-green: #1aae39; /* status: online/saved */
--sticker-red: #d33a2b; /* status: failed / error (ON_OFF.md decision) */
--sticker-purple-deep: #391c57; /* illustration only */
--sticker-orange-deep: #793400; /* illustration only */
/* --- Typography (Inter, self-hosted via @font-face) --- */
--font-family: "Inter", -apple-system, system-ui, "Segoe UI",
Helvetica, Arial, sans-serif;
/* Font-family is baked into each token below: the CSS `font` shorthand
resets font-family to the UA default (serif) unless a family is written,
and it cannot carry letter-spacing — so weights/sizes ship inline and the
family is appended per token. */
--type-display-1: 700 48px/1.05 var(--font-family);
--type-heading-1: 700 32px/1.1 var(--font-family);
--type-heading-2: 700 22px/1.27 var(--font-family);
--type-title: 600 17px/1.4 var(--font-family);
--type-body-md: 400 15px/1.5 var(--font-family);
--type-body-sm: 400 14px/1.4 var(--font-family);
--type-button: 500 15px/1.4 var(--font-family);
--type-caption: 400 13px/1.43 var(--font-family);
--type-eyebrow: 600 12px/1.33 var(--font-family);
/* --- Layout (base unit 8px) --- */
--space-xxs: 4px;
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 28px;
--space-xxl: 32px;
--container-max: 1200px;
/* --- Elevation --- */
--shadow-level-1: rgba(0, 0, 0, 0.01) 0 0.175px 1px,
rgba(0, 0, 0, 0.02) 0 0.8px 3px,
rgba(0, 0, 0, 0.027) 0 2px 8px,
rgba(0, 0, 0, 0.04) 0 4px 18px;
--shadow-level-2: rgba(0, 0, 0, 0.0122) 0 0.175px 1px,
rgba(0, 0, 0, 0.024) 0 0.8px 3px,
rgba(0, 0, 0, 0.032) 0 2px 8px,
rgba(0, 0, 0, 0.047) 0 4px 18px,
rgba(0, 0, 0, 0.05) 0 23px 52px;
/* --- Shapes --- */
--rounded-xs: 4px;
--rounded-sm: 5px;
--rounded-md: 8px;
--rounded-lg: 12px;
--rounded-full: 9999px;
}
/* Dark mode — flip the semantic tokens only (components use var(--…)). */
[data-theme="dark"] {
--canvas-soft: #141518;
--surface: #1c1e22;
--surface-2: #24262c;
--hairline: #33363d;
--ink: #f5f6f7;
--ink-secondary: #dcdee2;
--ink-muted: #a9adb6;
--ink-faint: #727680;
--primary: #3d8be0;
--primary-active: #5d9ff0;
--on-primary: #ffffff;
--secondary: #10142a;
--shadow-level-1: rgba(0, 0, 0, 0.45) 0 1px 3px;
--shadow-level-2: rgba(0, 0, 0, 0.55) 0 5px 22px;
}
/* Numerals: enable OpenType lnum */
html { font-feature-settings: "lnum" 1; }

View File

@@ -0,0 +1,54 @@
{{define "dashboard.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}} — Internet Use Control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js" defer></script>
</head>
<body class="app">
<nav class="nav-bar">
<a class="nav-bar__brand" href="/">
<span class="nav-bar__mark" aria-hidden="true"></span>
Internet Use Control
</a>
<div class="nav-bar__right">
<span class="nav-bar__user">{{.Username}}</span>
<form method="post" action="/logout" class="nav-bar__logout">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button-utility" type="submit">Sign out</button>
</form>
</div>
</nav>
<main class="container">
<header class="page-head">
<h1 class="heading-1">Dashboard</h1>
<p class="page-head__sub">Phase 1 skeleton — the Control Service is up and answering.</p>
</header>
{{template "status.html" .}}
<section class="section" aria-labelledby="devices-title">
<div class="section__head">
<h2 id="devices-title" class="heading-2">Devices</h2>
<span class="category-pill"><span class="dot dot--faint"></span>0 registered</span>
</div>
<div class="empty-state">
<p class="empty-state__title">No devices yet</p>
<p class="empty-state__body">Device registration arrives in Phase 2. Until then the
dashboard, session login and <code>/health</code> are the working pieces.</p>
</div>
</section>
</main>
<footer class="app-foot">
<span>Control Service · {{.Version}} · {{.AppEnv}}</span>
<span class="app-foot__sep">·</span>
<span>{{year}} Family Home Lab</span>
</footer>
</body>
</html>{{end}}

View File

@@ -0,0 +1,40 @@
{{define "login.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Sign in — Internet Use Control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="login-page">
<main class="login-band">
<div class="login-band__inner">
<p class="login-eyebrow">Internet Use Control</p>
<h1 class="login-headline">Good to see you,<br>Sam.</h1>
<p class="login-sub">Sign in to manage the kids' devices.</p>
</div>
<section class="auth-card" aria-labelledby="signin-title">
<h2 id="signin-title" class="auth-card__title">Sign in</h2>
{{if .Error}}<p class="auth-error" role="alert">{{.Error}}</p>{{end}}
<form method="post" action="/login" class="auth-form" autocomplete="on">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label class="field">
<span class="field__label">Username</span>
<input class="text-input" type="text" name="username" value="{{.Username}}"
autocomplete="username" autocapitalize="none" spellcheck="false" required>
</label>
<label class="field">
<span class="field__label">Password</span>
<input class="text-input" type="password" name="password"
autocomplete="current-password" required>
</label>
<button class="button-primary" type="submit">Sign in</button>
</form>
</section>
</main>
<footer class="login-foot">Family Home Lab · Control Service</footer>
</body>
</html>{{end}}

View File

@@ -0,0 +1,34 @@
{{define "status.html"}}<section class="card-grid" id="status" hx-get="/partials/status" hx-trigger="every 10s" hx-swap="outerHTML">
<article class="tool-card">
<div class="tool-card__icon tool-card__icon--green" aria-hidden="true">●</div>
<h3 class="tool-card__title">Service</h3>
<p class="tool-card__desc">
{{if .DBHealthy}}Running{{else}}Database error{{end}}
</p>
<span class="status-pill {{if .DBHealthy}}status-pill--on{{else}}status-pill--off{{end}}">
<span class="dot {{if .DBHealthy}}dot--on{{else}}dot--off{{end}}"></span>
{{if .DBHealthy}}online{{else}}offline{{end}}
</span>
</article>
<article class="tool-card">
<div class="tool-card__icon tool-card__icon--sky" aria-hidden="true">▤</div>
<h3 class="tool-card__title">Database</h3>
<p class="tool-card__desc">SQLite · source of truth</p>
<span class="status-pill status-pill--muted">{{if .DBHealthy}}ok{{else}}error{{end}}</span>
</article>
<article class="tool-card">
<div class="tool-card__icon tool-card__icon--purple" aria-hidden="true">⏱</div>
<h3 class="tool-card__title">Uptime</h3>
<p class="tool-card__desc">{{.ServerUp}}</p>
<span class="status-pill status-pill--muted">since boot</span>
</article>
<article class="tool-card">
<div class="tool-card__icon tool-card__icon--orange" aria-hidden="true">⌚</div>
<h3 class="tool-card__title">Server time</h3>
<p class="tool-card__desc">{{.ServerTime}}</p>
<span class="status-pill status-pill--muted">local</span>
</article>
</section>{{end}}