// SPDX-License-Identifier: Apache-2.0 package server import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "github.com/X4Applegate/caddyui/internal/models" "github.com/go-chi/chi/v5" ) // ─── REST JSON API v1 ───────────────────────────────────────────────────────── // // All endpoints are under /api/v1/ and live inside the requireAuth middleware // group, so both session cookies and Bearer API tokens are accepted. // Write endpoints (POST/PUT/DELETE) additionally require a write-scoped token // or an admin/write role — the requireWrite middleware enforces that at the // chi group level. // GET /api/v1/servers func (s *Server) apiV1ListServers(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } servers, err := models.ListCaddyServers(s.DB) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } out := make([]map[string]any, 0, len(servers)) for _, srv := range servers { var lastContactAt any if srv.LastContactAt.Valid { lastContactAt = srv.LastContactAt.Time.UTC() } entry := map[string]any{ "id": srv.ID, "name": srv.Name, "type": srv.Type, "status": srv.Status, "version": srv.Version, "tags": srv.TagList(), "last_contact_at": lastContactAt, } // v2.57.1: a node's admin URL is the address of its admin API. Handing // every node's to any account gave a tenant the exact targets for an // upstream-guard bypass attempt (and SECURITY.md says it is not exposed). if isAdminUser(cu) { entry["admin_url"] = srv.AdminURL } out = append(out, entry) } writeJSON(w, http.StatusOK, out) } // apiProxyHostInput is the JSON request body for create / update. type apiProxyHostInput struct { Domains string `json:"domains"` ForwardScheme string `json:"forward_scheme"` ForwardHost string `json:"forward_host"` ForwardPort int `json:"forward_port"` WebsocketSupport bool `json:"websocket_support"` BlockCommonExploits bool `json:"block_common_exploits"` SSLEnabled bool `json:"ssl_enabled"` SSLForced bool `json:"ssl_forced"` HTTP2Support bool `json:"http2_support"` Enabled bool `json:"enabled"` CertificateID int64 `json:"certificate_id"` BasicAuthEnabled bool `json:"basicauth_enabled"` AccessList string `json:"access_list"` IPBlocklist string `json:"ip_blocklist"` ExtraUpstreams string `json:"extra_upstreams"` CompressionEnabled bool `json:"compression_enabled"` SecurityHeadersEnabled bool `json:"security_headers_enabled"` TLSMinVersion string `json:"tls_min_version"` // v2.64.0 (discussion #137): client certificate authentication (mTLS). // On PATCH, "off" clears the mode — an empty string leaves it unchanged // like every other field in this struct. ClientAuthMode string `json:"client_auth_mode"` ClientAuthCAFiles string `json:"client_auth_ca_files"` MaintenanceMode bool `json:"maintenance_mode"` MaintenanceMsg string `json:"maintenance_msg"` MaxRequestBodyMB int `json:"max_request_body_mb"` StickySessions bool `json:"sticky_sessions"` LBPolicy string `json:"lb_policy"` UpstreamTimeoutSec int `json:"upstream_timeout_sec"` CORSEnabled bool `json:"cors_enabled"` CORSOrigins string `json:"cors_origins"` HealthCheckURI string `json:"health_check_uri"` HealthCheckIntervalSec int `json:"health_check_interval_sec"` HealthCheckMethod string `json:"health_check_method"` KeepaliveConns int `json:"keepalive_conns"` Tags string `json:"tags"` Notes string `json:"notes"` DisableAccessLog bool `json:"disable_access_log"` AddRequestID bool `json:"add_request_id"` StripRespHeaders string `json:"strip_resp_headers"` BlockedAgents string `json:"blocked_agents"` UpstreamSNI string `json:"upstream_sni"` HSTSPreload bool `json:"hsts_preload"` MaxConnsPerHost int `json:"max_conns_per_host"` UpstreamRetries int `json:"upstream_retries"` ForceHTTP1 bool `json:"force_http1"` ProxyProtocol string `json:"proxy_protocol"` } // proxyHostToAPIMap converts a ProxyHost to a JSON-serialisable map. func proxyHostToAPIMap(p *models.ProxyHost) map[string]any { return map[string]any{ "id": p.ID, "server_id": p.ServerID, "domains": p.Domains, "forward_scheme": p.ForwardScheme, "forward_host": p.ForwardHost, "forward_port": p.ForwardPort, "websocket_support": p.WebsocketSupport, "block_common_exploits": p.BlockCommonExploits, "ssl_enabled": p.SSLEnabled, "ssl_forced": p.SSLForced, "http2_support": p.HTTP2Support, "enabled": p.Enabled, "certificate_id": p.CertificateID, "basicauth_enabled": p.BasicAuthEnabled, "access_list": p.AccessList, "ip_blocklist": p.IPBlocklist, "extra_upstreams": p.ExtraUpstreams, "compression_enabled": p.CompressionEnabled, "security_headers_enabled": p.SecurityHeadersEnabled, "tls_min_version": p.TLSMinVersion, "client_auth_mode": p.ClientAuthMode, "client_auth_ca_files": p.ClientAuthCAFiles, "maintenance_mode": p.MaintenanceMode, "maintenance_msg": p.MaintenanceMsg, "max_request_body_mb": p.MaxRequestBodyMB, "sticky_sessions": p.StickySessions, "lb_policy": p.LBPolicy, "upstream_timeout_sec": p.UpstreamTimeoutSec, "cors_enabled": p.CORSEnabled, "cors_origins": p.CORSOrigins, "health_check_uri": p.HealthCheckURI, "health_check_interval_sec": p.HealthCheckIntervalSec, "health_check_method": p.HealthCheckMethod, "keepalive_conns": p.KeepaliveConns, "tags": p.Tags, "notes": p.Notes, "disable_access_log": p.DisableAccessLog, "add_request_id": p.AddRequestID, "strip_resp_headers": p.StripRespHeaders, "blocked_agents": p.BlockedAgents, "upstream_sni": p.UpstreamSNI, "hsts_preload": p.HSTSPreload, "max_conns_per_host": p.MaxConnsPerHost, "upstream_retries": p.UpstreamRetries, "force_http1": p.ForceHTTP1, "proxy_protocol": p.ProxyProtocol, "owner_email": p.OwnerEmail, "created_at": p.CreatedAt, "updated_at": p.UpdatedAt, } } // writeJSON is a convenience wrapper that sets Content-Type and encodes v as JSON. func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeJSONError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // validateClientAuthInput rejects a client-certificate (mTLS) configuration // that Caddy would accept but that could not admit anyone: an unknown mode, // or a verifying mode with no trusted CA to verify against. // // v2.64.0 (discussion #137). func validateClientAuthInput(mode, caFiles string) error { if !models.ValidClientAuthMode(mode) { return fmt.Errorf("client_auth_mode %q is not valid: use request, require, verify_if_given, require_and_verify, or off", mode) } if models.ClientAuthModeVerifies(mode) && strings.TrimSpace(caFiles) == "" { return fmt.Errorf("client_auth_mode %q verifies the client certificate, so client_auth_ca_files must list at least one trusted CA certificate file", mode) } return nil } // GET /api/v1/proxy-hosts func (s *Server) apiV1ListProxyHosts(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } serverID := s.currentServerID(r) hosts, err := models.ListProxyHosts(s.DB, serverID, cu.ID, cu.IsAdmin, nil) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } out := make([]map[string]any, 0, len(hosts)) for i := range hosts { out = append(out, proxyHostToAPIMap(&hosts[i])) } writeJSON(w, http.StatusOK, out) } // GET /api/v1/proxy-hosts/{id} func (s *Server) apiV1GetProxyHost(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } ph, err := models.GetProxyHost(s.DB, id) if err != nil || ph == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(cu, ph.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } writeJSON(w, http.StatusOK, proxyHostToAPIMap(ph)) } // POST /api/v1/proxy-hosts func (s *Server) apiV1CreateProxyHost(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } var inp apiProxyHostInput if err := json.NewDecoder(r.Body).Decode(&inp); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if inp.Domains == "" || inp.ForwardHost == "" || inp.ForwardPort == 0 { writeJSONError(w, http.StatusBadRequest, "domains, forward_host, and forward_port are required") return } if inp.ForwardScheme == "" { inp.ForwardScheme = "http" } if inp.HealthCheckMethod == "" { inp.HealthCheckMethod = "GET" } if err := validateClientAuthInput(inp.ClientAuthMode, inp.ClientAuthCAFiles); err != nil { writeJSONError(w, http.StatusBadRequest, err.Error()) return } ph := &models.ProxyHost{ Domains: inp.Domains, ForwardScheme: inp.ForwardScheme, ForwardHost: inp.ForwardHost, ForwardPort: inp.ForwardPort, WebsocketSupport: inp.WebsocketSupport, BlockCommonExploits: inp.BlockCommonExploits, SSLEnabled: inp.SSLEnabled, SSLForced: inp.SSLForced, HTTP2Support: inp.HTTP2Support, Enabled: inp.Enabled, CertificateID: inp.CertificateID, BasicAuthEnabled: inp.BasicAuthEnabled, BasicAuthUsers: "[]", AccessList: inp.AccessList, IPBlocklist: inp.IPBlocklist, ExtraUpstreams: func() string { if inp.ExtraUpstreams == "" { return "[]" } return inp.ExtraUpstreams }(), CompressionEnabled: inp.CompressionEnabled, SecurityHeadersEnabled: inp.SecurityHeadersEnabled, TLSMinVersion: inp.TLSMinVersion, ClientAuthMode: inp.ClientAuthMode, ClientAuthCAFiles: inp.ClientAuthCAFiles, MaintenanceMode: inp.MaintenanceMode, MaintenanceMsg: inp.MaintenanceMsg, MaxRequestBodyMB: inp.MaxRequestBodyMB, StickySessions: inp.StickySessions, LBPolicy: inp.LBPolicy, UpstreamTimeoutSec: inp.UpstreamTimeoutSec, CORSEnabled: inp.CORSEnabled, CORSOrigins: func() string { if inp.CORSOrigins == "" { return "*" } return inp.CORSOrigins }(), HealthCheckURI: inp.HealthCheckURI, HealthCheckIntervalSec: func() int { if inp.HealthCheckIntervalSec <= 0 { return 30 } return inp.HealthCheckIntervalSec }(), HealthCheckMethod: inp.HealthCheckMethod, KeepaliveConns: inp.KeepaliveConns, Tags: inp.Tags, Notes: inp.Notes, DisableAccessLog: inp.DisableAccessLog, AddRequestID: inp.AddRequestID, StripRespHeaders: inp.StripRespHeaders, BlockedAgents: inp.BlockedAgents, UpstreamSNI: inp.UpstreamSNI, HSTSPreload: inp.HSTSPreload, MaxConnsPerHost: inp.MaxConnsPerHost, UpstreamRetries: inp.UpstreamRetries, ForceHTTP1: inp.ForceHTTP1, ProxyProtocol: inp.ProxyProtocol, BasicAuthRealm: "Restricted", CustomReqHeaders: "{}", CustomRespHeaders: "{}", URLRewrites: "[]", } // SSRF guard (GHSA-r4wm-rgc5-q834): non-admins may not point an upstream at // loopback/link-local/internal management addresses (e.g. the Caddy admin API). if msg := s.validateProxyUpstreamsForUser(cu, ph); msg != "" { writeJSONError(w, http.StatusForbidden, msg) return } serverID := s.currentServerID(r) ownerID := cu.ID if cu.IsAdmin { ownerID = 0 } // v2.57.1: the UI refuses a domain another host or redirect already holds // on this server; this path did not. Caddy keeps the first matching route // and the newest row sorts first, so a duplicate silently hijacked the // existing host's traffic. if conflict, err := models.DomainsConflict(s.DB, serverID, ph.DomainList(), 0, 0); err != nil { writeJSONError(w, http.StatusInternalServerError, "could not validate domains: "+err.Error()) return } else if conflict != "" { writeJSONError(w, http.StatusConflict, fmt.Sprintf("domain %q is already in use by another proxy or redirect on this server", conflict)) return } newID, err := models.CreateProxyHost(s.DB, serverID, ownerID, ph) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } ph.ID = newID ph.ServerID = serverID _ = models.LogActivity(s.DB, serverID, cu.Email, "proxy_create", fmt.Sprintf("proxy:%d", newID), ph.Domains, true) s.trySyncCaddy(serverID, false) created, _ := models.GetProxyHost(s.DB, newID) if created == nil { created = ph } writeJSON(w, http.StatusCreated, proxyHostToAPIMap(created)) } // PUT /api/v1/proxy-hosts/{id} func (s *Server) apiV1UpdateProxyHost(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } existing, err := models.GetProxyHost(s.DB, id) if err != nil || existing == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(cu, existing.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } var inp apiProxyHostInput if err := json.NewDecoder(r.Body).Decode(&inp); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } // Merge: any field not supplied keeps the existing value. if inp.Domains != "" { existing.Domains = inp.Domains } if inp.ForwardScheme != "" { existing.ForwardScheme = inp.ForwardScheme } if inp.ForwardHost != "" { existing.ForwardHost = inp.ForwardHost } if inp.ForwardPort != 0 { existing.ForwardPort = inp.ForwardPort } if inp.CertificateID != 0 { existing.CertificateID = inp.CertificateID } if inp.TLSMinVersion != "" { existing.TLSMinVersion = inp.TLSMinVersion } // v2.64.0 (discussion #137): client certificate authentication. An // omitted field means "leave unchanged" throughout this handler, so // mTLS is switched off with the explicit mode "off" rather than "". if inp.ClientAuthMode != "" { if inp.ClientAuthMode == "off" { existing.ClientAuthMode, existing.ClientAuthCAFiles = "", "" } else { existing.ClientAuthMode = inp.ClientAuthMode } } if inp.ClientAuthCAFiles != "" && existing.ClientAuthMode != "" { existing.ClientAuthCAFiles = inp.ClientAuthCAFiles } if err := validateClientAuthInput(existing.ClientAuthMode, existing.ClientAuthCAFiles); err != nil { writeJSONError(w, http.StatusBadRequest, err.Error()) return } if inp.MaintenanceMsg != "" { existing.MaintenanceMsg = inp.MaintenanceMsg } if inp.MaxRequestBodyMB != 0 { existing.MaxRequestBodyMB = inp.MaxRequestBodyMB } if inp.UpstreamTimeoutSec != 0 { existing.UpstreamTimeoutSec = inp.UpstreamTimeoutSec } if inp.CORSOrigins != "" { existing.CORSOrigins = inp.CORSOrigins } if inp.HealthCheckURI != "" { existing.HealthCheckURI = inp.HealthCheckURI } if inp.HealthCheckIntervalSec != 0 { existing.HealthCheckIntervalSec = inp.HealthCheckIntervalSec } if inp.HealthCheckMethod != "" { existing.HealthCheckMethod = inp.HealthCheckMethod } if inp.KeepaliveConns != 0 { existing.KeepaliveConns = inp.KeepaliveConns } if inp.StripRespHeaders != "" { existing.StripRespHeaders = inp.StripRespHeaders } if inp.BlockedAgents != "" { existing.BlockedAgents = inp.BlockedAgents } if inp.UpstreamSNI != "" { existing.UpstreamSNI = inp.UpstreamSNI } if inp.MaxConnsPerHost != 0 { existing.MaxConnsPerHost = inp.MaxConnsPerHost } if inp.UpstreamRetries != 0 { existing.UpstreamRetries = inp.UpstreamRetries } if inp.ProxyProtocol != "" { existing.ProxyProtocol = inp.ProxyProtocol } if inp.ExtraUpstreams != "" { existing.ExtraUpstreams = inp.ExtraUpstreams } existing.WebsocketSupport = inp.WebsocketSupport existing.BlockCommonExploits = inp.BlockCommonExploits existing.SSLEnabled = inp.SSLEnabled existing.SSLForced = inp.SSLForced existing.HTTP2Support = inp.HTTP2Support existing.Enabled = inp.Enabled existing.BasicAuthEnabled = inp.BasicAuthEnabled existing.CompressionEnabled = inp.CompressionEnabled existing.SecurityHeadersEnabled = inp.SecurityHeadersEnabled existing.MaintenanceMode = inp.MaintenanceMode existing.StickySessions = inp.StickySessions existing.CORSEnabled = inp.CORSEnabled existing.DisableAccessLog = inp.DisableAccessLog existing.AddRequestID = inp.AddRequestID existing.HSTSPreload = inp.HSTSPreload existing.ForceHTTP1 = inp.ForceHTTP1 if inp.Tags != "" { existing.Tags = inp.Tags } if inp.Notes != "" { existing.Notes = inp.Notes } if inp.AccessList != "" { existing.AccessList = inp.AccessList } if inp.IPBlocklist != "" { existing.IPBlocklist = inp.IPBlocklist } if inp.LBPolicy != "" { existing.LBPolicy = inp.LBPolicy } // SSRF guard (GHSA-r4wm-rgc5-q834): re-validate the merged upstreams so a // non-admin can't edit an existing host to target an internal address. if msg := s.validateProxyUpstreamsForUser(cu, existing); msg != "" { writeJSONError(w, http.StatusForbidden, msg) return } if conflict, err := models.DomainsConflict(s.DB, existing.ServerID, existing.DomainList(), existing.ID, 0); err != nil { writeJSONError(w, http.StatusInternalServerError, "could not validate domains: "+err.Error()) return } else if conflict != "" { writeJSONError(w, http.StatusConflict, fmt.Sprintf("domain %q is already in use by another proxy or redirect on this server", conflict)) return } if err := models.UpdateProxyHost(s.DB, existing); err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } _ = models.LogActivity(s.DB, existing.ServerID, s.currentUserEmail(r), "proxy_update", fmt.Sprintf("proxy:%d", id), existing.Domains, true) s.trySyncCaddy(existing.ServerID, existing.CertificateID != 0) updated, _ := models.GetProxyHost(s.DB, id) if updated == nil { updated = existing } writeJSON(w, http.StatusOK, proxyHostToAPIMap(updated)) } // DELETE /api/v1/proxy-hosts/{id} func (s *Server) apiV1DeleteProxyHost(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } ph, err := models.GetProxyHost(s.DB, id) if err != nil || ph == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(cu, ph.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } if ph.DNSRecordID != "" { s.dnsDeleteRecord(ph.DNSProvider, ph.DNSProfileID, ph.DNSZoneID, ph.DNSZoneName, ph.DNSRecordID) } note := s.noteFleetDeletion(models.FleetResourceProxy, id, ph.Domains) if err := models.DeleteProxyHost(s.DB, id); err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } _ = models.LogActivity(s.DB, ph.ServerID, s.currentUserEmail(r), "proxy_delete", fmt.Sprintf("proxy:%d", id), ph.Domains, true) s.trySyncCaddyAfterDelete(ph.ServerID, false) s.propagateNotedDeletion(s.currentUserEmail(r), note) // v2.59.0 (issue #120) writeJSON(w, http.StatusOK, map[string]any{"deleted": true, "id": id}) } // POST /api/v1/proxy-hosts/{id}/toggle func (s *Server) apiV1ToggleProxyHost(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } ph, err := models.GetProxyHost(s.DB, id) if err != nil || ph == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), ph.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } ph.Enabled = !ph.Enabled if err := models.UpdateProxyHost(s.DB, ph); err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } s.trySyncCaddy(ph.ServerID, false) writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": ph.Enabled}) } // POST /api/v1/proxy-hosts/{id}/maintenance func (s *Server) apiV1ToggleMaintenanceProxyHost(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } ph, err := models.GetProxyHost(s.DB, id) if err != nil || ph == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), ph.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } // Accept optional JSON body {"maintenance": true/false}; default = toggle. var body struct { Maintenance *bool `json:"maintenance"` } _ = json.NewDecoder(r.Body).Decode(&body) if body.Maintenance != nil { ph.MaintenanceMode = *body.Maintenance } else { ph.MaintenanceMode = !ph.MaintenanceMode } if err := models.UpdateProxyHost(s.DB, ph); err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } s.trySyncCaddy(ph.ServerID, false) writeJSON(w, http.StatusOK, map[string]any{"id": id, "maintenance_mode": ph.MaintenanceMode}) } // ─── Redirection Host REST API ──────────────────────────────────────────────── func redirectionHostToAPIMap(r *models.RedirectionHost) map[string]any { return map[string]any{ "id": r.ID, "domains": r.Domains, "forward_scheme": r.ForwardScheme, "forward_domain": r.ForwardDomain, "forward_http_code": r.ForwardHTTPCode, "preserve_path": r.PreservePath, "ssl_enabled": r.SSLEnabled, "ssl_forced": r.SSLForced, "enabled": r.Enabled, "certificate_id": r.CertificateID, "tags": r.Tags, "notes": r.Notes, "owner_email": r.OwnerEmail, "created_at": r.CreatedAt, "updated_at": r.UpdatedAt, } } // GET /api/v1/redirection-hosts func (s *Server) apiV1ListRedirectionHosts(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } hosts, err := models.ListRedirectionHosts(s.DB, s.currentServerID(r), cu.ID, cu.IsAdmin, nil) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } out := make([]map[string]any, 0, len(hosts)) for i := range hosts { out = append(out, redirectionHostToAPIMap(&hosts[i])) } writeJSON(w, http.StatusOK, out) } // GET /api/v1/redirection-hosts/{id} func (s *Server) apiV1GetRedirectionHost(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } rh, err := models.GetRedirectionHost(s.DB, id) if err != nil || rh == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), rh.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } writeJSON(w, http.StatusOK, redirectionHostToAPIMap(rh)) } // POST /api/v1/redirection-hosts func (s *Server) apiV1CreateRedirectionHost(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } var inp struct { Domains string `json:"domains"` ForwardScheme string `json:"forward_scheme"` ForwardDomain string `json:"forward_domain"` ForwardHTTPCode int `json:"forward_http_code"` PreservePath bool `json:"preserve_path"` SSLEnabled bool `json:"ssl_enabled"` SSLForced bool `json:"ssl_forced"` Enabled bool `json:"enabled"` CertificateID int64 `json:"certificate_id"` Tags string `json:"tags"` Notes string `json:"notes"` } if err := json.NewDecoder(r.Body).Decode(&inp); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if inp.Domains == "" || inp.ForwardDomain == "" { writeJSONError(w, http.StatusBadRequest, "domains and forward_domain are required") return } if inp.ForwardScheme == "" { inp.ForwardScheme = "auto" } if inp.ForwardHTTPCode == 0 { inp.ForwardHTTPCode = 301 } rh := &models.RedirectionHost{ Domains: inp.Domains, ForwardScheme: inp.ForwardScheme, ForwardDomain: inp.ForwardDomain, ForwardHTTPCode: inp.ForwardHTTPCode, PreservePath: inp.PreservePath, SSLEnabled: inp.SSLEnabled, SSLForced: inp.SSLForced, Enabled: inp.Enabled, CertificateID: inp.CertificateID, Tags: inp.Tags, Notes: inp.Notes, } serverID := s.currentServerID(r) ownerID := int64(0) if !cu.IsAdmin { ownerID = cu.ID } if msg := s.validateRedirectionForUser(cu, rh); msg != "" { // v2.57.1 writeJSONError(w, http.StatusBadRequest, msg) return } if conflict, err := models.DomainsConflict(s.DB, serverID, rh.DomainList(), 0, 0); err != nil { writeJSONError(w, http.StatusInternalServerError, "could not validate domains: "+err.Error()) return } else if conflict != "" { writeJSONError(w, http.StatusConflict, fmt.Sprintf("domain %q is already in use by another proxy or redirect on this server", conflict)) return } newID, err := models.CreateRedirectionHost(s.DB, serverID, ownerID, rh) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } _ = models.LogActivity(s.DB, serverID, cu.Email, "redirect_create", fmt.Sprintf("redir:%d", newID), rh.Domains, true) s.trySyncCaddy(serverID, false) created, _ := models.GetRedirectionHost(s.DB, newID) if created == nil { created = rh created.ID = newID } writeJSON(w, http.StatusCreated, redirectionHostToAPIMap(created)) } // PUT /api/v1/redirection-hosts/{id} func (s *Server) apiV1UpdateRedirectionHost(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } existing, err := models.GetRedirectionHost(s.DB, id) if err != nil || existing == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), existing.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } var inp struct { Domains string `json:"domains"` ForwardScheme string `json:"forward_scheme"` ForwardDomain string `json:"forward_domain"` ForwardHTTPCode int `json:"forward_http_code"` PreservePath bool `json:"preserve_path"` SSLEnabled bool `json:"ssl_enabled"` SSLForced bool `json:"ssl_forced"` Enabled bool `json:"enabled"` Tags string `json:"tags"` Notes string `json:"notes"` } if err := json.NewDecoder(r.Body).Decode(&inp); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if inp.Domains != "" { existing.Domains = inp.Domains } if inp.ForwardScheme != "" { existing.ForwardScheme = inp.ForwardScheme } if inp.ForwardDomain != "" { existing.ForwardDomain = inp.ForwardDomain } if inp.ForwardHTTPCode != 0 { existing.ForwardHTTPCode = inp.ForwardHTTPCode } existing.PreservePath = inp.PreservePath existing.SSLEnabled = inp.SSLEnabled existing.SSLForced = inp.SSLForced existing.Enabled = inp.Enabled if inp.Tags != "" { existing.Tags = inp.Tags } if inp.Notes != "" { existing.Notes = inp.Notes } if msg := s.validateRedirectionForUser(s.currentUser(r), existing); msg != "" { // v2.57.1 writeJSONError(w, http.StatusBadRequest, msg) return } if conflict, err := models.DomainsConflict(s.DB, s.currentServerID(r), existing.DomainList(), 0, existing.ID); err != nil { writeJSONError(w, http.StatusInternalServerError, "could not validate domains: "+err.Error()) return } else if conflict != "" { writeJSONError(w, http.StatusConflict, fmt.Sprintf("domain %q is already in use by another proxy or redirect on this server", conflict)) return } if err := models.UpdateRedirectionHost(s.DB, existing); err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "redirect_update", fmt.Sprintf("redir:%d", id), existing.Domains, true) s.trySyncCaddy(s.currentServerID(r), false) updated, _ := models.GetRedirectionHost(s.DB, id) if updated == nil { updated = existing } writeJSON(w, http.StatusOK, redirectionHostToAPIMap(updated)) } // DELETE /api/v1/redirection-hosts/{id} func (s *Server) apiV1DeleteRedirectionHost(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } existing, err := models.GetRedirectionHost(s.DB, id) if err != nil || existing == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), existing.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } note := s.noteFleetDeletion(models.FleetResourceRedirect, id, existing.Domains) if err := models.DeleteRedirectionHost(s.DB, id); err != nil { writeJSONError(w, http.StatusNotFound, "not found") return } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "redirect_delete", fmt.Sprintf("redir:%d", id), "", true) s.trySyncCaddyAfterDelete(s.currentServerID(r), false) s.propagateNotedDeletion(s.currentUserEmail(r), note) writeJSON(w, http.StatusOK, map[string]any{"deleted": true, "id": id}) } // POST /api/v1/redirection-hosts/{id}/toggle func (s *Server) apiV1ToggleRedirectionHost(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } existing, err := models.GetRedirectionHost(s.DB, id) if err != nil || existing == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), existing.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } enabled, err := models.ToggleRedirectionHost(s.DB, id) if err != nil { writeJSONError(w, http.StatusNotFound, "not found or error: "+err.Error()) return } rh, _ := models.GetRedirectionHost(s.DB, id) if rh != nil { s.syncCaddy(s.currentServerID(r), false) } writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": enabled}) } // --- REST JSON API v1: Raw Routes --- func rawRouteToAPIMap(r *models.RawRoute) map[string]any { return map[string]any{ "id": r.ID, "label": r.Label, "listen": r.ListenDisplay(), // v2.36.1 (issue #64) "json_data": r.JSONData, "caddyfile_src": r.CaddyfileSrc, "enabled": r.Enabled, "certificate_id": r.CertificateID, "force_ssl": r.ForceSSL, "block_common_exploits": r.BlockCommonExploits, "owner_email": r.OwnerEmail, "created_at": r.CreatedAt, "updated_at": r.UpdatedAt, } } // GET /api/v1/raw-routes func (s *Server) apiV1ListRawRoutes(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } routes, err := models.ListRawRoutes(s.DB, s.currentServerID(r), cu.ID, cu.IsAdmin, nil) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } out := make([]map[string]any, 0, len(routes)) for i := range routes { out = append(out, rawRouteToAPIMap(&routes[i])) } writeJSON(w, http.StatusOK, out) } // GET /api/v1/raw-routes/{id} func (s *Server) apiV1GetRawRoute(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } rr, err := models.GetRawRoute(s.DB, id) if err != nil || rr == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), rr.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } writeJSON(w, http.StatusOK, rawRouteToAPIMap(rr)) } // POST /api/v1/raw-routes func (s *Server) apiV1CreateRawRoute(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } var inp struct { Label string `json:"label"` JSONData string `json:"json_data"` CaddyfileSrc string `json:"caddyfile_src"` Enabled bool `json:"enabled"` CertificateID int64 `json:"certificate_id"` ForceSSL bool `json:"force_ssl"` BlockCommonExploits bool `json:"block_common_exploits"` Listen string `json:"listen"` // v2.36.1 (issue #64): e.g. ":7070" or ":7070, :7071" } if err := json.NewDecoder(r.Body).Decode(&inp); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if inp.JSONData == "" { writeJSONError(w, http.StatusBadRequest, "json_data is required") return } rr := &models.RawRoute{ Label: inp.Label, JSONData: inp.JSONData, CaddyfileSrc: inp.CaddyfileSrc, Enabled: inp.Enabled, CertificateID: inp.CertificateID, ForceSSL: inp.ForceSSL, BlockCommonExploits: inp.BlockCommonExploits, Listen: models.NormalizeRawRouteListen(models.ParseRawRouteListenInput(inp.Listen)), // v2.36.1 (issue #64) } serverID := s.currentServerID(r) ownerID := int64(0) if !cu.IsAdmin { ownerID = cu.ID } if msg := s.validateRawRouteForUser(cu, rr); msg != "" { // v2.57.1 writeJSONError(w, http.StatusBadRequest, msg) return } newID, err := models.CreateRawRoute(s.DB, serverID, ownerID, rr) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } _ = models.LogActivity(s.DB, serverID, cu.Email, "rawroute_create", fmt.Sprintf("rr:%d", newID), inp.Label, true) s.trySyncCaddy(serverID, false) created, _ := models.GetRawRoute(s.DB, newID) if created == nil { created = rr created.ID = newID } writeJSON(w, http.StatusCreated, rawRouteToAPIMap(created)) } // PUT /api/v1/raw-routes/{id} func (s *Server) apiV1UpdateRawRoute(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } existing, err := models.GetRawRoute(s.DB, id) if err != nil || existing == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), existing.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } var inp struct { Label string `json:"label"` JSONData string `json:"json_data"` CaddyfileSrc string `json:"caddyfile_src"` Enabled bool `json:"enabled"` CertificateID int64 `json:"certificate_id"` ForceSSL bool `json:"force_ssl"` BlockCommonExploits bool `json:"block_common_exploits"` } if err := json.NewDecoder(r.Body).Decode(&inp); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if inp.Label != "" { existing.Label = inp.Label } if inp.JSONData != "" { existing.JSONData = inp.JSONData } if inp.CaddyfileSrc != "" { existing.CaddyfileSrc = inp.CaddyfileSrc } existing.Enabled = inp.Enabled existing.ForceSSL = inp.ForceSSL existing.BlockCommonExploits = inp.BlockCommonExploits if inp.CertificateID != 0 { existing.CertificateID = inp.CertificateID } if msg := s.validateRawRouteForUser(s.currentUser(r), existing); msg != "" { // v2.57.1 writeJSONError(w, http.StatusBadRequest, msg) return } if err := models.UpdateRawRoute(s.DB, existing); err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "rawroute_update", fmt.Sprintf("rr:%d", id), existing.Label, true) s.trySyncCaddy(s.currentServerID(r), false) updated, _ := models.GetRawRoute(s.DB, id) if updated == nil { updated = existing } writeJSON(w, http.StatusOK, rawRouteToAPIMap(updated)) } // DELETE /api/v1/raw-routes/{id} func (s *Server) apiV1DeleteRawRoute(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } existing, err := models.GetRawRoute(s.DB, id) if err != nil || existing == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), existing.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } note := s.noteFleetDeletion(models.FleetResourceRawRoute, id, existing.Label) if err := models.DeleteRawRoute(s.DB, id); err != nil { writeJSONError(w, http.StatusNotFound, "not found") return } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "rawroute_delete", fmt.Sprintf("rr:%d", id), "", true) s.trySyncCaddyAfterDelete(s.currentServerID(r), false) s.propagateNotedDeletion(s.currentUserEmail(r), note) writeJSON(w, http.StatusOK, map[string]any{"deleted": true, "id": id}) } // POST /api/v1/raw-routes/{id}/toggle func (s *Server) apiV1ToggleRawRoute(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } existing, err := models.GetRawRoute(s.DB, id) if err != nil || existing == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), existing.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } enabled, err := models.ToggleRawRoute(s.DB, id) if err != nil { writeJSONError(w, http.StatusNotFound, "not found or error: "+err.Error()) return } rr, _ := models.GetRawRoute(s.DB, id) if rr != nil { s.syncCaddy(s.currentServerID(r), false) } writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": enabled}) } // --- REST JSON API v1: Certificates --- // apiCallerMaySeeSecrets reports whether this request may be shown stored // secrets such as certificate private keys: a signed-in browser session or a // full-scope token. read_only and proxy_write tokens are the kind of credential // that ends up in dashboards and CI logs, so they never receive key material // even when the user that owns them could read it. func apiCallerMaySeeSecrets(r *http.Request) bool { switch currentAPITokenScope(r) { case "", models.TokenScopeFull: return true } return false } func certificateToAPIMap(c *models.Certificate, includeSecrets bool) map[string]any { ownerID := int64(0) if c.OwnerID.Valid { ownerID = c.OwnerID.Int64 } out := map[string]any{ "id": c.ID, "name": c.Name, "domains": c.Domains, "source": c.Source, "cert_pem": c.CertPEM, "key_pem": c.KeyPEM, "cert_path": c.CertPath, "key_path": c.KeyPath, "dns_provider": c.DNSProvider, "dns_profile_id": c.DNSProfileID, "owner_id": ownerID, "owner_email": c.OwnerEmail, "created_at": c.CreatedAt, "updated_at": c.UpdatedAt, } // Security (v2.57.1): private keys and file paths are only for callers who // may manage the row. The list endpoint used to return key_pem for every // admin-owned (global) certificate to any role — including read-only // tokens — because the single-row GET checked ownership and the list did not. if !includeSecrets { delete(out, "key_pem") delete(out, "key_path") delete(out, "cert_path") } return out } // GET /api/v1/certificates func (s *Server) apiV1ListCertificates(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } certs, err := models.ListCertificatesForUser(s.DB, s.currentServerID(r), cu.ID, cu.IsAdmin, nil) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } out := make([]map[string]any, 0, len(certs)) for i := range certs { out = append(out, certificateToAPIMap(&certs[i], s.canManageOwned(cu, certs[i].OwnerID) && apiCallerMaySeeSecrets(r))) } writeJSON(w, http.StatusOK, out) } // GET /api/v1/certificates/{id} func (s *Server) apiV1GetCertificate(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } c, err := models.GetCertificate(s.DB, id) if err != nil || c == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), c.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } writeJSON(w, http.StatusOK, certificateToAPIMap(c, apiCallerMaySeeSecrets(r))) // canManageOwned passed above } // POST /api/v1/certificates func (s *Server) apiV1CreateCertificate(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } var inp struct { Name string `json:"name"` Domains string `json:"domains"` Source string `json:"source"` CertPEM string `json:"cert_pem"` KeyPEM string `json:"key_pem"` CertPath string `json:"cert_path"` KeyPath string `json:"key_path"` DNSProvider string `json:"dns_provider"` DNSProfileID string `json:"dns_profile_id"` } if err := json.NewDecoder(r.Body).Decode(&inp); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if inp.Name == "" { writeJSONError(w, http.StatusBadRequest, "name is required") return } if inp.Source == "" { inp.Source = "pem" } c := &models.Certificate{ Name: inp.Name, Domains: inp.Domains, Source: inp.Source, CertPEM: inp.CertPEM, KeyPEM: inp.KeyPEM, CertPath: inp.CertPath, KeyPath: inp.KeyPath, DNSProvider: inp.DNSProvider, DNSProfileID: inp.DNSProfileID, } serverID := s.currentServerID(r) ownerID := int64(0) if !cu.IsAdmin { ownerID = cu.ID } newID, err := models.CreateCertificate(s.DB, serverID, ownerID, c) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } _ = models.LogActivity(s.DB, serverID, cu.Email, "cert_create", fmt.Sprintf("cert:%d", newID), inp.Name, true) created, _ := models.GetCertificate(s.DB, newID) if created == nil { created = c created.ID = newID } writeJSON(w, http.StatusCreated, certificateToAPIMap(created, apiCallerMaySeeSecrets(r))) } // PUT /api/v1/certificates/{id} func (s *Server) apiV1UpdateCertificate(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } existing, err := models.GetCertificate(s.DB, id) if err != nil || existing == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), existing.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } var inp struct { Name string `json:"name"` Domains string `json:"domains"` Source string `json:"source"` CertPEM string `json:"cert_pem"` KeyPEM string `json:"key_pem"` CertPath string `json:"cert_path"` KeyPath string `json:"key_path"` DNSProvider string `json:"dns_provider"` DNSProfileID string `json:"dns_profile_id"` } if err := json.NewDecoder(r.Body).Decode(&inp); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if inp.Name != "" { existing.Name = inp.Name } if inp.Domains != "" { existing.Domains = inp.Domains } if inp.Source != "" { existing.Source = inp.Source } if inp.CertPEM != "" { existing.CertPEM = inp.CertPEM } if inp.KeyPEM != "" { existing.KeyPEM = inp.KeyPEM } if inp.CertPath != "" { existing.CertPath = inp.CertPath } if inp.KeyPath != "" { existing.KeyPath = inp.KeyPath } if inp.DNSProvider != "" { existing.DNSProvider = inp.DNSProvider } if inp.DNSProfileID != "" { existing.DNSProfileID = inp.DNSProfileID } if err := models.UpdateCertificate(s.DB, existing); err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "cert_update", fmt.Sprintf("cert:%d", id), existing.Name, true) updated, _ := models.GetCertificate(s.DB, id) if updated == nil { updated = existing } writeJSON(w, http.StatusOK, certificateToAPIMap(updated, apiCallerMaySeeSecrets(r))) } // DELETE /api/v1/certificates/{id} func (s *Server) apiV1DeleteCertificate(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeJSONError(w, http.StatusBadRequest, "invalid id") return } existing, err := models.GetCertificate(s.DB, id) if err != nil || existing == nil { writeJSONError(w, http.StatusNotFound, "not found") return } if !s.canManageOwned(s.currentUser(r), existing.OwnerID) { writeJSONError(w, http.StatusForbidden, "forbidden") return } if inUse, _ := models.CertificateInUse(s.DB, id); inUse > 0 { writeJSONError(w, http.StatusConflict, "certificate is in use by one or more hosts; remove references first") return } if err := models.DeleteCertificate(s.DB, id); err != nil { writeJSONError(w, http.StatusNotFound, "not found") return } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "cert_delete", fmt.Sprintf("cert:%d", id), "", true) writeJSON(w, http.StatusOK, map[string]any{"deleted": true, "id": id}) }