Files
dockwatch/internal/httpapi/httpapi.go
T
jbergner 45ca18b74e
release-tag / release-image (push) Failing after 1m20s
init
2026-08-31 17:09:21 +02:00

1246 lines
41 KiB
Go

package httpapi
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"html/template"
"io"
"io/fs"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"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/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
}
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) http.Handler {
s := &Server{cfg: c, auth: a, stacks: ss, nodes: n, monitors: m, audit: au, notify: nt, git: gs}
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.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.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)
mux.Handle("/api/", a.Middleware(mutationOriginGuard(s.auditMiddleware(api))))
assets, _ := fs.Sub(web.FS, ".")
f := http.FileServer(http.FS(assets))
mux.Handle("GET /app.js", f)
mux.Handle("GET /styles.css", f)
mux.Handle("GET /{$}", f)
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/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}/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) 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) 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:
}
}