Files
glpi-neural-brain/internal/web/controller.go
jbergner 440423c5b6
All checks were successful
release-tag / release-image (push) Successful in 2m43s
RC-3
2026-08-09 11:29:13 +02:00

337 lines
11 KiB
Go

package web
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/local/glpi-neural-brain/internal/model"
"github.com/local/glpi-neural-brain/internal/sourceagent"
)
func (s *Server) handleGetControllerPolicy(w http.ResponseWriter, r *http.Request) {
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
return
}
ctx, cancel := contextTimeout(r, 10*time.Second)
defer cancel()
p, err := s.SourceAgents.ControllerPolicy(ctx)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, p)
}
func (s *Server) handleSetControllerPolicy(w http.ResponseWriter, r *http.Request) {
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
return
}
var p sourceagent.ControllerPolicy
if err := decode(r, &p); err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
ctx, cancel := contextTimeout(r, 10*time.Second)
defer cancel()
out, err := s.SourceAgents.SetControllerPolicy(ctx, p)
if err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
if s.Broker != nil {
s.Broker.Publish(model.Activity{Type: "controller.policy.updated", Source: "brain", Phase: "controller-policy", Message: "Docker-Controller-Policy wurde geändert", Strength: .88, Metadata: map[string]any{"enabled": out.Enabled, "autonomous_enabled": out.AutonomousEnabled, "dry_run": out.DryRun, "allow_destructive": out.AllowDestructive, "max_concurrent_jobs": out.MaxConcurrentJobs}})
}
writeJSON(w, 200, out)
}
func (s *Server) handleListControllerProfiles(w http.ResponseWriter, r *http.Request) {
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
return
}
ctx, cancel := contextTimeout(r, 10*time.Second)
defer cancel()
p, err := s.SourceAgents.ListControllerProfiles(ctx)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
if p == nil {
p = []sourceagent.ControllerProfile{}
}
writeJSON(w, 200, map[string]any{"profiles": p})
}
func (s *Server) handleCreateControllerProfile(w http.ResponseWriter, r *http.Request) {
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
return
}
var p sourceagent.ControllerProfile
if err := decode(r, &p); err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
ctx, cancel := contextTimeout(r, 10*time.Second)
defer cancel()
out, err := s.SourceAgents.UpsertControllerProfile(ctx, p)
if err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, out)
}
func (s *Server) handleUpdateControllerProfile(w http.ResponseWriter, r *http.Request) {
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
return
}
var p sourceagent.ControllerProfile
if err := decode(r, &p); err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
p.ID = r.PathValue("id")
ctx, cancel := contextTimeout(r, 10*time.Second)
defer cancel()
out, err := s.SourceAgents.UpsertControllerProfile(ctx, p)
if err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, out)
}
func (s *Server) handleDeleteControllerProfile(w http.ResponseWriter, r *http.Request) {
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
return
}
ctx, cancel := contextTimeout(r, 10*time.Second)
defer cancel()
if err := s.SourceAgents.DeleteControllerProfile(ctx, r.PathValue("id")); err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
func (s *Server) handleRunControllerProfile(w http.ResponseWriter, r *http.Request) {
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
return
}
var in struct {
DryRun *bool `json:"dry_run"`
Overrides map[string]any `json:"overrides"`
}
_ = decode(r, &in)
ctx, cancel := contextTimeout(r, 10*time.Second)
defer cancel()
profile, err := s.SourceAgents.ControllerProfile(ctx, r.PathValue("id"))
if err != nil {
writeJSON(w, 404, map[string]string{"error": err.Error()})
return
}
params := map[string]any{}
for k, v := range profile.Config {
params[k] = v
}
for k, v := range in.Overrides {
params[k] = v
}
job := sourceagent.ControllerJob{AgentID: profile.AgentID, ProfileID: profile.ID, Kind: profile.Kind, Purpose: profile.Purpose, Parameters: params}
if in.DryRun != nil {
job.DryRun = *in.DryRun
}
job, err = s.SourceAgents.QueueControllerJob(ctx, job)
if err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusAccepted, job)
}
func (s *Server) handleListControllerJobs(w http.ResponseWriter, r *http.Request) {
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
return
}
limit := 100
if n, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && n > 0 {
limit = n
}
ctx, cancel := contextTimeout(r, 10*time.Second)
defer cancel()
jobs, err := s.SourceAgents.ListControllerJobs(ctx, limit)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
if jobs == nil {
jobs = []sourceagent.ControllerJob{}
}
writeJSON(w, 200, map[string]any{"jobs": jobs, "stats": s.SourceAgents.ControllerStats(ctx)})
}
func (s *Server) handleCreateControllerJob(w http.ResponseWriter, r *http.Request) {
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
return
}
var job sourceagent.ControllerJob
if err := decode(r, &job); err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
job.Autonomous = false
ctx, cancel := contextTimeout(r, 10*time.Second)
defer cancel()
out, err := s.SourceAgents.QueueControllerJob(ctx, job)
if err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
if s.Broker != nil {
s.Broker.Publish(model.Activity{Type: "controller.job.queued", Source: "brain", Phase: "controller-manual", Message: "Manueller Docker-Controller-Job wurde eingeplant", Strength: .7, Metadata: map[string]any{"job_id": out.ID, "kind": out.Kind, "agent_id": out.AgentID, "dry_run": out.DryRun}})
}
writeJSON(w, http.StatusAccepted, out)
}
func (s *Server) handleCancelControllerJob(w http.ResponseWriter, r *http.Request) {
if !s.sourceStoreAvailable(w) || !s.adminAuthorized(w, r) {
return
}
ctx, cancel := contextTimeout(r, 10*time.Second)
defer cancel()
if err := s.SourceAgents.CancelControllerJob(ctx, r.PathValue("id")); err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
func agentHasCapability(a sourceagent.Agent, capability string) bool {
for _, c := range a.Capabilities {
if c == capability {
return true
}
}
return false
}
func (s *Server) handleAgentControllerClaim(w http.ResponseWriter, r *http.Request) {
a, err := s.authenticateSourceAgent(r)
if err != nil {
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
return
}
if !agentHasCapability(a, sourceagent.CapabilityDockerController) {
writeJSON(w, 403, map[string]string{"error": "agent has not advertised docker_controller capability"})
return
}
ctx, cancel := contextTimeout(r, 15*time.Second)
defer cancel()
p, err := s.SourceAgents.ControllerPolicy(ctx)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
if !p.Enabled {
w.WriteHeader(http.StatusNoContent)
return
}
job, ok, err := s.SourceAgents.ClaimControllerJob(ctx, a.ID, p.MaxJobDuration, agentHasCapability(a, sourceagent.CapabilityDockerCompose))
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
if !ok {
w.WriteHeader(http.StatusNoContent)
return
}
writeJSON(w, 200, sourceagent.ControllerClaim{SchemaVersion: sourceagent.SchemaVersion, Job: job, Policy: p})
}
func (s *Server) handleAgentControllerAuthorized(w http.ResponseWriter, r *http.Request) {
a, err := s.authenticateSourceAgent(r)
if err != nil {
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
return
}
ctx, cancel := contextTimeout(r, 5*time.Second)
defer cancel()
ok, reason, err := s.SourceAgents.ControllerJobAuthorized(ctx, a.ID, r.PathValue("id"))
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
if !ok {
writeJSON(w, 409, map[string]any{"authorized": false, "reason": reason})
return
}
writeJSON(w, 200, map[string]any{"authorized": true})
}
func (s *Server) handleAgentControllerResult(w http.ResponseWriter, r *http.Request) {
a, err := s.authenticateSourceAgent(r)
if err != nil {
writeJSON(w, 401, map[string]string{"error": "unauthorized"})
return
}
var result sourceagent.ControllerJobResult
dec := json.NewDecoder(io.LimitReader(r.Body, 2<<20))
if err := dec.Decode(&result); err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
result.JobID = strings.TrimSpace(r.PathValue("id"))
if result.AgentID != "" && result.AgentID != a.ID {
writeJSON(w, 403, map[string]string{"error": "agent_id mismatch"})
return
}
ctx, cancel := contextTimeout(r, 10*time.Second)
defer cancel()
job, _ := s.SourceAgents.ControllerJob(ctx, result.JobID)
if err := s.SourceAgents.CompleteControllerJob(ctx, a.ID, result); err != nil {
writeJSON(w, 409, map[string]string{"error": err.Error()})
return
}
nodeIDs := controllerJobNodeIDs(job)
if job.Kind == "evidence_http_probe" && result.Status == sourceagent.ControllerJobSucceeded && result.Error == "" && s.Graph != nil {
if external, ok := s.Graph.LookupExternal(strings.TrimSpace(asControllerString(job.Parameters["url"]))); ok {
if external.Metadata == nil {
external.Metadata = map[string]any{}
}
external.Metadata["controller_probe_verified_at"] = time.Now().UTC().Format(time.RFC3339Nano)
external.Metadata["controller_probe_agent_id"] = a.ID
external.Metadata["controller_probe_job_id"] = result.JobID
for _, key := range []string{"http_status", "body_sha256", "captured_bytes", "effective_url", "content_type", "download_bytes"} {
if value, exists := result.Result[key]; exists {
external.Metadata["controller_probe_"+key] = value
}
}
s.Graph.UpsertNode(external)
nodeIDs = append(nodeIDs, external.ID)
}
}
if s.Broker != nil {
s.Broker.Publish(model.Activity{Type: "controller.job.completed", Source: "agent", Phase: "controller", NodeIDs: nodeIDs, Message: "Docker-Controller-Job wurde abgeschlossen", Strength: .76, Metadata: map[string]any{"job_id": result.JobID, "agent_id": a.ID, "kind": job.Kind, "status": result.Status, "duration_ms": result.DurationMS, "error": result.Error, "result": result.Result}})
}
writeJSON(w, 200, map[string]any{"ok": true})
}
func asControllerString(v any) string {
return strings.TrimSpace(strings.TrimSpace(toControllerString(v)))
}
func toControllerString(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
b, _ := json.Marshal(v)
return string(b)
}
func controllerJobNodeIDs(job sourceagent.ControllerJob) []string {
var out []string
switch raw := job.Parameters["source_node_ids"].(type) {
case []string:
out = append(out, raw...)
case []any:
for _, v := range raw {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
}
return out
}