Fresh boots opened the owner/token/session stores before EnsureDataRoot dropped to the host user, leaving a root-owned maestro/ state dir whose EACCES surfaced as a bogus ERR_INVALID_CREDENTIALS on /api/auth/setup. main() now runs the handoff first; EnsureDataRoot heals pre-existing root-owned state dirs while still root (healOwnerState). Storage failures from the owner store now answer 500 ERR_INTERNAL everywhere (status/setup/login/change-password/RequireSession/ GateWebSocket) instead of blaming credentials or re-advertising setup mode; SetupRequired returns the store error instead of swallowing it, and FileOwnerStore.Verify propagates read errors rather than masking them as unknown-user.
206 lines
8.3 KiB
Go
206 lines
8.3 KiB
Go
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
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 <dataRoot>/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)
|
|
}
|
|
}
|