feat(auth): instance-owner gate — owner store, JWT sessions, setup/login UI (onboarding M1+M2)

lib/owner: argon2id credential store, HS256 access/refresh pair with kid,
hashed-jti session store, throttler (5 free failures then 4^n backoff).
handlers/auth: /api/auth/* + RequireSession/CSRF/GateWebSocket middleware;
every /api/* route is now gated (428 in setup mode). --reset-owner recovery.
Frontend: AuthScreen (setup/login), useAuth gate hook, gated data hooks +
single-flight silent refresh, Owner account section in settings.
spec.yaml documents the full surface.
This commit is contained in:
2026-08-22 22:58:18 +01:00
parent cf51a6be94
commit 2ffdf77905
24 changed files with 2850 additions and 37 deletions
+72 -18
View File
@@ -22,8 +22,10 @@ import (
"log"
"net/http"
"os"
"path/filepath"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/auth"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers"
handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry"
@@ -31,18 +33,62 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/lib/devboot"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/owner"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
)
//go:embed all:frontend/dist
var embeddedFrontend embed.FS
// resetOwner implements --reset-owner: documented headless lockout recovery.
// It removes the owner store, signing key and sessions, then exits — the next
// boot runs the setup wizard again.
func resetOwner() {
dataRoot, err := libcontainers.DataRoot()
if err != nil {
log.Fatalf("Failed to resolve data root: %v", err)
}
dir := filepath.Join(dataRoot, "maestro")
for _, f := range []string{"owners.json", "session.key", "sessions.json"} {
if err := os.Remove(filepath.Join(dir, f)); err != nil && !os.IsNotExist(err) {
log.Fatalf("Failed to remove %s: %v", f, err)
}
}
log.Printf("Owner identity reset (%s); restart Maestro to run the setup wizard.", dir)
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "--reset-owner" {
resetOwner()
return
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cache := libcontainers.NewContainerCache()
// Instance-owner gate (onboarding theme M1): self-contained credential +
// session stores at the data root, independent of the fleet.
dataRoot, err := libcontainers.DataRoot()
if err != nil {
log.Fatalf("Failed to resolve data root: %v", err)
}
maestroDir := filepath.Join(dataRoot, "maestro")
ownerStore, err := owner.NewFileOwnerStore(maestroDir)
if err != nil {
log.Fatalf("Failed to open owner store: %v", err)
}
tokenService, err := owner.NewTokenService(maestroDir)
if err != nil {
log.Fatalf("Failed to initialize session signing key: %v", err)
}
sessionStore, err := owner.NewFileSessionStore(maestroDir)
if err != nil {
log.Fatalf("Failed to open session store: %v", err)
}
authService := auth.New(ownerStore, tokenService, sessionStore)
if os.Getenv("DEV_MODE_STATIC_SERVICES") == "true" {
// DEV MODE: Load from file, skip Docker client
cache.LoadDevServices("services.json")
@@ -59,10 +105,6 @@ func main() {
// Provision the instance CA on first run (security theme M1). The CA is
// the trust anchor for every peer edge on dcntrlized_net; services get
// their identity certs from it at start/install.
dataRoot, err := libcontainers.DataRoot()
if err != nil {
log.Fatalf("Failed to resolve data root: %v", err)
}
if _, err := pki.EnsureCA(dataRoot); err != nil {
log.Fatalf("Failed to provision instance CA: %v", err)
}
@@ -94,27 +136,39 @@ func main() {
}
wsHub := containers.NewWebSocketHub(cache)
gate := authService.RequireSession
mux := http.NewServeMux()
mux.HandleFunc("/api/containers", containers.NewListContainersHandler(cache))
mux.HandleFunc("/api/ws", wsHub.HandleWS)
mux.HandleFunc("/api/containers/install", containers.InstallContainerHandler(cache))
mux.HandleFunc("/api/containers/uninstall", containers.UninstallContainerHandler(cache))
mux.HandleFunc("/api/containers/start", containers.StartContainerHandler(cache))
mux.HandleFunc("/api/containers/stop", containers.StopContainerHandler(cache))
mux.HandleFunc("/api/actions/{containerId}/{actionName}", actions.RunAction)
mux.HandleFunc("/api/registry", handlers.GetRegistry)
// Owner gate endpoints (onboarding theme M1). Status/setup/login/refresh/
// logout stay public — they are how a session is obtained; change-password
// rides behind the session gate like every other route.
mux.HandleFunc("/api/auth/status", authService.Status)
mux.HandleFunc("/api/auth/setup", authService.Setup)
mux.HandleFunc("/api/auth/login", authService.Login)
mux.HandleFunc("/api/auth/refresh", authService.Refresh)
mux.HandleFunc("/api/auth/logout", authService.Logout)
mux.Handle("/api/auth/change-password", gate(http.HandlerFunc(authService.ChangePassword)))
mux.Handle("/api/ws", authService.GateWebSocket(http.HandlerFunc(wsHub.HandleWS)))
mux.Handle("/api/containers", gate(containers.NewListContainersHandler(cache)))
mux.Handle("/api/containers/install", gate(containers.InstallContainerHandler(cache)))
mux.Handle("/api/containers/uninstall", gate(containers.UninstallContainerHandler(cache)))
mux.Handle("/api/containers/start", gate(containers.StartContainerHandler(cache)))
mux.Handle("/api/containers/stop", gate(containers.StopContainerHandler(cache)))
mux.Handle("/api/actions/{containerId}/{actionName}", gate(http.HandlerFunc(actions.RunAction)))
mux.Handle("/api/registry", gate(http.HandlerFunc(handlers.GetRegistry)))
// Trusted-services surface (security theme M2 §3): pending first-contact
// authorizations and the owner's approve/revoke actions, forwarded to the
// auth service's own trust store.
peersHandler := peers.NewHandler()
mux.HandleFunc("/api/peers", peersHandler.List)
mux.HandleFunc("/api/peers/attestation", peersHandler.Attestation)
mux.HandleFunc("/api/peers/approve", peersHandler.Approve)
mux.HandleFunc("/api/peers/revoke", peersHandler.Revoke)
mux.HandleFunc("/api/peers/reject", peersHandler.Reject)
mux.HandleFunc("/api/peers/hide", peersHandler.Hide)
mux.Handle("/api/peers", gate(http.HandlerFunc(peersHandler.List)))
mux.Handle("/api/peers/attestation", gate(http.HandlerFunc(peersHandler.Attestation)))
mux.Handle("/api/peers/approve", gate(http.HandlerFunc(peersHandler.Approve)))
mux.Handle("/api/peers/revoke", gate(http.HandlerFunc(peersHandler.Revoke)))
mux.Handle("/api/peers/reject", gate(http.HandlerFunc(peersHandler.Reject)))
mux.Handle("/api/peers/hide", gate(http.HandlerFunc(peersHandler.Hide)))
frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist")
if err != nil {