Files
glpi-ai-agent/internal/web/server.go
jbergner 9b3227348d
All checks were successful
release-tag / release-image (push) Successful in 1m33s
init
2026-07-27 17:32:35 +02:00

229 lines
7.8 KiB
Go

package web
import (
"crypto/subtle"
"embed"
"encoding/json"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/example/glpi-ai-agent/internal/config"
"github.com/example/glpi-ai-agent/internal/metrics"
"github.com/example/glpi-ai-agent/internal/queue"
"github.com/example/glpi-ai-agent/internal/state"
)
//go:embed templates/dashboard.html
var files embed.FS
type Server struct {
cfg config.Config
metrics *metrics.Metrics
state *state.Store
q *queue.Queue
tpl *template.Template
}
func New(cfg config.Config, m *metrics.Metrics, s *state.Store, q *queue.Queue) (*Server, error) {
t, err := template.ParseFS(files, "templates/dashboard.html")
if err != nil {
return nil, err
}
return &Server{cfg: cfg, metrics: m, state: s, q: q, tpl: t}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /readyz", s.ready)
mux.HandleFunc("GET /metrics", s.prom)
mux.Handle("GET /", s.auth(http.HandlerFunc(s.dashboard)))
mux.Handle("GET /api/status", s.auth(http.HandlerFunc(s.status)))
mux.Handle("GET /api/runs", s.auth(http.HandlerFunc(s.runs)))
mux.HandleFunc("POST /webhook/glpi", s.webhook)
return securityHeaders(requestLog(mux))
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"status":"ok"}`)
}
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
g, o := s.metrics.Health()
w.Header().Set("Content-Type", "application/json")
if !g || !o {
w.WriteHeader(http.StatusServiceUnavailable)
}
json.NewEncoder(w).Encode(map[string]any{"glpi": g, "ollama": o})
}
func (s *Server) prom(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
s.metrics.WritePrometheus(w)
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = s.tpl.ExecuteTemplate(w, "dashboard.html", map[string]any{"DryRun": s.cfg.DryRun, "AutoReply": s.cfg.AutoReply, "AutoCategory": s.cfg.AutoCategory, "CommunicationLanguage": s.cfg.CommunicationLanguage, "CommunicationStyle": s.cfg.CommunicationStyle})
}
func (s *Server) status(w http.ResponseWriter, r *http.Request) {
g, o := s.metrics.Health()
respondJSON(w, map[string]any{
"uptime_seconds": int(time.Since(s.metrics.Started).Seconds()), "dry_run": s.cfg.DryRun, "auto_reply": s.cfg.AutoReply, "auto_category": s.cfg.AutoCategory,
"processed": s.metrics.Processed.Load(), "skipped": s.metrics.Skipped.Load(), "errors": s.metrics.Errors.Load(), "category_changes": s.metrics.CategoryChanged.Load(), "replies": s.metrics.Replies.Load(), "queue_depth": s.q.Len(),
"glpi_ok": g, "ollama_ok": o, "knowledge_docs": s.metrics.KnowledgeDocs(), "last_poll": s.metrics.LastPoll(),
"communication_language": s.cfg.CommunicationLanguage, "communication_style": s.cfg.CommunicationStyle, "knowledge_allowed_sources": s.cfg.KnowledgeAllowedSources, "knowledge_auto_reply_sources": s.cfg.KnowledgeAutoReplySources,
"context_enabled": s.cfg.ContextEnabled, "context_fetches": s.metrics.ContextFetches.Load(), "context_errors": s.metrics.ContextErrors.Load(),
"change_calendar_enabled": s.cfg.ChangeCalendarEnabled, "major_incidents_enabled": s.cfg.MajorIncidentsEnabled, "user_device_context_enabled": s.cfg.UserDeviceContextEnabled,
"uptime_kuma_enabled": s.cfg.UptimeKumaEnabled, "uptime_kuma_mode": s.cfg.UptimeKumaMode, "uptime_kuma_status_pages": s.cfg.UptimeKumaStatusPages, "context_fail_closed": s.cfg.ContextBlockReplyOnError, "context_incident_block": s.cfg.ContextBlockReplyOnIncident,
})
}
func (s *Server) runs(w http.ResponseWriter, r *http.Request) {
limit := 50
if v := r.URL.Query().Get("limit"); v != "" {
if n, e := strconv.Atoi(v); e == nil && n > 0 && n <= 200 {
limit = n
}
}
respondJSON(w, s.state.Recent(limit))
}
var ticketRE = regexp.MustCompile(`(?i)/Ticket/(\d+)`)
func (s *Server) webhook(w http.ResponseWriter, r *http.Request) {
if s.cfg.WebhookSecret == "" {
http.Error(w, "webhook disabled", http.StatusNotFound)
return
}
got := r.Header.Get("X-Webhook-Secret")
if subtle.ConstantTimeCompare([]byte(got), []byte(s.cfg.WebhookSecret)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "bad request", 400)
return
}
id := extractTicketID(body)
if id <= 0 {
http.Error(w, "no ticket id found", 422)
return
}
s.metrics.WebhookEvents.Add(1)
if !s.q.Enqueue(id) {
w.WriteHeader(http.StatusAccepted)
return
}
s.metrics.QueueDepth.Store(int64(s.q.Len()))
w.WriteHeader(http.StatusAccepted)
}
func extractTicketID(body []byte) int64 {
var v any
if json.Unmarshal(body, &v) == nil {
if id := walkID(v); id > 0 {
return id
}
}
if m := ticketRE.FindSubmatch(body); len(m) == 2 {
id, _ := strconv.ParseInt(string(m[1]), 10, 64)
return id
}
return 0
}
func walkID(v any) int64 {
m, ok := v.(map[string]any)
if !ok {
return 0
}
for _, k := range []string{"ticket_id", "ticketId"} {
if n := num(m[k]); n > 0 {
return n
}
}
if typ, ok := m["itemtype"].(string); ok && strings.EqualFold(typ, "Ticket") {
if n := num(m["id"]); n > 0 {
return n
}
if n := num(m["items_id"]); n > 0 {
return n
}
}
// A nested object explicitly named "ticket" may legitimately only carry an id.
if child, ok := m["ticket"].(map[string]any); ok {
if n := num(child["id"]); n > 0 {
return n
}
if n := walkID(child); n > 0 {
return n
}
}
// Other generic wrapper objects are searched only for explicit ticket markers;
// their own generic "id" must never be mistaken for a ticket id.
for _, k := range []string{"item", "data", "object"} {
if child, ok := m[k]; ok {
if n := walkID(child); n > 0 {
return n
}
}
}
return 0
}
func num(v any) int64 {
switch x := v.(type) {
case float64:
return int64(x)
case string:
n, _ := strconv.ParseInt(x, 10, 64)
return n
}
return 0
}
func respondJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func (s *Server) auth(next http.Handler) http.Handler {
if s.cfg.WebAllowAnonymous {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(s.cfg.WebUsername)) != 1 || subtle.ConstantTimeCompare([]byte(p), []byte(s.cfg.WebPassword)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="GLPI AI Agent"`)
http.Error(w, "unauthorized", 401)
return
}
next.ServeHTTP(w, r)
})
}
func securityHeaders(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'")
next.ServeHTTP(w, r)
})
}
func requestLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
if r.URL.Path != "/healthz" {
slog.Debug("http request", "method", r.Method, "path", r.URL.Path, "duration", time.Since(start).String())
}
})
}
func Listen(addr string, h http.Handler) *http.Server {
return &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
}
func (s *Server) String() string { return fmt.Sprintf("web(%s)", s.cfg.HTTPAddr) }