Phase 8: overrides (shared keycode + versioned admin lock/unlock/grant API)
Build on Phases 1–7. Adds the two override paths from AGENTS.md §4 FR-10/FR-11: the shared device keycode and the versioned admin HTTP API the separate Kotlin admin app will call. - store: migration 004 adds state.grant_until and a settings table. New overrides.go records every override in `overrides` and verifies the shared keycode (bcrypt; one code for every device, §10 rule 3). ExpireLocks also clears an expired grant, and NextScheduleBoundary includes the grant end so a device re-locks on time. - effective state: a grant unlocks a device inside a schedule window; a manual lock clears a grant and a grant clears a manual lock. The retained config payload gains grant_until (additive, v stays 1). - web: /api/v1/admin/... (list, state, lock, unlock, grant) guarded by CONTROL_ADMIN_API_TOKEN, separate from the session login, plus the on-device keycode endpoint POST /api/v1/device/override with per-address throttling. A state change is published retained before the API responds. - cmd/control: `keycode` subcommand and start-up seeding from CONTROL_DEVICE_KEYCODE_HASH. - deploy/verify-phase8.sh + `make verify-phase8` prove the definition of done: an API lock reaches a real broker subscriber in well under a second. - Version 0.6.0-phase8 (compose, Makefile, docs). Measured on .13: the lock reached the TLS broker subscriber in 7 ms (handler 1 ms, request to retained publish accepted). Tests: go vet ./... and go test -count=1 ./... pass in golang:1.25-alpine. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -7,10 +7,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -58,6 +60,9 @@ func main() {
|
||||
case "mqtt-clients":
|
||||
mqttClients(os.Args[2:])
|
||||
return
|
||||
case "keycode":
|
||||
keycode(os.Args[2:])
|
||||
return
|
||||
case "rollup":
|
||||
rollup(os.Args[2:])
|
||||
return
|
||||
@@ -107,6 +112,16 @@ func run() error {
|
||||
log.Info("audit log capped", "removed", n, "max", auditMaxRows)
|
||||
}
|
||||
|
||||
// Phase 8: seed the shared device keycode from environment.d. The stored
|
||||
// value is the runtime copy that `control keycode` can change; the
|
||||
// environment is the deploy-time source. A malformed hash stops start-up
|
||||
// rather than silently disabling the keycode path.
|
||||
if seeded, err := st.SeedKeycodeHash(context.Background(), cfg.DeviceKeycodeHash); err != nil {
|
||||
return err
|
||||
} else if seeded {
|
||||
log.Info("device keycode seeded from CONTROL_DEVICE_KEYCODE_HASH")
|
||||
}
|
||||
|
||||
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure,
|
||||
cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword)
|
||||
if err != nil {
|
||||
@@ -365,6 +380,77 @@ func env(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// keycode sets the shared device keycode (AGENTS.md §10 rule 3). It reads the
|
||||
// code from a positional argument or stdin, or generates a random six-digit
|
||||
// code with --generate, and stores only its bcrypt hash in SQLite. The code
|
||||
// itself is printed once, and only when it was generated here, so it can be set
|
||||
// on each device; it is never logged by the running service.
|
||||
func keycode(args []string) {
|
||||
dbPath := env("CONTROL_DB_PATH", "/data/control.db")
|
||||
generate := false
|
||||
code := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--db", "-db":
|
||||
if i+1 < len(args) {
|
||||
dbPath = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--generate", "-generate":
|
||||
generate = true
|
||||
default:
|
||||
if code == "" {
|
||||
code = args[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if generate {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "control:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
code = fmt.Sprintf("%06d", n.Int64())
|
||||
} else if code == "" {
|
||||
b, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "control:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
code = strings.TrimSpace(string(b))
|
||||
}
|
||||
if len(code) < 4 || len(code) > 64 {
|
||||
fmt.Fprintln(os.Stderr, "control: keycode must be 4 to 64 characters")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
st, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "control:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.Migrate(context.Background()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "control:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "control:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := st.SetKeycodeHash(context.Background(), string(hash), "admin"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "control:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if generate {
|
||||
fmt.Printf("device keycode set: %s\n", code)
|
||||
return
|
||||
}
|
||||
fmt.Println("device keycode updated")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user