// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package main import ( "context" "embed" "io/fs" "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" handlersinstance "git.dcntrlized.org/Dcntrlized/Maestro/handlers/instance" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers" handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry" libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" "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") } else { // PROD MODE: Use Docker dc, err := docker.NewDockerClient() if err != nil { log.Fatalf("Failed to init docker client: %v", err) } defer dc.Close() if err := libcontainers.EnsureDataRoot(); err != nil { log.Fatalf("Data root check failed: %v", err) } // 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. if _, err := pki.EnsureCA(dataRoot); err != nil { log.Fatalf("Failed to provision instance CA: %v", err) } // Dev mode: provision the identities the docker-compose.dev/debug.yaml // peers bind-mount at /dcntrlized/pki, BEFORE the shared infrastructure // boots — dcntrlized-auth's mTLS listener refuses to start without its // identity. Must run before EnsureDatabaseRunning/EnsureAuthRunning. if env.IsDev() { devboot.EnsureIdentities() } if err := cache.LoadInstalledContainers(); err != nil { log.Fatalf("Failed to load installed containers: %v", err) } // Ensure the shared database and auth service are running before // reconciling, so their containers are picked up by the reconciliation // below. Failures are not fatal: Maestro keeps running and the UI // surfaces infrastructure status for manual action. if err := libcontainers.EnsureDatabaseRunning(ctx); err != nil { log.Printf("Warning: failed to ensure shared database: %v", err) } if err := libcontainers.EnsureAuthRunning(ctx); err != nil { log.Printf("Warning: failed to ensure shared auth service: %v", err) } // No dev pre-approval: the fleet's first mTLS contact records a pending // authorization and is refused (403) until the owner approves it via // /api/peers — dev runs the real trust-store flow. cache.ReconcileContainers(ctx, dc) go cache.WatchContainers(ctx, dc) } wsHub := containers.NewWebSocketHub(cache) gate := authService.RequireSession mux := http.NewServeMux() // 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))) // Instance public profile (hub-app theme M1): the one additional // unauthenticated surface — deliberately allowlisted around the owner // gate (cross-theme contract, hub-app D2). Exact path only; sub-paths // like /api/instance/attestation (federation) are registered separately. mux.Handle("/api/instance", handlersinstance.NewPublicProfileHandler(cache.GetServices, dataRoot)) 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.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 { log.Fatalf("Failed to parse embedded frontend subtree: %v", err) } mux.Handle("/", http.FileServer(http.FS(frontendDir))) port := os.Getenv("PORT") if port == "" { port = "8080" } server := &http.Server{ Addr: ":" + port, // Use the variable here Handler: mux, } log.Printf("Server starting on port %s...", port) if err := server.ListenAndServe(); err != nil { log.Fatal(err) } }