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:
+72
-18
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user