internal/server/api_v1.go
Rama main · réplica de lectura del repositorio original.
// 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})
}