// 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)
}
// Drop to the host user BEFORE any state is created: the stores below
// mkdir/write under /maestro, and files made during the root
// phase stay root-owned after the handoff, locking the owner gate out of
// its own store (EACCES surfaced as a bogus ERR_INVALID_CREDENTIALS on
// /api/auth/setup).
if err := libcontainers.EnsureDataRoot(); err != nil {
log.Fatalf("Data root check failed: %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()
// 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)
}
}