package httpapi import ( "context" "crypto/subtle" "encoding/json" "errors" "html/template" "io" "io/fs" "net" "net/http" "net/url" "strconv" "strings" "time" "git.send.nrw/sendnrw/dockwatch/internal/audit" "git.send.nrw/sendnrw/dockwatch/internal/auth" "git.send.nrw/sendnrw/dockwatch/internal/buildinfo" "git.send.nrw/sendnrw/dockwatch/internal/composeedit" "git.send.nrw/sendnrw/dockwatch/internal/config" "git.send.nrw/sendnrw/dockwatch/internal/gitops" "git.send.nrw/sendnrw/dockwatch/internal/hostsecurity" "git.send.nrw/sendnrw/dockwatch/internal/monitor" "git.send.nrw/sendnrw/dockwatch/internal/nodes" "git.send.nrw/sendnrw/dockwatch/internal/notify" "git.send.nrw/sendnrw/dockwatch/internal/stacks" web "git.send.nrw/sendnrw/dockwatch/web" "github.com/gorilla/websocket" ) type Server struct { cfg config.Config auth *auth.Service stacks *stacks.Service nodes *nodes.Manager monitors *monitor.Service audit *audit.Service notify *notify.Service git *gitops.Service security *hostsecurity.Service } func New(c config.Config, a *auth.Service, ss *stacks.Service, n *nodes.Manager, m *monitor.Service, au *audit.Service, nt *notify.Service, gs *gitops.Service, hs *hostsecurity.Service) http.Handler { s := &Server{cfg: c, auth: a, stacks: ss, nodes: n, monitors: m, audit: au, notify: nt, git: gs, security: hs} mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]any{"ok": true, "mode": c.Mode, "build": buildinfo.Current()}) }) if c.Mode == config.ModeAgent { s.agent(mux) return securityHeaders(mux) } mux.HandleFunc("POST /hooks/git/{id}", s.gitWebhook) mux.HandleFunc("GET /status/{slug}", s.publicStatusPage) mux.HandleFunc("GET /public/api/status/{slug}", s.publicStatusJSON) mux.HandleFunc("GET /auth/login", a.Login) mux.HandleFunc("GET /auth/callback", func(w http.ResponseWriter, r *http.Request) { if e := a.Callback(w, r); e != nil { http.Error(w, e.Error(), 401) return } http.Redirect(w, r, "/", 302) }) mux.Handle("POST /auth/logout", a.Middleware(mutationOriginGuard(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { a.Logout(w, r) jsonOut(w, 200, map[string]bool{"ok": true}) })))) api := http.NewServeMux() api.HandleFunc("GET /api/me", func(w http.ResponseWriter, r *http.Request) { u, _ := auth.UserFrom(r.Context()); jsonOut(w, 200, u) }) api.HandleFunc("GET /api/system", func(w http.ResponseWriter, r *http.Request) { out := map[string]any{"mode": c.Mode, "build": buildinfo.Current()} if u, ok := auth.UserFrom(r.Context()); ok && u.Role == "admin" { out["secret_fingerprint"] = c.SecretFingerprint() } jsonOut(w, 200, out) }) api.HandleFunc("GET /api/monitors", s.listMonitors) api.HandleFunc("GET /api/monitors/{id}", s.getMonitor) api.Handle("POST /api/monitors", auth.RequireRole("operator", http.HandlerFunc(s.createMonitor))) api.Handle("PUT /api/monitors/{id}", auth.RequireRole("operator", http.HandlerFunc(s.updateMonitor))) api.Handle("POST /api/monitors/{id}/pause", auth.RequireRole("operator", http.HandlerFunc(s.pauseMonitor))) api.Handle("POST /api/monitors/{id}/resume", auth.RequireRole("operator", http.HandlerFunc(s.resumeMonitor))) api.Handle("POST /api/monitors/{id}/maintenance", auth.RequireRole("operator", http.HandlerFunc(s.maintenanceMonitor))) api.Handle("DELETE /api/monitors/{id}/maintenance", auth.RequireRole("operator", http.HandlerFunc(s.clearMaintenance))) api.Handle("DELETE /api/monitors/{id}", auth.RequireRole("operator", http.HandlerFunc(s.deleteMonitor))) api.Handle("POST /api/monitors/{id}/check", auth.RequireRole("operator", http.HandlerFunc(s.checkMonitorNow))) api.HandleFunc("GET /api/monitors/{id}/checks", s.checks) api.HandleFunc("GET /api/services", s.listServices) api.Handle("POST /api/services", auth.RequireRole("operator", http.HandlerFunc(s.createService))) api.Handle("PUT /api/services/{id}", auth.RequireRole("operator", http.HandlerFunc(s.updateService))) api.Handle("DELETE /api/services/{id}", auth.RequireRole("operator", http.HandlerFunc(s.deleteService))) api.HandleFunc("GET /api/status-pages", s.listStatusPages) api.Handle("POST /api/status-pages", auth.RequireRole("admin", http.HandlerFunc(s.createStatusPage))) api.Handle("PUT /api/status-pages/{id}", auth.RequireRole("admin", http.HandlerFunc(s.updateStatusPage))) api.Handle("DELETE /api/status-pages/{id}", auth.RequireRole("admin", http.HandlerFunc(s.deleteStatusPage))) api.HandleFunc("GET /api/docker/{kind}", s.dockerInventory) api.Handle("POST /api/docker/{kind}/actions/{action}", auth.RequireRole("operator", http.HandlerFunc(s.dockerAction))) api.Handle("GET /api/docker/{kind}/{id}/inspect", auth.RequireRole("operator", http.HandlerFunc(s.dockerInspect))) api.Handle("GET /api/docker/containers/{id}/identity", auth.RequireRole("operator", http.HandlerFunc(s.containerIdentity))) api.Handle("POST /api/docker/containers/{id}/bind-permissions/preview", auth.RequireRole("operator", http.HandlerFunc(s.bindPermissionPreview))) api.Handle("POST /api/host/bind-permissions/repair", auth.RequireRole("admin", http.HandlerFunc(s.repairBindPermissions))) api.Handle("POST /api/host/users", auth.RequireRole("admin", http.HandlerFunc(s.createHostUser))) api.HandleFunc("GET /api/stacks", s.listStacks) api.Handle("GET /api/stacks/{name}", auth.RequireRole("operator", http.HandlerFunc(s.getStack))) api.HandleFunc("POST /api/compose/parse", s.composeParse) api.Handle("POST /api/compose/patch", auth.RequireRole("operator", http.HandlerFunc(s.composePatch))) api.Handle("PUT /api/stacks/{name}", auth.RequireRole("operator", http.HandlerFunc(s.saveStack))) api.Handle("POST /api/stacks/{name}/actions/{action}", auth.RequireRole("operator", http.HandlerFunc(s.stackAction))) api.Handle("POST /api/stacks/{name}/exec", auth.RequireRole("operator", http.HandlerFunc(s.execStack))) api.Handle("GET /api/stacks/{name}/logs", auth.RequireRole("operator", http.HandlerFunc(s.logs))) api.Handle("DELETE /api/stacks/{name}", auth.RequireRole("operator", http.HandlerFunc(s.deleteStack))) api.HandleFunc("GET /api/stacks/{name}/graph", s.stackGraph) api.Handle("GET /api/stacks/{name}/bind-permissions", auth.RequireRole("operator", http.HandlerFunc(s.stackBindPermissions))) api.HandleFunc("GET /api/stacks/{name}/image-updates", s.stackImageUpdates) api.Handle("GET /api/stacks/{name}/terminal", auth.RequireRole("operator", http.HandlerFunc(s.stackTerminal))) api.Handle("GET /api/activity", auth.RequireRole("admin", http.HandlerFunc(s.activity))) api.Handle("GET /api/notifications", auth.RequireRole("admin", http.HandlerFunc(s.listNotifications))) api.Handle("POST /api/notifications", auth.RequireRole("admin", http.HandlerFunc(s.createNotification))) api.Handle("PUT /api/notifications/{id}", auth.RequireRole("admin", http.HandlerFunc(s.updateNotification))) api.Handle("DELETE /api/notifications/{id}", auth.RequireRole("admin", http.HandlerFunc(s.deleteNotification))) api.Handle("POST /api/notifications/{id}/test", auth.RequireRole("admin", http.HandlerFunc(s.testNotification))) api.HandleFunc("GET /api/git-sources", s.listGitSources) api.Handle("POST /api/git-sources", auth.RequireRole("operator", http.HandlerFunc(s.createGitSource))) api.Handle("PUT /api/git-sources/{id}", auth.RequireRole("operator", http.HandlerFunc(s.updateGitSource))) api.Handle("DELETE /api/git-sources/{id}", auth.RequireRole("operator", http.HandlerFunc(s.deleteGitSource))) api.Handle("POST /api/git-sources/{id}/sync", auth.RequireRole("operator", http.HandlerFunc(s.syncGitSource))) api.Handle("POST /api/git-sources/{id}/rotate-secret", auth.RequireRole("admin", http.HandlerFunc(s.rotateGitSecret))) api.HandleFunc("GET /api/nodes", s.listNodes) api.Handle("POST /api/nodes", auth.RequireRole("admin", http.HandlerFunc(s.createNode))) api.Handle("PUT /api/nodes/{id}", auth.RequireRole("admin", http.HandlerFunc(s.updateNode))) api.Handle("DELETE /api/nodes/{id}", auth.RequireRole("admin", http.HandlerFunc(s.deleteNode))) api.HandleFunc("GET /api/nodes/{id}/health", s.nodeHealth) api.Handle("GET /api/security/status", auth.RequireRole("admin", http.HandlerFunc(s.securityStatus))) api.Handle("GET /api/security/firewall", auth.RequireRole("admin", http.HandlerFunc(s.securityFirewall))) api.Handle("POST /api/security/firewall/preview", auth.RequireRole("admin", http.HandlerFunc(s.securityFirewallPreview))) api.Handle("POST /api/security/firewall/apply", auth.RequireRole("admin", http.HandlerFunc(s.securityFirewallApply))) api.Handle("POST /api/security/firewall/commit", auth.RequireRole("admin", http.HandlerFunc(s.securityFirewallCommit))) api.Handle("POST /api/security/firewall/rollback", auth.RequireRole("admin", http.HandlerFunc(s.securityFirewallRollback))) api.Handle("GET /api/security/fail2ban", auth.RequireRole("admin", http.HandlerFunc(s.securityFail2Ban))) api.Handle("PUT /api/security/fail2ban", auth.RequireRole("admin", http.HandlerFunc(s.securityApplyFail2Ban))) api.Handle("GET /api/security/auditd", auth.RequireRole("admin", http.HandlerFunc(s.securityAuditd))) api.Handle("PUT /api/security/auditd", auth.RequireRole("admin", http.HandlerFunc(s.securityApplyAuditd))) api.Handle("POST /api/security/components/{component}/install", auth.RequireRole("admin", http.HandlerFunc(s.securityInstall))) api.Handle("POST /api/security/components/{component}/actions/{action}", auth.RequireRole("admin", http.HandlerFunc(s.securityComponentAction))) api.Handle("GET /api/packages/updates", auth.RequireRole("admin", http.HandlerFunc(s.packageUpdates))) api.Handle("POST /api/packages/refresh", auth.RequireRole("admin", http.HandlerFunc(s.packageRefresh))) api.Handle("POST /api/packages/upgrade", auth.RequireRole("admin", http.HandlerFunc(s.packageUpgrade))) mux.Handle("/api/", a.Middleware(mutationOriginGuard(s.auditMiddleware(api)))) assets, _ := fs.Sub(web.FS, ".") f := http.FileServer(http.FS(assets)) staticNoStore := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") f.ServeHTTP(w, r) }) mux.Handle("GET /app.js", staticNoStore) mux.Handle("GET /styles.css", staticNoStore) mux.Handle("GET /{$}", staticNoStore) return securityHeaders(mux) } func (s *Server) agent(m *http.ServeMux) { a := http.NewServeMux() a.HandleFunc("GET /agent/v1/health", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]any{"ok": true, "mode": config.ModeAgent, "build": buildinfo.Current()}) }) a.HandleFunc("GET /agent/v1/docker/{kind}", s.localDockerInventory) a.HandleFunc("POST /agent/v1/docker/{kind}/actions/{action}", s.localDockerAction) a.HandleFunc("GET /agent/v1/docker/{kind}/{id}/inspect", s.localDockerInspect) a.HandleFunc("GET /agent/v1/docker/containers/{id}/identity", s.localContainerIdentity) a.HandleFunc("POST /agent/v1/docker/containers/{id}/bind-permissions/preview", s.localBindPermissionPreview) a.HandleFunc("POST /agent/v1/host/bind-permissions/repair", s.localRepairBindPermissions) a.HandleFunc("POST /agent/v1/host/users", s.localCreateHostUser) a.HandleFunc("GET /agent/v1/security/status", s.localSecurityStatus) a.HandleFunc("GET /agent/v1/security/firewall", s.localSecurityFirewall) a.HandleFunc("POST /agent/v1/security/firewall/preview", s.localSecurityFirewallPreview) a.HandleFunc("POST /agent/v1/security/firewall/apply", s.localSecurityFirewallApply) a.HandleFunc("POST /agent/v1/security/firewall/commit", s.localSecurityFirewallCommit) a.HandleFunc("POST /agent/v1/security/firewall/rollback", s.localSecurityFirewallRollback) a.HandleFunc("GET /agent/v1/security/fail2ban", s.localSecurityFail2Ban) a.HandleFunc("PUT /agent/v1/security/fail2ban", s.localSecurityApplyFail2Ban) a.HandleFunc("GET /agent/v1/security/auditd", s.localSecurityAuditd) a.HandleFunc("PUT /agent/v1/security/auditd", s.localSecurityApplyAuditd) a.HandleFunc("POST /agent/v1/security/components/{component}/install", s.localSecurityInstall) a.HandleFunc("POST /agent/v1/security/components/{component}/actions/{action}", s.localSecurityComponentAction) a.HandleFunc("GET /agent/v1/packages/updates", s.localPackageUpdates) a.HandleFunc("POST /agent/v1/packages/refresh", s.localPackageRefresh) a.HandleFunc("POST /agent/v1/packages/upgrade", s.localPackageUpgrade) a.HandleFunc("GET /agent/v1/stacks", s.localList) a.HandleFunc("GET /agent/v1/stacks/{name}", s.localGet) a.HandleFunc("PUT /agent/v1/stacks/{name}", s.localSave) a.HandleFunc("POST /agent/v1/stacks/{name}/actions/{action}", s.localAction) a.HandleFunc("GET /agent/v1/stacks/{name}/logs", s.localLogs) a.HandleFunc("POST /agent/v1/stacks/{name}/exec", s.localExec) a.HandleFunc("POST /agent/v1/git/sync", s.localGitSync) a.HandleFunc("DELETE /agent/v1/stacks/{name}", s.localDelete) a.HandleFunc("GET /agent/v1/stacks/{name}/graph", s.localGraph) a.HandleFunc("GET /agent/v1/stacks/{name}/bind-permissions", s.localStackBindPermissions) a.HandleFunc("GET /agent/v1/stacks/{name}/image-updates", s.localImageUpdates) a.HandleFunc("GET /agent/v1/stacks/{name}/terminal", s.localTerminal) a.HandleFunc("POST /agent/v1/probe", func(w http.ResponseWriter, r *http.Request) { var in monitor.Input if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, monitor.Probe(r.Context(), in)) }) m.Handle("/agent/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if len(got) != len(s.cfg.AgentToken) || subtle.ConstantTimeCompare([]byte(got), []byte(s.cfg.AgentToken)) != 1 { http.Error(w, "unauthorized", 401) return } a.ServeHTTP(w, r) })) } func (s *Server) composeParse(w http.ResponseWriter, r *http.Request) { var in struct { Compose string `json:"compose"` } if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, e := composeedit.Parse(in.Compose) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) composePatch(w http.ResponseWriter, r *http.Request) { var in struct { Compose string `json:"compose"` Path []string `json:"path"` Value any `json:"value"` Delete bool `json:"delete"` } if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } out, e := composeedit.Apply(in.Compose, composeedit.Patch{Path: in.Path, Value: in.Value, Delete: in.Delete}) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]any{"compose": out}) } func canReadSensitiveConfig(r *http.Request) bool { u, ok := auth.UserFrom(r.Context()) return ok && (u.Role == "operator" || u.Role == "admin") } func sanitizeMonitorForViewer(m *monitor.Monitor) { m.HeadersJSON = "{}" m.Body = "" m.Keyword = "" } func (s *Server) listMonitors(w http.ResponseWriter, r *http.Request) { v, e := s.monitors.List(r.Context()) if e != nil { http.Error(w, e.Error(), 500) return } if !canReadSensitiveConfig(r) { for i := range v { sanitizeMonitorForViewer(&v[i]) } } jsonOut(w, 200, v) } func (s *Server) getMonitor(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } v, e := s.monitors.Get(r.Context(), id) if e != nil { http.Error(w, e.Error(), 404) return } if !canReadSensitiveConfig(r) { sanitizeMonitorForViewer(&v) } jsonOut(w, 200, v) } func (s *Server) updateMonitor(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } var in monitor.Input if e = read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, e := s.monitors.Update(r.Context(), id, in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) pauseMonitor(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } if e = s.monitors.SetPaused(r.Context(), id, true); e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Server) resumeMonitor(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } if e = s.monitors.SetPaused(r.Context(), id, false); e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Server) maintenanceMonitor(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } var in monitor.MaintenanceInput if e = read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } if e = s.monitors.SetMaintenance(r.Context(), id, in); e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Server) clearMaintenance(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } if e = s.monitors.ClearMaintenance(r.Context(), id); e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Server) createMonitor(w http.ResponseWriter, r *http.Request) { var in monitor.Input if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } u, _ := auth.UserFrom(r.Context()) v, e := s.monitors.Create(r.Context(), in, u.ID) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 201, v) } func (s *Server) deleteMonitor(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } if e = s.monitors.Delete(r.Context(), id); e != nil { http.Error(w, e.Error(), 500) return } w.WriteHeader(204) } func (s *Server) checks(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } lim, _ := strconv.Atoi(r.URL.Query().Get("limit")) v, e := s.monitors.Checks(r.Context(), id, lim) if e != nil { http.Error(w, e.Error(), 500) return } jsonOut(w, 200, v) } func (s *Server) checkMonitorNow(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } c, e := s.monitors.CheckNow(r.Context(), id) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, c) } func (s *Server) listServices(w http.ResponseWriter, r *http.Request) { v, e := s.monitors.ListGroups(r.Context()) if e != nil { http.Error(w, e.Error(), 500) return } if !canReadSensitiveConfig(r) { for gi := range v { for mi := range v[gi].Monitors { sanitizeMonitorForViewer(&v[gi].Monitors[mi]) } } } jsonOut(w, 200, v) } func (s *Server) createService(w http.ResponseWriter, r *http.Request) { var in monitor.ProbeGroupInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, e := s.monitors.CreateGroup(r.Context(), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 201, v) } func (s *Server) updateService(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } var in monitor.ProbeGroupInput if e = read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, e := s.monitors.UpdateGroup(r.Context(), id, in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) deleteService(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } if e = s.monitors.DeleteGroup(r.Context(), id); e != nil { http.Error(w, e.Error(), 500) return } w.WriteHeader(204) } func (s *Server) listStatusPages(w http.ResponseWriter, r *http.Request) { v, e := s.monitors.ListStatusPages(r.Context()) if e != nil { http.Error(w, e.Error(), 500) return } jsonOut(w, 200, v) } func (s *Server) createStatusPage(w http.ResponseWriter, r *http.Request) { var in monitor.StatusPageInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, e := s.monitors.CreateStatusPage(r.Context(), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 201, v) } func (s *Server) updateStatusPage(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } var in monitor.StatusPageInput if e = read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, e := s.monitors.UpdateStatusPage(r.Context(), id, in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) deleteStatusPage(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } if e = s.monitors.DeleteStatusPage(r.Context(), id); e != nil { http.Error(w, e.Error(), 500) return } w.WriteHeader(204) } type publicProbeJSON struct { Name string `json:"name"` Status string `json:"status"` Uptime24h float64 `json:"uptime_24h"` LastLatencyMS int64 `json:"last_latency_ms,omitempty"` LastCheckedAt *int64 `json:"last_checked_at,omitempty"` } type publicServiceJSON struct { Name string `json:"name"` Description string `json:"description,omitempty"` Status string `json:"status"` Probes []publicProbeJSON `json:"probes"` } type publicStatusJSONResponse struct { Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description,omitempty"` Status string `json:"status"` Services []publicServiceJSON `json:"services"` } func (s *Server) publicStatusJSON(w http.ResponseWriter, r *http.Request) { v, e := s.monitors.PublicStatusPage(r.Context(), r.PathValue("slug")) if e != nil { http.Error(w, "Statusseite nicht gefunden", 404) return } out := publicStatusJSONResponse{Name: v.Name, Slug: v.Slug, Description: v.Description, Status: v.Status, Services: []publicServiceJSON{}} for _, service := range v.Services { ps := publicServiceJSON{Name: service.Name, Description: service.Description, Status: service.Status, Probes: []publicProbeJSON{}} for _, probe := range service.Monitors { ps.Probes = append(ps.Probes, publicProbeJSON{Name: probe.Name, Status: probe.Status, Uptime24h: probe.Uptime24h, LastLatencyMS: probe.LastLatencyMS, LastCheckedAt: probe.LastCheckedAt}) } out.Services = append(out.Services, ps) } jsonOut(w, 200, out) } var publicStatusTemplate = template.Must(template.New("status").Funcs(template.FuncMap{"upper": strings.ToUpper, "statusDE": func(v string) string { switch strings.ToLower(v) { case "up": return "Verfügbar" case "down": return "Ausgefallen" case "maintenance": return "Wartung" case "paused": return "Pausiert" default: return "Unbekannt" } }}).Parse(`{{.Name}} · Status
Dockwatch · Öffentlicher Status

{{.Name}}

{{.Description}}
{{if eq .Status "up"}}Alle veröffentlichten Dienste sind verfügbar{{else if eq .Status "down"}}Dienststörung erkannt{{else if eq .Status "maintenance"}}Wartung läuft{{else}}Status wird ermittelt{{end}}
Diese Seite wird automatisch alle 30 Sekunden aktualisiert.
{{statusDE .Status}}
{{range .Services}}

{{.Name}}

{{.Description}}
{{statusDE .Status}}
{{range .Monitors}}
{{.Name}}{{printf "%.2f" .Uptime24h}}% / 24h{{if .LastLatencyMS}} · {{.LastLatencyMS}} ms{{end}}
{{end}}
{{else}}
Keine öffentlichen Dienste konfiguriert.
{{end}}
`)) func (s *Server) publicStatusPage(w http.ResponseWriter, r *http.Request) { v, e := s.monitors.PublicStatusPage(r.Context(), r.PathValue("slug")) if e != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _ = publicStatusTemplate.Execute(w, v) } func nodeID(r *http.Request) int64 { id, _ := strconv.ParseInt(r.URL.Query().Get("node_id"), 10, 64) return id } func (s *Server) relay(w http.ResponseWriter, r *http.Request, id int64, method, path string, body any) { b, status, e := s.nodes.Do(r.Context(), id, method, path, body) if e != nil { if status == 0 { status = 502 } http.Error(w, e.Error(), status) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(b) } func (s *Server) relayWithTimeout(w http.ResponseWriter, r *http.Request, id int64, method, path string, body any, timeout time.Duration) { b, status, e := s.nodes.DoWithTimeout(r.Context(), id, method, path, body, timeout) if e != nil { if status == 0 { status = 502 } http.Error(w, e.Error(), status) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(b) } func (s *Server) dockerInventory(w http.ResponseWriter, r *http.Request) { kind := r.PathValue("kind") if id := nodeID(r); id > 0 { s.relay(w, r, id, "GET", "/agent/v1/docker/"+kind, nil) return } s.localDockerInventory(w, r) } func (s *Server) localDockerInventory(w http.ResponseWriter, r *http.Request) { v, e := s.stacks.DockerInventory(r.Context(), r.PathValue("kind")) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) dockerAction(w http.ResponseWriter, r *http.Request) { var in stacks.DockerActionInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } kind, action := r.PathValue("kind"), r.PathValue("action") if id := nodeID(r); id > 0 { s.relay(w, r, id, "POST", "/agent/v1/docker/"+kind+"/actions/"+action, in) return } out, e := s.stacks.DockerAction(r.Context(), kind, action, in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]any{"ok": true, "output": out}) } func (s *Server) localDockerAction(w http.ResponseWriter, r *http.Request) { var in stacks.DockerActionInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } out, e := s.stacks.DockerAction(r.Context(), r.PathValue("kind"), r.PathValue("action"), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]any{"ok": true, "output": out}) } func (s *Server) dockerInspect(w http.ResponseWriter, r *http.Request) { kind := r.PathValue("kind") if id := nodeID(r); id > 0 { s.relay(w, r, id, "GET", "/agent/v1/docker/"+url.PathEscape(kind)+"/"+url.PathEscape(r.PathValue("id"))+"/inspect", nil) return } s.localDockerInspect(w, r) } func (s *Server) localDockerInspect(w http.ResponseWriter, r *http.Request) { v, e := s.stacks.DockerInspect(r.Context(), r.PathValue("kind"), r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) containerIdentity(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if node := nodeID(r); node > 0 { s.relay(w, r, node, "GET", "/agent/v1/docker/containers/"+url.PathEscape(id)+"/identity", nil) return } s.localContainerIdentity(w, r) } func (s *Server) localContainerIdentity(w http.ResponseWriter, r *http.Request) { v, e := s.stacks.ContainerIdentity(r.Context(), r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) bindPermissionPreview(w http.ResponseWriter, r *http.Request) { var in stacks.BindPermissionPreviewInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } in.ContainerID = r.PathValue("id") if node := nodeID(r); node > 0 { s.relay(w, r, node, "POST", "/agent/v1/docker/containers/"+url.PathEscape(in.ContainerID)+"/bind-permissions/preview", in) return } s.bindPermissionPreviewLocal(w, r, in) } func (s *Server) localBindPermissionPreview(w http.ResponseWriter, r *http.Request) { var in stacks.BindPermissionPreviewInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } in.ContainerID = r.PathValue("id") s.bindPermissionPreviewLocal(w, r, in) } func (s *Server) bindPermissionPreviewLocal(w http.ResponseWriter, r *http.Request, in stacks.BindPermissionPreviewInput) { v, e := s.stacks.BindPermissionPreview(r.Context(), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) repairBindPermissions(w http.ResponseWriter, r *http.Request) { var in stacks.RepairBindPermissionsInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } if node := nodeID(r); node > 0 { s.relay(w, r, node, "POST", "/agent/v1/host/bind-permissions/repair", in) return } s.repairBindPermissionsLocal(w, r, in) } func (s *Server) localRepairBindPermissions(w http.ResponseWriter, r *http.Request) { var in stacks.RepairBindPermissionsInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } s.repairBindPermissionsLocal(w, r, in) } func (s *Server) repairBindPermissionsLocal(w http.ResponseWriter, r *http.Request, in stacks.RepairBindPermissionsInput) { v, e := s.stacks.RepairBindPermissions(r.Context(), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) stackBindPermissions(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if node := nodeID(r); node > 0 { s.relay(w, r, node, "GET", "/agent/v1/stacks/"+url.PathEscape(name)+"/bind-permissions", nil) return } s.localStackBindPermissions(w, r) } func (s *Server) localStackBindPermissions(w http.ResponseWriter, r *http.Request) { v, e := s.stacks.StackBindPermissions(r.Context(), r.PathValue("name")) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) createHostUser(w http.ResponseWriter, r *http.Request) { var in stacks.CreateHostUserInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } if node := nodeID(r); node > 0 { s.relay(w, r, node, "POST", "/agent/v1/host/users", in) return } s.createHostUserLocal(w, r, in) } func (s *Server) localCreateHostUser(w http.ResponseWriter, r *http.Request) { var in stacks.CreateHostUserInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } s.createHostUserLocal(w, r, in) } func (s *Server) createHostUserLocal(w http.ResponseWriter, r *http.Request, in stacks.CreateHostUserInput) { v, e := s.stacks.CreateHostUser(r.Context(), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) listStacks(w http.ResponseWriter, r *http.Request) { if id := nodeID(r); id > 0 { s.relay(w, r, id, "GET", "/agent/v1/stacks", nil) return } s.localList(w, r) } func (s *Server) getStack(w http.ResponseWriter, r *http.Request) { if id := nodeID(r); id > 0 { s.relay(w, r, id, "GET", "/agent/v1/stacks/"+r.PathValue("name"), nil) return } s.localGet(w, r) } func (s *Server) saveStack(w http.ResponseWriter, r *http.Request) { var b stacks.SaveInput if e := read(r, &b); e != nil { http.Error(w, e.Error(), 400) return } if id := nodeID(r); id > 0 { s.relay(w, r, id, "PUT", "/agent/v1/stacks/"+r.PathValue("name"), b) return } if e := s.stacks.Save(r.Context(), r.PathValue("name"), b); e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) { p := "/agent/v1/stacks/" + r.PathValue("name") + "/actions/" + r.PathValue("action") if id := nodeID(r); id > 0 { s.relay(w, r, id, "POST", p, nil) return } s.localAction(w, r) } func (s *Server) logs(w http.ResponseWriter, r *http.Request) { path := "/agent/v1/stacks/" + r.PathValue("name") + "/logs" q := r.URL.RawQuery if q != "" { path += "?" + q } if id := nodeID(r); id > 0 { if r.URL.Query().Get("live") == "true" { if e := s.nodes.Stream(r.Context(), id, "GET", path, nil, w); e != nil { http.Error(w, e.Error(), 502) } return } s.relay(w, r, id, "GET", path, nil) return } s.localLogs(w, r) } func (s *Server) deleteStack(w http.ResponseWriter, r *http.Request) { if id := nodeID(r); id > 0 { q := url.Values{} q.Set("down", r.URL.Query().Get("down")) q.Set("purge", r.URL.Query().Get("purge")) s.relay(w, r, id, "DELETE", "/agent/v1/stacks/"+url.PathEscape(r.PathValue("name"))+"?"+q.Encode(), nil) return } s.localDelete(w, r) } func (s *Server) execStack(w http.ResponseWriter, r *http.Request) { var in stacks.ExecInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } p := "/agent/v1/stacks/" + r.PathValue("name") + "/exec" if id := nodeID(r); id > 0 { s.relay(w, r, id, "POST", p, in) return } v, e := s.stacks.Exec(r.Context(), r.PathValue("name"), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]string{"output": v}) } func (s *Server) localList(w http.ResponseWriter, r *http.Request) { v, e := s.stacks.List(r.Context()) if e != nil { http.Error(w, e.Error(), 500) return } jsonOut(w, 200, v) } func (s *Server) localGet(w http.ResponseWriter, r *http.Request) { v, e := s.stacks.Get(r.Context(), r.PathValue("name")) if e != nil { http.Error(w, e.Error(), 404) return } jsonOut(w, 200, v) } func (s *Server) localSave(w http.ResponseWriter, r *http.Request) { var b stacks.SaveInput if e := read(r, &b); e != nil { http.Error(w, e.Error(), 400) return } if e := s.stacks.Save(r.Context(), r.PathValue("name"), b); e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Server) localAction(w http.ResponseWriter, r *http.Request) { v, e := s.stacks.Action(r.Context(), r.PathValue("name"), r.PathValue("action")) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]string{"output": v}) } func (s *Server) localLogs(w http.ResponseWriter, r *http.Request) { tail, _ := strconv.Atoi(r.URL.Query().Get("tail")) if r.URL.Query().Get("live") == "true" { if e := s.stacks.StreamLogs(r.Context(), r.PathValue("name"), tail, w); e != nil { http.Error(w, e.Error(), 400) } return } v, e := s.stacks.Logs(r.Context(), r.PathValue("name"), tail) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]string{"output": v}) } func (s *Server) localExec(w http.ResponseWriter, r *http.Request) { var in stacks.ExecInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, e := s.stacks.Exec(r.Context(), r.PathValue("name"), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]string{"output": v}) } func (s *Server) localDelete(w http.ResponseWriter, r *http.Request) { if e := s.stacks.Delete(r.Context(), r.PathValue("name"), r.URL.Query().Get("down") == "true", r.URL.Query().Get("purge") == "true"); e != nil { http.Error(w, e.Error(), 500) return } w.WriteHeader(204) } func (s *Server) listNodes(w http.ResponseWriter, r *http.Request) { v, e := s.nodes.List(r.Context()) if e != nil { http.Error(w, e.Error(), 500) return } jsonOut(w, 200, v) } func (s *Server) createNode(w http.ResponseWriter, r *http.Request) { var in struct { Name string `json:"name"` BaseURL string `json:"base_url"` Token string `json:"token"` } if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, e := s.nodes.Create(r.Context(), in.Name, in.BaseURL, in.Token) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 201, v) } func (s *Server) updateNode(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } var in struct { Name string `json:"name"` BaseURL string `json:"base_url"` Token string `json:"token"` Enabled *bool `json:"enabled"` } if e = read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, e := s.nodes.Update(r.Context(), id, in.Name, in.BaseURL, in.Token, in.Enabled) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) deleteNode(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } if e = s.nodes.Delete(r.Context(), id); e != nil { http.Error(w, e.Error(), 500) return } w.WriteHeader(204) } func (s *Server) nodeHealth(w http.ResponseWriter, r *http.Request) { id, e := monitor.ParseID(r.PathValue("id")) if e != nil { http.Error(w, e.Error(), 400) return } s.relay(w, r, id, "GET", "/agent/v1/health", nil) } func (s *Server) localGitSync(w http.ResponseWriter, r *http.Request) { var in gitops.Input if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } commit, e := s.git.SyncTransient(r.Context(), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]string{"commit": commit}) } func read(r *http.Request, v any) error { defer r.Body.Close() d := json.NewDecoder(io.LimitReader(r.Body, 16<<20)) d.DisallowUnknownFields() if err := d.Decode(v); err != nil { return err } var extra any if err := d.Decode(&extra); !errors.Is(err, io.EOF) { return errors.New("request body must contain exactly one JSON value") } return nil } func jsonOut(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } 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("Permissions-Policy", "camera=(), microphone=(), geolocation=()") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") next.ServeHTTP(w, r) }) } func mutationOriginGuard(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { next.ServeHTTP(w, r) return } if strings.EqualFold(r.Header.Get("Sec-Fetch-Site"), "cross-site") { http.Error(w, "cross-site request rejected", http.StatusForbidden) return } if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" { u, err := url.Parse(origin) if err != nil || !strings.EqualFold(u.Host, r.Host) { http.Error(w, "origin rejected", http.StatusForbidden) return } } next.ServeHTTP(w, r) }) } type statusRecorder struct { http.ResponseWriter status int } func (w *statusRecorder) WriteHeader(code int) { w.status = code; w.ResponseWriter.WriteHeader(code) } func (w *statusRecorder) Write(b []byte) (int, error) { if w.status == 0 { w.status = 200 } return w.ResponseWriter.Write(b) } func (s *Server) auditMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet || r.Method == http.MethodHead || strings.HasSuffix(r.URL.Path, "/terminal") { next.ServeHTTP(w, r) return } rec := &statusRecorder{ResponseWriter: w} next.ServeHTTP(rec, r) u, _ := auth.UserFrom(r.Context()) uid := u.ID actor := u.Name if actor == "" { actor = u.Email } if actor == "" { actor = "user" } ip := r.RemoteAddr if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { ip = host } status := rec.status if status == 0 { status = 200 } _ = s.audit.Log(contextBg(), audit.Entry{UserID: &uid, Actor: actor, Action: r.Method + " " + r.URL.Path, Resource: r.URL.Query().Get("node_id"), Detail: map[string]any{"query": r.URL.RawQuery}, IP: ip, UserAgent: r.UserAgent(), Status: status}) }) } func contextBg() context.Context { return context.Background() } func (s *Server) activity(w http.ResponseWriter, r *http.Request) { lim, _ := strconv.Atoi(r.URL.Query().Get("limit")) off, _ := strconv.Atoi(r.URL.Query().Get("offset")) v, e := s.audit.List(r.Context(), lim, off, r.URL.Query().Get("action")) if e != nil { http.Error(w, e.Error(), 500) return } jsonOut(w, 200, v) } func parseIntID(r *http.Request) (int64, error) { return strconv.ParseInt(r.PathValue("id"), 10, 64) } func (s *Server) listNotifications(w http.ResponseWriter, r *http.Request) { v, e := s.notify.List(r.Context()) if e != nil { http.Error(w, e.Error(), 500) return } jsonOut(w, 200, v) } func (s *Server) createNotification(w http.ResponseWriter, r *http.Request) { var in notify.Input if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, e := s.notify.Create(r.Context(), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 201, v) } func (s *Server) updateNotification(w http.ResponseWriter, r *http.Request) { id, e := parseIntID(r) if e != nil { http.Error(w, "invalid id", 400) return } var in notify.Input if e = read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, e := s.notify.Update(r.Context(), id, in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) deleteNotification(w http.ResponseWriter, r *http.Request) { id, e := parseIntID(r) if e != nil { http.Error(w, "invalid id", 400) return } if e = s.notify.Delete(r.Context(), id); e != nil { http.Error(w, e.Error(), 500) return } w.WriteHeader(204) } func (s *Server) testNotification(w http.ResponseWriter, r *http.Request) { id, e := parseIntID(r) if e != nil { http.Error(w, "invalid id", 400) return } if e = s.notify.Test(r.Context(), id); e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]bool{"ok": true}) } func (s *Server) listGitSources(w http.ResponseWriter, r *http.Request) { v, e := s.git.List(r.Context()) if e != nil { http.Error(w, e.Error(), 500) return } jsonOut(w, 200, v) } func (s *Server) createGitSource(w http.ResponseWriter, r *http.Request) { var in gitops.Input if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, secret, e := s.git.Create(r.Context(), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 201, map[string]any{"source": v, "webhook_secret": secret, "webhook_url": strings.TrimRight(s.cfg.BaseURL, "/") + "/hooks/git/" + strconv.FormatInt(v.ID, 10)}) } func (s *Server) updateGitSource(w http.ResponseWriter, r *http.Request) { id, e := parseIntID(r) if e != nil { http.Error(w, "invalid id", 400) return } var in gitops.Input if e = read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } v, e := s.git.Update(r.Context(), id, in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) deleteGitSource(w http.ResponseWriter, r *http.Request) { id, e := parseIntID(r) if e != nil { http.Error(w, "invalid id", 400) return } if e = s.git.Delete(r.Context(), id); e != nil { http.Error(w, e.Error(), 500) return } w.WriteHeader(204) } func (s *Server) syncGitSource(w http.ResponseWriter, r *http.Request) { id, e := parseIntID(r) if e != nil { http.Error(w, "invalid id", 400) return } v, e := s.git.Sync(r.Context(), id) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) rotateGitSecret(w http.ResponseWriter, r *http.Request) { id, e := parseIntID(r) if e != nil { http.Error(w, "invalid id", 400) return } secret, e := s.git.RotateSecret(r.Context(), id) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]string{"webhook_secret": secret}) } func (s *Server) gitWebhook(w http.ResponseWriter, r *http.Request) { id, e := parseIntID(r) if e != nil { http.Error(w, "invalid id", 400) return } body, e := io.ReadAll(io.LimitReader(r.Body, 2<<20)) if e != nil { http.Error(w, e.Error(), 400) return } sig := r.Header.Get("X-Hub-Signature-256") tok := r.Header.Get("X-Gitlab-Token") if tok == "" { tok = r.Header.Get("X-Webhook-Token") } if e = s.git.VerifyWebhook(r.Context(), id, body, sig, tok); e != nil { http.Error(w, "unauthorized webhook", 401) return } v, e := s.git.Sync(r.Context(), id) status := 200 if e != nil { status = 400 } detail := map[string]any{"git_source_id": id} if e != nil { detail["error"] = e.Error() } else { detail["stack"] = v.StackName detail["commit"] = v.LastCommit } _ = s.audit.Log(r.Context(), audit.Entry{Actor: "git-webhook", Action: "git.sync", Resource: strconv.FormatInt(id, 10), Detail: detail, IP: r.RemoteAddr, UserAgent: r.UserAgent(), Status: status}) if e != nil { http.Error(w, e.Error(), status) return } jsonOut(w, 200, map[string]any{"ok": true, "source": v}) } func (s *Server) stackGraph(w http.ResponseWriter, r *http.Request) { if id := nodeID(r); id > 0 { s.relay(w, r, id, "GET", "/agent/v1/stacks/"+url.PathEscape(r.PathValue("name"))+"/graph", nil) return } s.localGraph(w, r) } func (s *Server) localGraph(w http.ResponseWriter, r *http.Request) { v, e := s.stacks.Graph(r.Context(), r.PathValue("name")) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) stackImageUpdates(w http.ResponseWriter, r *http.Request) { if id := nodeID(r); id > 0 { s.relay(w, r, id, "GET", "/agent/v1/stacks/"+url.PathEscape(r.PathValue("name"))+"/image-updates", nil) return } s.localImageUpdates(w, r) } func (s *Server) localImageUpdates(w http.ResponseWriter, r *http.Request) { v, e := s.stacks.ImageUpdates(r.Context(), r.PathValue("name")) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } var wsUpgrader = websocket.Upgrader{ReadBufferSize: 4096, WriteBufferSize: 16384, CheckOrigin: func(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { return true } u, e := url.Parse(origin) return e == nil && strings.EqualFold(u.Host, r.Host) }} func (s *Server) stackTerminal(w http.ResponseWriter, r *http.Request) { u, _ := auth.UserFrom(r.Context()) actor := u.Name if actor == "" { actor = u.Email } uid := u.ID _ = s.audit.Log(r.Context(), audit.Entry{UserID: &uid, Actor: actor, Action: "terminal.open", Resource: r.PathValue("name"), Detail: map[string]any{"node_id": nodeID(r), "service": r.URL.Query().Get("service")}, Status: 101}) if id := nodeID(r); id > 0 { s.proxyTerminal(w, r, id) return } s.localTerminal(w, r) } func (s *Server) localTerminal(w http.ResponseWriter, r *http.Request) { ws, e := wsUpgrader.Upgrade(w, r, nil) if e != nil { return } defer ws.Close() service := r.URL.Query().Get("service") shell := r.URL.Query().Get("shell") _ = s.stacks.Terminal(r.Context(), r.PathValue("name"), service, shell, ws) } func (s *Server) proxyTerminal(w http.ResponseWriter, r *http.Request, id int64) { client, e := wsUpgrader.Upgrade(w, r, nil) if e != nil { return } defer client.Close() q := url.Values{} q.Set("service", r.URL.Query().Get("service")) q.Set("shell", r.URL.Query().Get("shell")) path := "/agent/v1/stacks/" + url.PathEscape(r.PathValue("name")) + "/terminal?" + q.Encode() agent, resp, e := s.nodes.DialWebSocket(r.Context(), id, path) if e != nil { msg := e.Error() if resp != nil { msg = resp.Status } _ = client.WriteJSON(stacks.TerminalMessage{Type: "error", Data: msg}) return } defer agent.Close() done := make(chan error, 2) copyWS := func(dst, src *websocket.Conn) { for { typ, b, e := src.ReadMessage() if e != nil { done <- e return } if e = dst.WriteMessage(typ, b); e != nil { done <- e return } } } go copyWS(agent, client) go copyWS(client, agent) select { case <-r.Context().Done(): case <-done: } } func (s *Server) packageUpdates(w http.ResponseWriter, r *http.Request) { if id := nodeID(r); id > 0 { s.relayWithTimeout(w, r, id, http.MethodGet, "/agent/v1/packages/updates", nil, 2*time.Minute) return } s.localPackageUpdates(w, r) } func (s *Server) localPackageUpdates(w http.ResponseWriter, r *http.Request) { if s.security == nil { http.Error(w, "host package service unavailable", http.StatusServiceUnavailable) return } v, e := s.security.PackageUpdates(r.Context()) if e != nil { http.Error(w, e.Error(), http.StatusBadRequest) return } jsonOut(w, http.StatusOK, v) } func (s *Server) packageRefresh(w http.ResponseWriter, r *http.Request) { if id := nodeID(r); id > 0 { s.relayWithTimeout(w, r, id, http.MethodPost, "/agent/v1/packages/refresh", map[string]any{}, 12*time.Minute) return } s.localPackageRefresh(w, r) } func (s *Server) localPackageRefresh(w http.ResponseWriter, r *http.Request) { if s.security == nil { http.Error(w, "host package service unavailable", http.StatusServiceUnavailable) return } v, e := s.security.RefreshPackageMetadata(r.Context()) if e != nil { http.Error(w, e.Error(), http.StatusBadRequest) return } jsonOut(w, http.StatusOK, v) } func (s *Server) packageUpgrade(w http.ResponseWriter, r *http.Request) { var in hostsecurity.PackageUpgradeInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), http.StatusBadRequest) return } if id := nodeID(r); id > 0 { s.relayWithTimeout(w, r, id, http.MethodPost, "/agent/v1/packages/upgrade", in, 50*time.Minute) return } s.packageUpgradeLocal(w, r, in) } func (s *Server) localPackageUpgrade(w http.ResponseWriter, r *http.Request) { var in hostsecurity.PackageUpgradeInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), http.StatusBadRequest) return } s.packageUpgradeLocal(w, r, in) } func (s *Server) packageUpgradeLocal(w http.ResponseWriter, r *http.Request, in hostsecurity.PackageUpgradeInput) { if s.security == nil { http.Error(w, "host package service unavailable", http.StatusServiceUnavailable) return } v, e := s.security.UpgradePackages(r.Context(), in) if e != nil { http.Error(w, e.Error(), http.StatusBadRequest) return } jsonOut(w, http.StatusOK, v) } func (s *Server) securityStatus(w http.ResponseWriter, r *http.Request) { if id := nodeID(r); id > 0 { s.relay(w, r, id, http.MethodGet, "/agent/v1/security/status", nil) return } s.localSecurityStatus(w, r) } func (s *Server) localSecurityStatus(w http.ResponseWriter, r *http.Request) { if s.security == nil { http.Error(w, "host security service unavailable", http.StatusServiceUnavailable) return } jsonOut(w, http.StatusOK, s.security.Status(r.Context())) } func (s *Server) securityFirewall(w http.ResponseWriter, r *http.Request) { if id := nodeID(r); id > 0 { s.relay(w, r, id, http.MethodGet, "/agent/v1/security/firewall", nil) return } s.localSecurityFirewall(w, r) } func (s *Server) localSecurityFirewall(w http.ResponseWriter, r *http.Request) { if s.security == nil { http.Error(w, "host security service unavailable", http.StatusServiceUnavailable) return } jsonOut(w, http.StatusOK, s.security.FirewallPolicy()) } func (s *Server) securityFirewallPreview(w http.ResponseWriter, r *http.Request) { var in hostsecurity.FirewallPolicy if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } if id := nodeID(r); id > 0 { s.relay(w, r, id, http.MethodPost, "/agent/v1/security/firewall/preview", in) return } s.securityFirewallPreviewLocal(w, r, in) } func (s *Server) localSecurityFirewallPreview(w http.ResponseWriter, r *http.Request) { var in hostsecurity.FirewallPolicy if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } s.securityFirewallPreviewLocal(w, r, in) } func (s *Server) securityFirewallPreviewLocal(w http.ResponseWriter, r *http.Request, in hostsecurity.FirewallPolicy) { if s.security == nil { http.Error(w, "host security service unavailable", 503) return } v, e := s.security.PreviewFirewall(r.Context(), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) securityFirewallApply(w http.ResponseWriter, r *http.Request) { var in struct { Policy hostsecurity.FirewallPolicy `json:"policy"` RollbackSeconds int `json:"rollback_seconds"` } if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } if id := nodeID(r); id > 0 { s.relay(w, r, id, http.MethodPost, "/agent/v1/security/firewall/apply", in) return } s.securityFirewallApplyLocal(w, r, in.Policy, in.RollbackSeconds) } func (s *Server) localSecurityFirewallApply(w http.ResponseWriter, r *http.Request) { var in struct { Policy hostsecurity.FirewallPolicy `json:"policy"` RollbackSeconds int `json:"rollback_seconds"` } if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } s.securityFirewallApplyLocal(w, r, in.Policy, in.RollbackSeconds) } func (s *Server) securityFirewallApplyLocal(w http.ResponseWriter, r *http.Request, p hostsecurity.FirewallPolicy, seconds int) { if s.security == nil { http.Error(w, "host security service unavailable", 503) return } v, e := s.security.ApplyFirewall(r.Context(), p, seconds) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) securityFirewallCommit(w http.ResponseWriter, r *http.Request) { var in struct { ChangeID string `json:"change_id"` } if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } if id := nodeID(r); id > 0 { s.relay(w, r, id, http.MethodPost, "/agent/v1/security/firewall/commit", in) return } s.securityFirewallCommitLocal(w, r, in.ChangeID) } func (s *Server) localSecurityFirewallCommit(w http.ResponseWriter, r *http.Request) { var in struct { ChangeID string `json:"change_id"` } if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } s.securityFirewallCommitLocal(w, r, in.ChangeID) } func (s *Server) securityFirewallCommitLocal(w http.ResponseWriter, r *http.Request, id string) { if s.security == nil { http.Error(w, "host security service unavailable", 503) return } if e := s.security.CommitFirewall(id); e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]any{"ok": true, "message": "Firewall change committed."}) } func (s *Server) securityFirewallRollback(w http.ResponseWriter, r *http.Request) { var in struct { ChangeID string `json:"change_id"` } if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } if id := nodeID(r); id > 0 { s.relay(w, r, id, http.MethodPost, "/agent/v1/security/firewall/rollback", in) return } s.securityFirewallRollbackLocal(w, r, in.ChangeID) } func (s *Server) localSecurityFirewallRollback(w http.ResponseWriter, r *http.Request) { var in struct { ChangeID string `json:"change_id"` } if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } s.securityFirewallRollbackLocal(w, r, in.ChangeID) } func (s *Server) securityFirewallRollbackLocal(w http.ResponseWriter, r *http.Request, id string) { if s.security == nil { http.Error(w, "host security service unavailable", 503) return } if e := s.security.RollbackFirewall(r.Context(), id); e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, map[string]any{"ok": true, "message": "Firewall change rolled back."}) } func (s *Server) securityFail2Ban(w http.ResponseWriter, r *http.Request) { if id := nodeID(r); id > 0 { s.relay(w, r, id, http.MethodGet, "/agent/v1/security/fail2ban", nil) return } s.localSecurityFail2Ban(w, r) } func (s *Server) localSecurityFail2Ban(w http.ResponseWriter, r *http.Request) { if s.security == nil { http.Error(w, "host security service unavailable", 503) return } jsonOut(w, 200, s.security.Fail2BanPolicy()) } func (s *Server) securityApplyFail2Ban(w http.ResponseWriter, r *http.Request) { var in hostsecurity.Fail2BanPolicy if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } if id := nodeID(r); id > 0 { s.relay(w, r, id, http.MethodPut, "/agent/v1/security/fail2ban", in) return } s.securityApplyFail2BanLocal(w, r, in) } func (s *Server) localSecurityApplyFail2Ban(w http.ResponseWriter, r *http.Request) { var in hostsecurity.Fail2BanPolicy if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } s.securityApplyFail2BanLocal(w, r, in) } func (s *Server) securityApplyFail2BanLocal(w http.ResponseWriter, r *http.Request, in hostsecurity.Fail2BanPolicy) { if s.security == nil { http.Error(w, "host security service unavailable", 503) return } v, e := s.security.ApplyFail2Ban(r.Context(), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) securityAuditd(w http.ResponseWriter, r *http.Request) { if id := nodeID(r); id > 0 { s.relay(w, r, id, http.MethodGet, "/agent/v1/security/auditd", nil) return } s.localSecurityAuditd(w, r) } func (s *Server) localSecurityAuditd(w http.ResponseWriter, r *http.Request) { if s.security == nil { http.Error(w, "host security service unavailable", 503) return } jsonOut(w, 200, s.security.AuditdPolicy()) } func (s *Server) securityApplyAuditd(w http.ResponseWriter, r *http.Request) { var in hostsecurity.AuditdPolicy if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } if id := nodeID(r); id > 0 { s.relay(w, r, id, http.MethodPut, "/agent/v1/security/auditd", in) return } s.securityApplyAuditdLocal(w, r, in) } func (s *Server) localSecurityApplyAuditd(w http.ResponseWriter, r *http.Request) { var in hostsecurity.AuditdPolicy if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } s.securityApplyAuditdLocal(w, r, in) } func (s *Server) securityApplyAuditdLocal(w http.ResponseWriter, r *http.Request, in hostsecurity.AuditdPolicy) { if s.security == nil { http.Error(w, "host security service unavailable", 503) return } v, e := s.security.ApplyAuditd(r.Context(), in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) securityInstall(w http.ResponseWriter, r *http.Request) { var in hostsecurity.InstallInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } component := r.PathValue("component") if id := nodeID(r); id > 0 { s.relayWithTimeout(w, r, id, http.MethodPost, "/agent/v1/security/components/"+url.PathEscape(component)+"/install", in, 10*time.Minute) return } s.securityInstallLocal(w, r, component, in) } func (s *Server) localSecurityInstall(w http.ResponseWriter, r *http.Request) { var in hostsecurity.InstallInput if e := read(r, &in); e != nil { http.Error(w, e.Error(), 400) return } s.securityInstallLocal(w, r, r.PathValue("component"), in) } func (s *Server) securityInstallLocal(w http.ResponseWriter, r *http.Request, component string, in hostsecurity.InstallInput) { if s.security == nil { http.Error(w, "host security service unavailable", 503) return } v, e := s.security.Install(r.Context(), component, in) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) } func (s *Server) securityComponentAction(w http.ResponseWriter, r *http.Request) { component, action := r.PathValue("component"), r.PathValue("action") if id := nodeID(r); id > 0 { s.relay(w, r, id, http.MethodPost, "/agent/v1/security/components/"+url.PathEscape(component)+"/actions/"+url.PathEscape(action), map[string]any{}) return } s.securityComponentActionLocal(w, r, component, action) } func (s *Server) localSecurityComponentAction(w http.ResponseWriter, r *http.Request) { s.securityComponentActionLocal(w, r, r.PathValue("component"), r.PathValue("action")) } func (s *Server) securityComponentActionLocal(w http.ResponseWriter, r *http.Request, component, action string) { if s.security == nil { http.Error(w, "host security service unavailable", 503) return } v, e := s.security.ComponentAction(r.Context(), component, action) if e != nil { http.Error(w, e.Error(), 400) return } jsonOut(w, 200, v) }