1760 lines
59 KiB
Go
1760 lines
59 KiB
Go
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)))
|
|
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/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, "status page not found", 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}).Parse(`<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="30"><title>{{.Name}} · Status</title><style>:root{color-scheme:dark}body{margin:0;background:#0b0f14;color:#e8edf3;font:15px system-ui,-apple-system,Segoe UI,sans-serif}.wrap{max-width:900px;margin:0 auto;padding:54px 20px}.brand{color:#8b98a8;font-size:13px;margin-bottom:28px}h1{font-size:34px;margin:0 0 8px}.desc{color:#9aa8b7;margin-bottom:22px;white-space:pre-wrap}.summary,.service{background:#121820;border:1px solid #222c38;border-radius:14px;padding:18px 20px;margin:12px 0}.summary{display:flex;align-items:center;justify-content:space-between;gap:18px}.summary strong{font-size:17px}.dot{width:11px;height:11px;border-radius:50%;display:inline-block;flex:0 0 auto}.up{background:#26c281}.down{background:#ef5b5b}.maintenance{background:#f0b849}.pending,.paused,.unknown{background:#8290a2}.service h2{font-size:17px;margin:0}.row{display:flex;align-items:center;justify-content:space-between;gap:15px}.meta{color:#8b98a8;font-size:13px;margin-top:5px}.probe{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:8px;padding:10px 0;border-top:1px solid #1d2631;margin-top:10px}.probe+.probe{margin-top:0}.badge{font-size:12px;text-transform:uppercase;font-weight:700;letter-spacing:.04em}.footer{color:#647181;font-size:12px;text-align:center;margin-top:32px}@media(max-width:600px){.wrap{padding:30px 14px}h1{font-size:28px}.summary{align-items:flex-start;flex-direction:column}.probe{grid-template-columns:auto 1fr}.probe .meta{grid-column:2}}</style></head><body><main class="wrap"><div class="brand">Dockwatch Public Status</div><h1>{{.Name}}</h1><div class="desc">{{.Description}}</div><section class="summary"><div><strong>{{if eq .Status "up"}}All published services operational{{else if eq .Status "down"}}Service disruption detected{{else if eq .Status "maintenance"}}Maintenance in progress{{else}}Status being evaluated{{end}}</strong><div class="meta">This page refreshes automatically every 30 seconds.</div></div><div class="row"><span class="dot {{.Status}}"></span><span class="badge">{{upper .Status}}</span></div></section>{{range .Services}}<section class="service"><div class="row"><div><h2>{{.Name}}</h2><div class="meta">{{.Description}}</div></div><div class="row"><span class="dot {{.Status}}"></span><span class="badge">{{upper .Status}}</span></div></div>{{range .Monitors}}<div class="probe"><span class="dot {{.Status}}"></span><span>{{.Name}}</span><span class="meta">{{printf "%.2f" .Uptime24h}}% / 24h{{if .LastLatencyMS}} · {{.LastLatencyMS}} ms{{end}}</span></div>{{end}}</section>{{else}}<section class="service">No public services configured.</section>{{end}}<div class="footer">Only explicitly published service status is shown · powered by Dockwatch</div></main></body></html>`))
|
|
|
|
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) 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)
|
|
}
|