All checks were successful
release-tag / release-image (push) Successful in 1m33s
229 lines
7.8 KiB
Go
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) }
|