Files
jbergner 8c67c7a7fa
All checks were successful
release-tag / release-image (push) Successful in 10m52s
Update 1.5.0
2026-08-27 07:51:39 +02:00

284 lines
11 KiB
Go

package main
import (
"context"
"crypto/subtle"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
//go:embed index.html
var web embed.FS
type target struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
PublicURL string `json:"public_url"`
Path string `json:"-"`
Auth string `json:"-"`
Optional bool `json:"optional,omitempty"`
}
type status struct {
Name string `json:"name"`
OK bool `json:"ok"`
Status int `json:"status"`
LatencyMS int64 `json:"latency_ms"`
Detail any `json:"detail,omitempty"`
Error string `json:"error,omitempty"`
PublicURL string `json:"public_url,omitempty"`
Optional bool `json:"optional,omitempty"`
}
type server struct {
http *http.Client
targets []target
agentURL string
agentReadToken string
neuroforgeURL string
neuroforgeKey string
codebaseMemoryURL string
codebaseMemoryPublicURL string
vectorMode string
neuroforgeSearchK string
failOpen string
controlledLearning string
goalLearning string
outcomeLearning string
outcomeRetrieval string
outcomeSearchK string
outcomeMinSimilarity string
outcomeFailOpen string
researchEnabled string
searxngEnabled string
stagingBridge string
autonomyEnabled string
}
func env(k, d string) string {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
return d
}
func main() {
if err := validateControlSecrets(); err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
agentURL := env("AGENT_URL", "http://agent:8080")
nfURL := env("NEUROFORGE_URL", "http://neuroforge:8080")
nfKeyRaw := strings.TrimSpace(os.Getenv("NEUROFORGE_API_KEY"))
nfAuth := ""
if nfKeyRaw != "" {
nfAuth = "Bearer " + nfKeyRaw
}
s := &server{http: &http.Client{Timeout: 6 * time.Second}, agentURL: strings.TrimRight(agentURL, "/"), agentReadToken: strings.TrimSpace(os.Getenv("CONTROL_READ_TOKEN")), neuroforgeURL: strings.TrimRight(nfURL, "/"), neuroforgeKey: nfKeyRaw, codebaseMemoryURL: strings.TrimRight(strings.TrimSpace(os.Getenv("CODEBASE_MEMORY_URL")), "/"), codebaseMemoryPublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("PUBLIC_CODEBASE_MEMORY_URL")), "/"), vectorMode: env("KNOWLEDGE_VECTOR_BACKEND", "dual"), neuroforgeSearchK: env("NEUROFORGE_SEARCH_K", "128"), failOpen: env("NEUROFORGE_FAIL_OPEN", "true"), controlledLearning: env("NEUROFORGE_CONTROLLED_LEARNING", "true"), goalLearning: env("NEUROFORGE_GOAL_LEARNING_ENABLED", "false"), outcomeLearning: env("OUTCOME_LEARNING_ENABLED", "true"), outcomeRetrieval: env("OUTCOME_RETRIEVAL_ENABLED", "true"), outcomeSearchK: env("OUTCOME_RETRIEVAL_SEARCH_K", "6"), outcomeMinSimilarity: env("OUTCOME_RETRIEVAL_MIN_SIMILARITY", "0.58"), outcomeFailOpen: env("OUTCOME_RETRIEVAL_FAIL_OPEN", "true"), researchEnabled: env("NEUROFORGE_RESEARCH_ENABLED", "false"), searxngEnabled: env("NEUROFORGE_SEARXNG_ENABLED", "false"), stagingBridge: env("NEUROFORGE_KB_STAGING_ENABLED", "true"), autonomyEnabled: env("NEUROFORGE_AUTONOMY_ENABLED", "false")}
s.targets = []target{
{ID: "agent", Name: "GLPI AI Agent", URL: agentURL, PublicURL: env("PUBLIC_AGENT_URL", "http://localhost:8080"), Path: "/readyz"},
{ID: "knowledge", Name: "Knowledgebase", URL: env("KNOWLEDGE_URL", "http://knowledge:8080"), PublicURL: env("PUBLIC_KNOWLEDGE_URL", "http://localhost:8081"), Path: "/api/health"},
{ID: "neuroforge", Name: "NeuroForge Brain", URL: nfURL, PublicURL: env("PUBLIC_NEUROFORGE_URL", "http://localhost:8090/admin"), Path: "/api/v1/stats", Auth: nfAuth},
}
if s.codebaseMemoryURL != "" {
s.targets = append(s.targets, target{ID: "codebase-memory", Name: "Codebase Memory MCP", URL: s.codebaseMemoryURL, PublicURL: s.codebaseMemoryPublicURL, Path: "/", Optional: true})
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"ok": true}) })
mux.HandleFunc("GET /api/status", s.handleStatus)
mux.HandleFunc("GET /api/config", s.handleConfig)
mux.HandleFunc("GET /api/graph/runtime", s.handleRuntimeGraph)
mux.HandleFunc("GET /api/graph/runs", s.handleGraphRuns)
mux.HandleFunc("GET /api/graph/ticket", s.handleTicketGraph)
mux.HandleFunc("GET /api/graph/learning", s.handleLearningGraph)
mux.HandleFunc("GET /api/graph/research", s.handleResearchGraph)
mux.HandleFunc("GET /api/graph/brain", s.handleBrainGraph)
mux.HandleFunc("GET /api/graph/engineering", s.handleEngineeringGraph)
mux.HandleFunc("GET /api/graph/impact", s.handleEngineeringImpact)
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
b, _ := web.ReadFile("index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(b)
})
addr := env("CONTROL_ADDR", ":8070")
srv := &http.Server{Addr: addr, Handler: secure(controlBasicAuth(mux)), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second}
log.Printf("mega control listening on %s", addr)
errCh := make(chan error, 1)
go func() {
err := srv.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
errCh <- err
}()
select {
case err := <-errCh:
if err != nil {
log.Printf("control HTTP server stopped unexpectedly: %v", err)
}
case <-ctx.Done():
log.Printf("control shutdown requested")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("control graceful shutdown failed: %v", err)
_ = srv.Close()
}
}
func validateControlSecrets() error {
check := func(name string, min int) error {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return nil
}
u := strings.ToUpper(v)
if strings.Contains(u, "CHANGE_ME") || strings.Contains(u, "CHANGEME") || strings.Contains(u, "PLACEHOLDER") {
return fmt.Errorf("%s still contains a placeholder", name)
}
if len(v) < min {
return fmt.Errorf("%s must be at least %d characters", name, min)
}
return nil
}
if err := check("NEUROFORGE_API_KEY", 24); err != nil {
return err
}
if err := check("CONTROL_READ_TOKEN", 24); err != nil {
return err
}
if err := check("CONTROL_BASIC_AUTH_PASSWORD", 12); err != nil {
return err
}
u, p := strings.TrimSpace(os.Getenv("CONTROL_BASIC_AUTH_USER")), strings.TrimSpace(os.Getenv("CONTROL_BASIC_AUTH_PASSWORD"))
if (u == "") != (p == "") {
return errors.New("CONTROL_BASIC_AUTH_USER and CONTROL_BASIC_AUTH_PASSWORD must both be set or both be empty")
}
return nil
}
func controlBasicAuth(next http.Handler) http.Handler {
user := strings.TrimSpace(os.Getenv("CONTROL_BASIC_AUTH_USER"))
pass := strings.TrimSpace(os.Getenv("CONTROL_BASIC_AUTH_PASSWORD"))
if user == "" && pass == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
u, p, ok := r.BasicAuth()
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1
if !ok || !userOK || !passOK {
w.Header().Set("WWW-Authenticate", `Basic realm="NeuroForge Control", charset="UTF-8"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func secure(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"vector_backend": s.vectorMode, "neuroforge_search_k": s.neuroforgeSearchK, "neuroforge_fail_open": s.failOpen, "controlled_learning": s.controlledLearning, "goal_learning": s.goalLearning, "outcome_learning": s.outcomeLearning, "outcome_retrieval": s.outcomeRetrieval, "outcome_retrieval_search_k": s.outcomeSearchK, "outcome_retrieval_min_similarity": s.outcomeMinSimilarity, "outcome_retrieval_fail_open": s.outcomeFailOpen, "quality_replay": "available-on-agent", "research_enabled": s.researchEnabled, "searxng_enabled": s.searxngEnabled, "kb_staging_bridge": s.stagingBridge, "autonomy_enabled": s.autonomyEnabled, "control_plane": "read-only", "policy_owner": "glpi-agent", "unified_graph": true, "engineering_graph": "embedded-ast", "codebase_memory_url": s.codebaseMemoryPublicURL})
}
func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
out := s.statusSnapshot(ctx)
all := true
for _, st := range out {
if !st.OK && !st.Optional {
all = false
}
}
code := 200
if !all {
code = 207
}
writeJSON(w, code, map[string]any{"ok": all, "checked_at": time.Now().UTC(), "services": out})
}
func (s *server) statusSnapshot(ctx context.Context) []status {
ch := make(chan status, len(s.targets))
for _, t := range s.targets {
go func(t target) { ch <- s.check(ctx, t) }(t)
}
out := make([]status, 0, len(s.targets))
for range s.targets {
out = append(out, <-ch)
}
return out
}
func (s *server) check(ctx context.Context, t target) status {
started := time.Now()
st := status{Name: t.Name, PublicURL: t.PublicURL, Optional: t.Optional}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(t.URL, "/")+t.Path, nil)
if err != nil {
st.Error = err.Error()
return st
}
if t.Auth != "" {
req.Header.Set("Authorization", t.Auth)
}
resp, err := s.http.Do(req)
st.LatencyMS = time.Since(started).Milliseconds()
if err != nil {
st.Error = err.Error()
return st
}
defer resp.Body.Close()
st.Status = resp.StatusCode
st.OK = resp.StatusCode >= 200 && resp.StatusCode < 300
b, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if len(b) > 0 {
var v any
if json.Unmarshal(b, &v) == nil {
st.Detail = v
} else {
st.Detail = string(b)
}
}
if !st.OK && st.Error == "" {
st.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return st
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}