// SPDX-License-Identifier: Apache-2.0 package server import ( "encoding/json" "fmt" "log" "net/http" "net/url" "slices" "strconv" "strings" "time" "github.com/X4Applegate/caddyui/internal/auth" "github.com/X4Applegate/caddyui/internal/caddy" "github.com/X4Applegate/caddyui/internal/dns" "github.com/X4Applegate/caddyui/internal/models" "github.com/X4Applegate/caddyui/internal/porkbun" "github.com/go-chi/chi/v5" ) // --- Proxy Hosts --- type advancedRouteRow struct { ID int64 Label string Hosts string // comma-joined hosts from match[].host[] Summary string // e.g. "3 upstreams · 3 redirects" Enabled bool // v2.7.4: ownership flags for template-side gating. CanEdit hides // Edit/Delete on rows the viewer is only seeing via group peer-ship; // OwnerEmail + IsTeamRow render the "Team: " chip. OwnerEmail string CanEdit bool IsTeamRow bool } func (s *Server) listProxyHosts(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) isAdmin := cu != nil && cu.Role == models.RoleAdmin var viewerID int64 if cu != nil { viewerID = cu.ID } peers := s.groupPeerIDs(r) peerSet := map[int64]bool{} for _, id := range peers { peerSet[id] = true } hosts, err := models.ListProxyHostSummaries(s.DB, s.currentServerID(r), viewerID, isAdmin, peers) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } raws, _ := models.ListRawRoutes(s.DB, s.currentServerID(r), viewerID, isAdmin, peers) var advancedRows []advancedRouteRow for _, rr := range raws { var decoded any if err := json.Unmarshal([]byte(rr.JSONData), &decoded); err != nil { continue } hostSet := map[string]struct{}{} var up, redirs, files int for _, route := range flattenToRouteMaps(decoded) { for _, h := range hostsFromRoute(route) { hostSet[h] = struct{}{} } countHandlers(route, &up, &redirs, &files) } hosts := make([]string, 0, len(hostSet)) for h := range hostSet { hosts = append(hosts, h) } var parts []string if up > 0 { parts = append(parts, pluralize(up, "upstream", "upstreams")) } if files > 0 { parts = append(parts, pluralize(files, "file server", "file servers")) } if redirs > 0 { parts = append(parts, pluralize(redirs, "redirect", "redirects")) } summary := strings.Join(parts, " · ") if summary == "" { summary = "custom handlers" } canEdit := isAdmin || (rr.OwnerID.Valid && viewerID != 0 && (rr.OwnerID.Int64 == viewerID || peerSet[rr.OwnerID.Int64])) isTeam := !isAdmin && rr.OwnerID.Valid && viewerID != 0 && rr.OwnerID.Int64 != viewerID advancedRows = append(advancedRows, advancedRouteRow{ ID: rr.ID, Label: rr.Label, Hosts: strings.Join(hosts, ", "), Summary: summary, Enabled: rr.Enabled, OwnerEmail: rr.OwnerEmail, CanEdit: canEdit, IsTeamRow: isTeam, }) } // Tag filtering: ?tag=production narrows the list to hosts bearing that tag. activeTag := strings.TrimSpace(r.URL.Query().Get("tag")) if activeTag != "" { filtered := hosts[:0] for _, h := range hosts { for _, t := range h.TagList() { if strings.EqualFold(t, activeTag) { filtered = append(filtered, h) break } } } hosts = filtered } // Status filter: ?status=enabled|disabled|maintenance|all statusFilter := strings.TrimSpace(r.URL.Query().Get("status")) if statusFilter != "" && statusFilter != "all" { filtered := hosts[:0] for _, h := range hosts { switch statusFilter { case "enabled": if h.Enabled && !h.MaintenanceMode { filtered = append(filtered, h) } case "disabled": if !h.Enabled { filtered = append(filtered, h) } case "maintenance": if h.MaintenanceMode { filtered = append(filtered, h) } } } hosts = filtered } // Load latest health check for each host. hostIDs := make([]int64, len(hosts)) for i, p := range hosts { hostIDs[i] = p.ID } healthMap, _ := models.LatestProxyHealth(s.DB, hostIDs) certs, _ := s.certOptionListForRequest(r) // Per-host today request counts (best-effort — zero if analytics disabled). hostRequestsToday := make(map[int64]int) visibleDomains := make([]string, 0, len(hosts)) for _, h := range hosts { visibleDomains = append(visibleDomains, h.DomainList()...) } if domainCounts, err := models.DomainRequestsTodayForDomains(s.DB, visibleDomains); err == nil { for _, h := range hosts { total := 0 for _, d := range h.DomainList() { total += domainCounts[strings.ToLower(d)] } if total > 0 { hostRequestsToday[h.ID] = total } } } // Build a set of host IDs that have been modified since the last sync. // This lets the template show a warning badge on stale hosts. var lastSyncTime time.Time _ = s.DB.QueryRow( `SELECT created_at FROM activity_log WHERE server_id = ? AND action = 'sync_applied' ORDER BY id DESC LIMIT 1`, s.currentServerID(r), ).Scan(&lastSyncTime) needsSync := make(map[int64]bool) if !lastSyncTime.IsZero() { for _, h := range hosts { if h.UpdatedAt.After(lastSyncTime) { needsSync[h.ID] = true } } } s.render(w, r, "proxy_hosts.html", map[string]any{ "User": s.currentUser(r), "Hosts": hosts, "AdvancedRows": advancedRows, "Section": "proxy", // v2.7.4: thread viewer ID so templates can hide Edit/Delete on rows // the user is only seeing via group peer-ship, and render a // "Team: " chip instead. Security still enforced in the // update/delete handlers — this is UX clarity, not a gate. "ViewerID": viewerID, "HealthMap": healthMap, "ActiveTag": activeTag, "StatusFilter": statusFilter, "NeedsSync": needsSync, "HostRequestsToday": hostRequestsToday, "Certificates": certs, }) } // getProxyHostHealth renders the per-host health history page (/proxy-hosts/{id}/health). func (s *Server) getProxyHostHealth(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.NotFound(w, r) return } host, err := models.GetProxyHost(s.DB, id) if err != nil || host == nil { http.NotFound(w, r) return } if !s.canManageOwned(s.currentUser(r), host.OwnerID) { // v2.57.1: same scope as the host list http.Error(w, "forbidden", http.StatusForbidden) return } checks, err := models.GetProxyHealthHistory(s.DB, id, 50) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Calculate uptime % for last 24 h: (ok checks / total checks) * 100. var total, okCount int cutoff := time.Now().Add(-24 * time.Hour) for _, c := range checks { if c.CheckedAt.After(cutoff) { total++ if c.OK { okCount++ } } } var uptime float64 if total > 0 { uptime = float64(okCount) / float64(total) * 100 } s.render(w, r, "proxy_host_health.html", map[string]any{ "User": s.currentUser(r), "Host": host, "Checks": checks, "Uptime": uptime, "CadenceLabel": publicHealthCadenceLabel(*host), "Expectations": host.ExpectationList(), // v2.38.0 "ExpectationResults": s.expectationResultsFor(id), // v2.38.0 "Total": total, "Section": "proxy", }) } func pluralize(n int, singular, plural string) string { if n == 1 { return fmt.Sprintf("1 %s", singular) } return fmt.Sprintf("%d %s", n, plural) } // countHandlers walks a route's handle[] (and any nested subroute/handle_path // routes) and tallies handler types so the proxy hosts list can show a quick // summary of what an Advanced route does. func countHandlers(route map[string]any, upstreams, redirects, fileServers *int) { handle, _ := route["handle"].([]any) for _, h := range handle { m, _ := h.(map[string]any) if m == nil { continue } switch m["handler"] { case "reverse_proxy": *upstreams++ case "file_server": *fileServers++ case "static_response": if sc, ok := m["status_code"].(float64); ok && sc >= 300 && sc < 400 { *redirects++ } else if headers, ok := m["headers"].(map[string]any); ok { if _, hasLoc := headers["Location"]; hasLoc { *redirects++ } } case "subroute": if sub, ok := m["routes"].([]any); ok { for _, r := range sub { if rm, ok := r.(map[string]any); ok { countHandlers(rm, upstreams, redirects, fileServers) } } } } } } // toggleProxyHost flips the enabled state on a proxy host and triggers a sync. func (s *Server) toggleProxyHost(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) cu := s.currentUser(r) isAdmin := cu != nil && cu.Role == models.RoleAdmin if !isAdmin { host, err := models.GetProxyHost(s.DB, id) if err != nil || host == nil || !s.canManageOwned(cu, host.OwnerID) { http.Error(w, "forbidden", http.StatusForbidden) return } } enabled, err := models.ToggleProxyHost(s.DB, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } action := "proxy_enable" if !enabled { action = "proxy_disable" } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), action, fmt.Sprintf("proxy:%d", id), "", true) // v2.12.29: capture + log the sync error instead of swallowing it. If // Caddy rejects the new config (e.g. an unknown-field validation // error like the v2.12.20 `network` bug) the toggle still flips in // the DB but Caddy never sees the change — and the user previously // got zero feedback that anything went wrong. Logging it at least // surfaces the failure in the container logs. if err := s.syncCaddy(s.currentServerID(r), false); err != nil { log.Printf("toggleProxyHost: auto-sync failed (toggle persisted but Caddy not updated): %v", err) } if ph, _ := models.GetProxyHost(s.DB, id); ph != nil { // v2.68.3 (issue #150) s.followProxyHostTargets(s.currentUserEmail(r), ph, nil) } ref := r.Header.Get("Referer") if ref == "" { ref = "/proxy-hosts" } http.Redirect(w, r, ref, http.StatusSeeOther) } // bulkToggleProxyHosts enables or disables a set of proxy hosts in one shot. // Expects form fields: ids[]={id,...} and action=enable|disable. // Admin/write users only (the route is inside the requireWrite middleware block). func (s *Server) bulkToggleProxyHosts(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } isAdmin := cu.Role == models.RoleAdmin if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } action := r.FormValue("action") // "enable" or "disable" if action != "enable" && action != "disable" { http.Error(w, "invalid action", http.StatusBadRequest) return } enabled := action == "enable" rawIDs := r.Form["ids[]"] if len(rawIDs) == 0 { http.Redirect(w, r, "/proxy-hosts", http.StatusSeeOther) return } var changed []*models.ProxyHost for _, raw := range rawIDs { id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil { continue } ph, err := models.GetProxyHost(s.DB, id) if err != nil || ph == nil { continue } // Ownership check: admin can toggle any, others only their own. if !isAdmin && (!s.canManageOwned(cu, ph.OwnerID)) { continue } if ph.Enabled != enabled { ph.Enabled = enabled if err := models.UpdateProxyHost(s.DB, ph); err != nil { log.Printf("bulkToggle: update %d: %v", id, err) continue } changed = append(changed, ph) } } s.syncChangedProxyHosts(cu.Email, changed) http.Redirect(w, r, "/proxy-hosts", http.StatusSeeOther) } // bulkMaintenanceProxyHosts enables or disables maintenance mode for a set of // proxy hosts. Expects form fields: ids[]={id,...} and action=enable|disable. func (s *Server) bulkMaintenanceProxyHosts(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } isAdmin := cu.Role == models.RoleAdmin if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } action := r.FormValue("action") if action != "enable" && action != "disable" { http.Error(w, "invalid action", http.StatusBadRequest) return } maint := action == "enable" rawIDs := r.Form["ids[]"] if len(rawIDs) == 0 { http.Redirect(w, r, "/proxy-hosts", http.StatusSeeOther) return } var changed []*models.ProxyHost for _, raw := range rawIDs { id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil { continue } ph, err := models.GetProxyHost(s.DB, id) if err != nil || ph == nil { continue } // Ownership check: admin can change any, others only their own. if !isAdmin && (!s.canManageOwned(cu, ph.OwnerID)) { continue } if ph.MaintenanceMode != maint { ph.MaintenanceMode = maint if err := models.UpdateProxyHost(s.DB, ph); err != nil { log.Printf("bulkMaintenance: update %d: %v", id, err) continue } changed = append(changed, ph) } } s.syncChangedProxyHosts(cu.Email, changed) http.Redirect(w, r, "/proxy-hosts", http.StatusSeeOther) } // syncChangedProxyHosts applies a bulk action: each server that owns a changed // host is synced once, then the hosts follow their automatic deployment // targets (issue #150) with one sync per target. Only the first host's server // used to be synced, so hosts selected across servers stayed stale elsewhere. func (s *Server) syncChangedProxyHosts(actor string, changed []*models.ProxyHost) { sources := deployBatch{} for _, ph := range changed { sources[ph.ServerID] = true } s.syncDeployBatch(sources) targets := deployBatch{} for _, ph := range changed { s.followProxyHostTargets(actor, ph, targets) } s.syncDeployBatch(targets) } // bulkCertificateProxyHosts applies one certificate selection to many proxy // hosts. certificate_id=0 clears custom certs back to Auto/ACME. func (s *Server) bulkCertificateProxyHosts(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } cu := s.currentUser(r) if cu == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } isAdmin := cu.Role == models.RoleAdmin certID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("certificate_id")), 10, 64) if err != nil || certID < 0 { http.Error(w, "invalid certificate", http.StatusBadRequest) return } sid := s.currentServerID(r) if certID > 0 { allowed := false certs, err := s.certOptionListForRequest(r) if err != nil { http.Error(w, "load certificates: "+err.Error(), http.StatusInternalServerError) return } for _, c := range certs { if c.ID == certID { allowed = true break } } if !allowed { http.Error(w, "certificate not found or not allowed", http.StatusForbidden) return } } rawIDs := r.Form["ids[]"] if len(rawIDs) == 0 { http.Redirect(w, r, "/proxy-hosts", http.StatusSeeOther) return } updated := 0 for _, raw := range rawIDs { id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil { continue } ph, err := models.GetProxyHost(s.DB, id) if err != nil || ph == nil || ph.ServerID != sid { continue } if !isAdmin && (!s.canManageOwned(cu, ph.OwnerID)) { continue } if ph.CertificateID == certID { continue } ph.CertificateID = certID if err := models.UpdateProxyHost(s.DB, ph); err != nil { log.Printf("bulkCertificate: update proxy %d: %v", id, err) continue } updated++ } if updated > 0 { _ = models.LogActivity(s.DB, sid, cu.Email, "proxy_bulk_certificate", fmt.Sprintf("cert:%d", certID), fmt.Sprintf("%d proxy host(s)", updated), true) if err := s.syncCaddy(sid, true); err != nil { log.Printf("bulkCertificate: syncCaddy(%d): %v", sid, err) } } http.Redirect(w, r, "/proxy-hosts", http.StatusSeeOther) } // bulkDeleteProxyHosts deletes a set of proxy hosts in one shot. // Expects form field: ids[]={id,...}. // Admin can delete any host; non-admins can only delete their own. func (s *Server) bulkDeleteProxyHosts(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } isAdmin := cu.Role == models.RoleAdmin _ = r.ParseForm() ids := r.Form["ids[]"] if len(ids) == 0 { http.Redirect(w, r, "/proxy-hosts", http.StatusSeeOther) return } sid := s.currentServerID(r) deleted := 0 var notes []fleetDeletionNote // v2.59.0 (issue #120) for _, raw := range ids { id, err := strconv.ParseInt(raw, 10, 64) if err != nil { continue } // Ownership check: admin can delete any, others only their own. if !isAdmin { ph, err := models.GetProxyHost(s.DB, id) if err != nil || ph == nil || !s.canManageOwned(cu, ph.OwnerID) { continue } } note := s.noteFleetDeletion(models.FleetResourceProxy, id, "") if err := models.DeleteProxyHost(s.DB, id); err != nil { log.Printf("bulk-delete proxy host %d: %v", id, err) continue } deleted++ notes = append(notes, note) _ = models.LogActivity(s.DB, sid, cu.Email, "proxy_host_delete", "id", strconv.FormatInt(id, 10), true) } if deleted > 0 { if err := s.syncCaddyOpts(sid, false, true); err != nil { log.Printf("bulk-delete: sync error: %v", err) } } for _, n := range notes { s.propagateNotedDeletion(cu.Email, n) } http.Redirect(w, r, "/proxy-hosts", http.StatusSeeOther) } // reorderProxyHosts — v2.11.11: writes a new sort_order for each proxy // host based on its index in the submitted ids[] list. Multiplied by 10 // so future single-row Sort Order edits can wedge between drag-saved // rows without a full re-renumber. Skips Caddy sync — list ordering is // UI-only and doesn't change the generated config. Non-admins can only // reorder rows they own; foreign rows in the list are silently ignored. func (s *Server) reorderProxyHosts(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } cu := s.currentUser(r) if cu == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } isAdmin := cu.Role == models.RoleAdmin rawIDs := r.Form["ids[]"] if len(rawIDs) == 0 { w.WriteHeader(http.StatusOK) return } for i, raw := range rawIDs { id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil { continue } if !isAdmin { ph, err := models.GetProxyHost(s.DB, id) if err != nil || ph == nil || !s.canManageOwned(cu, ph.OwnerID) { continue } } if _, err := s.DB.Exec(`UPDATE proxy_hosts SET sort_order=? WHERE id=?`, i*10, id); err != nil { log.Printf("reorderProxyHosts: update %d: %v", id, err) } } w.WriteHeader(http.StatusOK) } // reorderRedirectionHosts — v2.11.11: parallel of reorderProxyHosts for // the /redirection-hosts list page. func (s *Server) reorderRedirectionHosts(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } cu := s.currentUser(r) if cu == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } isAdmin := cu.Role == models.RoleAdmin rawIDs := r.Form["ids[]"] if len(rawIDs) == 0 { w.WriteHeader(http.StatusOK) return } for i, raw := range rawIDs { id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil { continue } if !isAdmin { rh, err := models.GetRedirectionHost(s.DB, id) if err != nil || rh == nil || !s.canManageOwned(cu, rh.OwnerID) { continue } } if _, err := s.DB.Exec(`UPDATE redirection_hosts SET sort_order=? WHERE id=?`, i*10, id); err != nil { log.Printf("reorderRedirectionHosts: update %d: %v", id, err) } } w.WriteHeader(http.StatusOK) } // bulkDeleteCertificates — v2.11.10: deletes a set of certificates in one // shot. Honours the same ownership + in-use checks as deleteCertificate: // admin can delete any; non-admin can only delete their own AND only when // no other user's site still references the cert. Rows that fail any // guard are silently skipped so a partial bulk delete still succeeds for // the rows that pass. func (s *Server) bulkDeleteCertificates(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } isAdmin := cu.Role == models.RoleAdmin _ = r.ParseForm() ids := r.Form["ids[]"] if len(ids) == 0 { http.Redirect(w, r, "/certificates", http.StatusSeeOther) return } sid := s.currentServerID(r) deleted := 0 for _, raw := range ids { id, err := strconv.ParseInt(raw, 10, 64) if err != nil { continue } if !isAdmin { cert, err := models.GetCertificate(s.DB, id) if err != nil || cert == nil { continue } if !s.canManageOwned(cu, cert.OwnerID) { continue } if foreign, _ := models.CertificateInUseByOthers(s.DB, id, cu.ID); foreign > 0 { continue } } if err := models.DeleteCertificate(s.DB, id); err != nil { log.Printf("bulk-delete cert %d: %v", id, err) continue } s.deleteCertificateProbe(id) s.deleteCertificateExportStatus(id) deleted++ _ = models.LogActivity(s.DB, sid, cu.Email, "cert_delete", fmt.Sprintf("cert:%d", id), "", true) } if deleted > 0 { // forceTLS=true on cert delete (matches deleteCertificate) so any // host that was using the deleted cert reverts to auto-issuance. if err := s.syncCaddy(sid, true); err != nil { log.Printf("bulk-delete cert: sync error: %v", err) } } http.Redirect(w, r, "/certificates", http.StatusSeeOther) } // importPorkbunCertificatePage — v2.14.0: renders the Porkbun certificate // import page. Lists all domains on the Porkbun account so the user can // pick one and pull its SSL bundle directly into CaddyUI. func (s *Server) importPorkbunCertificatePage(w http.ResponseWriter, r *http.Request) { apiKey, _ := models.GetSetting(s.DB, settingPBAPIKey) secretKey, _ := models.GetSetting(s.DB, settingPBSecretKey) if apiKey == "" || secretKey == "" { s.render(w, r, "certificate_import_porkbun.html", map[string]any{ "User": s.currentUser(r), "Section": "certs", "Error": "Porkbun API credentials are not configured. Go to Settings → DNS Providers to add them.", }) return } pb := porkbun.New(apiKey, secretKey) domains, err := pb.ListDomains() if err != nil { s.render(w, r, "certificate_import_porkbun.html", map[string]any{ "User": s.currentUser(r), "Section": "certs", "Error": "Failed to list Porkbun domains: " + err.Error(), }) return } s.render(w, r, "certificate_import_porkbun.html", map[string]any{ "User": s.currentUser(r), "Section": "certs", "Domains": domains, }) } // importPorkbunCertificate — v2.14.0: fetches the SSL bundle for the // selected domain from Porkbun and creates a Certificate record in CaddyUI. func (s *Server) importPorkbunCertificate(w http.ResponseWriter, r *http.Request) { domain := strings.TrimSpace(r.FormValue("domain")) if domain == "" { http.Error(w, "domain is required", http.StatusBadRequest) return } apiKey, _ := models.GetSetting(s.DB, settingPBAPIKey) secretKey, _ := models.GetSetting(s.DB, settingPBSecretKey) if apiKey == "" || secretKey == "" { http.Error(w, "Porkbun credentials not configured", http.StatusBadRequest) return } pb := porkbun.New(apiKey, secretKey) bundle, err := pb.RetrieveSSL(domain) if err != nil { s.render(w, r, "certificate_import_porkbun.html", map[string]any{ "User": s.currentUser(r), "Section": "certs", "Error": "Failed to retrieve certificate from Porkbun: " + err.Error(), "Selected": domain, }) return } cu := s.currentUser(r) var ownerID int64 if cu != nil && cu.Role != models.RoleAdmin { ownerID = cu.ID } cert := &models.Certificate{ Name: domain, Domains: domain, Source: models.CertSourcePEM, CertPEM: bundle.CertificateChain, KeyPEM: bundle.PrivateKey, } sid := s.currentServerID(r) id, err := models.CreateCertificate(s.DB, sid, ownerID, cert) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = models.LogActivity(s.DB, sid, s.currentUserEmail(r), "cert_create", fmt.Sprintf("cert:%d", id), domain+" (Porkbun import)", true) s.trySyncCaddy(sid, true) http.Redirect(w, r, "/certificates", http.StatusSeeOther) } // bulkToggleRawRoutes — v2.11.9: enables or disables a set of raw routes // in one shot. Mirrors bulkToggleProxyHosts. // Expects form fields: ids[]={id,...} and action=enable|disable. func (s *Server) bulkToggleRawRoutes(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } action := r.FormValue("action") if action != "enable" && action != "disable" { http.Error(w, "invalid action", http.StatusBadRequest) return } enabled := action == "enable" rawIDs := r.Form["ids[]"] if len(rawIDs) == 0 { http.Redirect(w, r, "/raw-routes", http.StatusSeeOther) return } cu := s.currentUser(r) isAdmin := cu != nil && cu.Role == models.RoleAdmin sid := s.currentServerID(r) var changed []*models.RawRoute for _, raw := range rawIDs { id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil { continue } rr, err := models.GetRawRoute(s.DB, id) if err != nil || rr == nil { continue } if !isAdmin && (cu == nil || !s.canManageOwned(cu, rr.OwnerID)) { continue } if rr.Enabled != enabled { rr.Enabled = enabled if err := models.UpdateRawRoute(s.DB, rr); err != nil { log.Printf("bulkToggleRaw: update %d: %v", id, err) continue } changed = append(changed, rr) } } if len(changed) > 0 { if err := s.syncCaddy(sid, false); err != nil { log.Printf("bulkToggleRaw: syncCaddy: %v", err) } targets := deployBatch{} // v2.68.3 (issue #150) for _, rr := range changed { s.followRawRouteTargets(s.currentUserEmail(r), rr, targets) } s.syncDeployBatch(targets) } http.Redirect(w, r, "/raw-routes", http.StatusSeeOther) } // bulkDeleteRawRoutes — v2.11.9: deletes a set of raw routes in one shot. // Admin can delete any; others only their own. func (s *Server) bulkDeleteRawRoutes(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } isAdmin := cu.Role == models.RoleAdmin _ = r.ParseForm() ids := r.Form["ids[]"] if len(ids) == 0 { http.Redirect(w, r, "/raw-routes", http.StatusSeeOther) return } sid := s.currentServerID(r) deleted := 0 var notes []fleetDeletionNote // v2.59.0 (issue #120) for _, raw := range ids { id, err := strconv.ParseInt(raw, 10, 64) if err != nil { continue } if !isAdmin { rr, err := models.GetRawRoute(s.DB, id) if err != nil || rr == nil || !s.canManageOwned(cu, rr.OwnerID) { continue } } note := s.noteFleetDeletion(models.FleetResourceRawRoute, id, "") if err := models.DeleteRawRoute(s.DB, id); err != nil { log.Printf("bulk-delete raw route %d: %v", id, err) continue } deleted++ notes = append(notes, note) _ = models.LogActivity(s.DB, sid, cu.Email, "raw_route_delete", "id", strconv.FormatInt(id, 10), true) } if deleted > 0 { if err := s.syncCaddyOpts(sid, false, true); err != nil { log.Printf("bulk-delete raw: sync error: %v", err) } } for _, n := range notes { s.propagateNotedDeletion(cu.Email, n) } http.Redirect(w, r, "/raw-routes", http.StatusSeeOther) } // bulkToggleRedirectionHosts — v2.11.6: enables or disables a set of // redirection hosts in one shot. Mirrors bulkToggleProxyHosts. // Expects form fields: ids[]={id,...} and action=enable|disable. func (s *Server) bulkToggleRedirectionHosts(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } action := r.FormValue("action") if action != "enable" && action != "disable" { http.Error(w, "invalid action", http.StatusBadRequest) return } enabled := action == "enable" rawIDs := r.Form["ids[]"] if len(rawIDs) == 0 { http.Redirect(w, r, "/redirection-hosts", http.StatusSeeOther) return } cu := s.currentUser(r) isAdmin := cu != nil && cu.Role == models.RoleAdmin sid := s.currentServerID(r) var changed []*models.RedirectionHost for _, raw := range rawIDs { id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil { continue } rh, err := models.GetRedirectionHost(s.DB, id) if err != nil || rh == nil { continue } if !isAdmin && (cu == nil || !s.canManageOwned(cu, rh.OwnerID)) { continue } if rh.Enabled != enabled { rh.Enabled = enabled if err := models.UpdateRedirectionHost(s.DB, rh); err != nil { log.Printf("bulkToggleRedir: update %d: %v", id, err) continue } changed = append(changed, rh) } } if len(changed) > 0 { if err := s.syncCaddy(sid, false); err != nil { log.Printf("bulkToggleRedir: syncCaddy: %v", err) } targets := deployBatch{} // v2.68.3 (issue #150) for _, rh := range changed { s.followRedirectionHostTargets(s.currentUserEmail(r), rh, targets) } s.syncDeployBatch(targets) } http.Redirect(w, r, "/redirection-hosts", http.StatusSeeOther) } // bulkDeleteRedirectionHosts — v2.11.6: deletes a set of redirection hosts. // Admin can delete any; others only their own. func (s *Server) bulkDeleteRedirectionHosts(w http.ResponseWriter, r *http.Request) { cu := s.currentUser(r) if cu == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } isAdmin := cu.Role == models.RoleAdmin _ = r.ParseForm() ids := r.Form["ids[]"] if len(ids) == 0 { http.Redirect(w, r, "/redirection-hosts", http.StatusSeeOther) return } sid := s.currentServerID(r) deleted := 0 var notes []fleetDeletionNote // v2.59.0 (issue #120) for _, raw := range ids { id, err := strconv.ParseInt(raw, 10, 64) if err != nil { continue } if !isAdmin { rh, err := models.GetRedirectionHost(s.DB, id) if err != nil || rh == nil || !s.canManageOwned(cu, rh.OwnerID) { continue } } note := s.noteFleetDeletion(models.FleetResourceRedirect, id, "") if err := models.DeleteRedirectionHost(s.DB, id); err != nil { log.Printf("bulk-delete redirection %d: %v", id, err) continue } deleted++ notes = append(notes, note) _ = models.LogActivity(s.DB, sid, cu.Email, "redirect_delete", "id", strconv.FormatInt(id, 10), true) } if deleted > 0 { if err := s.syncCaddyOpts(sid, false, true); err != nil { log.Printf("bulk-delete redir: sync error: %v", err) } } for _, n := range notes { s.propagateNotedDeletion(cu.Email, n) } http.Redirect(w, r, "/redirection-hosts", http.StatusSeeOther) } // toggleMaintenanceMode flips the maintenance_mode flag for a proxy host. func (s *Server) toggleMaintenanceMode(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { http.NotFound(w, r) return } ph, err := models.GetProxyHost(s.DB, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Security (v2.57.1): this handler flipped maintenance mode on any host by // ID. Every other mutation handler (toggle, bulk, edit, delete) checks // ownership; this one — and the bulk variant's single-host sibling — did not. if !s.canManageOwned(s.currentUser(r), ph.OwnerID) { http.Error(w, "forbidden", http.StatusForbidden) return } ph.MaintenanceMode = !ph.MaintenanceMode if err := models.UpdateProxyHost(s.DB, ph); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if err := s.syncCaddy(ph.ServerID, false); err != nil { log.Printf("toggleMaintenanceMode: syncCaddy: %v", err) } s.followProxyHostTargets(s.currentUserEmail(r), ph, nil) // v2.68.3 (issue #150) ref := r.Header.Get("Referer") if ref == "" { ref = "/proxy-hosts" } http.Redirect(w, r, ref, http.StatusSeeOther) } // toggleRedirectionHost flips the enabled state on a redirection host and triggers a sync. func (s *Server) toggleRedirectionHost(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) cu := s.currentUser(r) isAdmin := cu != nil && cu.Role == models.RoleAdmin if !isAdmin { rh, err := models.GetRedirectionHost(s.DB, id) if err != nil || rh == nil || !s.canManageOwned(cu, rh.OwnerID) { http.Error(w, "forbidden", http.StatusForbidden) return } } enabled, err := models.ToggleRedirectionHost(s.DB, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } action := "redirect_enable" if !enabled { action = "redirect_disable" } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), action, fmt.Sprintf("redirect:%d", id), "", true) s.trySyncCaddy(s.currentServerID(r), false) if rh, _ := models.GetRedirectionHost(s.DB, id); rh != nil { // v2.68.3 (issue #150) s.followRedirectionHostTargets(s.currentUserEmail(r), rh, nil) } ref := r.Header.Get("Referer") if ref == "" { ref = "/redirection-hosts" } http.Redirect(w, r, ref, http.StatusSeeOther) } // parseBasicAuthUsers collects basicauth_user[], basicauth_pass[], and // basicauth_hash[] form fields (indexed arrays, same name repeated per entry). // For each user: if a new password is provided it's bcrypt-hashed; if the // password field is empty the hidden existing hash is re-used (edit scenario). // Users with neither a new password nor an existing hash are skipped. func parseBasicAuthUsers(r *http.Request) ([]models.BasicAuthUser, error) { usernames := r.Form["basicauth_user"] passwords := r.Form["basicauth_pass"] hashes := r.Form["basicauth_hash"] var result []models.BasicAuthUser for i, username := range usernames { username = strings.TrimSpace(username) if username == "" { continue } pass := "" if i < len(passwords) { pass = strings.TrimSpace(passwords[i]) } existingHash := "" if i < len(hashes) { existingHash = hashes[i] } var hash string if pass != "" { h, err := auth.HashPassword(pass) if err != nil { return nil, fmt.Errorf("hashing password for %q: %w", username, err) } hash = h } else if existingHash != "" { hash = existingHash } else { // New user row with no password — skip. continue } result = append(result, models.BasicAuthUser{Username: username, BcryptHash: hash}) } return result, nil } // previewBasicAuthUsers returns the complete Basic Auth rows represented by // the unsaved form without hashing or exposing either new passwords or saved // bcrypt hashes in the preview response. func previewBasicAuthUsers(r *http.Request) []models.BasicAuthUser { usernames := r.Form["basicauth_user"] passwords := r.Form["basicauth_pass"] hashes := r.Form["basicauth_hash"] users := make([]models.BasicAuthUser, 0, len(usernames)) for i, rawUsername := range usernames { username := strings.TrimSpace(rawUsername) if username == "" { continue } passwordPresent := i < len(passwords) && strings.TrimSpace(passwords[i]) != "" hashPresent := i < len(hashes) && strings.TrimSpace(hashes[i]) != "" if !passwordPresent && !hashPresent { continue } users = append(users, models.BasicAuthUser{Username: username, BcryptHash: ""}) } return users } // buildBasicAuthPreviewHandler mirrors the authentication handler returned by // Caddy's adapter while keeping credential material redacted. It avoids a // remote /adapt call on every editor keystroke. func buildBasicAuthPreviewHandler(users []models.BasicAuthUser, realm string) map[string]any { if len(users) == 0 { return nil } accounts := make([]any, 0, len(users)) for _, user := range users { accounts = append(accounts, map[string]any{ "username": user.Username, "password": user.BcryptHash, }) } httpBasic := map[string]any{ "accounts": accounts, "hash": map[string]any{"algorithm": "bcrypt"}, "hash_cache": map[string]any{}, } if realm != "" && realm != "Restricted" { httpBasic["realm"] = realm } return map[string]any{ "handler": "authentication", "providers": map[string]any{ "http_basic": httpBasic, }, } } const previewRedacted = "" func redactPreviewRawQuery(raw string) (string, bool) { if raw == "" { return raw, false } var redacted strings.Builder redacted.Grow(len(raw)) changed := false segmentStart := 0 for i := 0; i <= len(raw); i++ { if i < len(raw) && raw[i] != '&' && raw[i] != ';' { continue } segment := raw[segmentStart:i] key := segment if equals := strings.IndexByte(segment, '='); equals >= 0 { key = segment[:equals] } decodedKey, err := url.QueryUnescape(key) if err != nil { // A malformed key cannot be classified safely. Preserve its name but // fail closed on its value so a credential cannot slip into Copy JSON. if equals := strings.IndexByte(segment, '='); equals >= 0 { segment = segment[:equals+1] + url.QueryEscape(previewRedacted) changed = true } } else if previewSensitiveKey(decodedKey) { segment = key + "=" + url.QueryEscape(previewRedacted) changed = true } redacted.WriteString(segment) if i < len(raw) { redacted.WriteByte(raw[i]) } segmentStart = i + 1 } return redacted.String(), changed } func redactPreviewRawUserinfo(raw string) (string, bool) { authorityStart := -1 if schemeSeparator := strings.Index(raw, "://"); schemeSeparator >= 0 { authorityStart = schemeSeparator + 3 } else if strings.HasPrefix(raw, "//") { authorityStart = 2 } if authorityStart < 0 { return raw, false } authorityEnd := len(raw) if separator := strings.IndexAny(raw[authorityStart:], "/?#"); separator >= 0 { authorityEnd = authorityStart + separator } authority := raw[authorityStart:authorityEnd] at := strings.LastIndexByte(authority, '@') if at < 0 { return raw, false } userinfo := "redacted" if strings.Contains(authority[:at], ":") { userinfo = "redacted:redacted" } redactedAuthority := userinfo + authority[at:] return raw[:authorityStart] + redactedAuthority + raw[authorityEnd:], true } func redactPreviewURL(raw string) string { var userinfoChanged bool raw, userinfoChanged = redactPreviewRawUserinfo(raw) queryChanged := false if queryStart := strings.IndexByte(raw, '?'); queryStart >= 0 { queryEnd := len(raw) if fragmentStart := strings.IndexByte(raw[queryStart+1:], '#'); fragmentStart >= 0 { queryEnd = queryStart + 1 + fragmentStart } redactedQuery, changed := redactPreviewRawQuery(raw[queryStart+1 : queryEnd]) if changed { raw = raw[:queryStart+1] + redactedQuery + raw[queryEnd:] queryChanged = true } } parsed, err := url.Parse(raw) if err != nil { // The raw-query pass above still removes sensitive values when the URL // itself is malformed and cannot be parsed safely. return raw } changed := userinfoChanged || queryChanged if parsed.User != nil { if _, hasPassword := parsed.User.Password(); hasPassword { parsed.User = url.UserPassword("redacted", "redacted") } else { parsed.User = url.User("redacted") } changed = true } if !changed { return raw } return parsed.String() } func previewSensitiveKey(key string) bool { normalized := strings.ToLower(strings.ReplaceAll(key, "_", "-")) if normalized == "authorization" || normalized == "proxy-authorization" || normalized == "cookie" || normalized == "set-cookie" { return true } return strings.Contains(normalized, "password") || strings.Contains(normalized, "secret") || strings.Contains(normalized, "token") || strings.Contains(normalized, "credential") || strings.Contains(normalized, "api-key") || strings.Contains(normalized, "apikey") } func redactPreviewShape(value any) any { switch typed := value.(type) { case []any: redacted := make([]any, len(typed)) for i := range redacted { redacted[i] = previewRedacted } return redacted case []string: redacted := make([]string, len(typed)) for i := range redacted { redacted[i] = previewRedacted } return redacted default: return previewRedacted } } // redactProxyRoutePreview scrubs known credential-bearing keys and headers // while preserving the route's structure. It also removes URL userinfo and // sensitive query parameters from arbitrary strings, including adapted // advanced handlers. func redactProxyRoutePreview(value any) any { switch typed := value.(type) { case map[string]any: for key, child := range typed { if previewSensitiveKey(key) { typed[key] = redactPreviewShape(child) } else { typed[key] = redactProxyRoutePreview(child) } } return typed case map[string][]string: for key, child := range typed { if previewSensitiveKey(key) { typed[key] = redactPreviewShape(child).([]string) } } return typed case map[string][]any: for key, child := range typed { if previewSensitiveKey(key) { typed[key] = redactPreviewShape(child).([]any) } else { for i := range child { child[i] = redactProxyRoutePreview(child[i]) } } } return typed case []any: for i := range typed { typed[i] = redactProxyRoutePreview(typed[i]) } return typed case string: return redactPreviewURL(typed) default: return typed } } // buildBasicAuthHandler adapts a Caddyfile basicauth block for the given users // via Caddy's /adapt endpoint and returns the authentication JSON handler. // Returns nil if the user list is empty or if adaptation fails (error is logged). // realm is the HTTP Basic Auth realm string shown in the browser prompt. func (s *Server) buildBasicAuthHandler(caddyCl *caddy.Client, users []models.BasicAuthUser, realm string) map[string]any { if len(users) == 0 { return nil } var sb strings.Builder sb.WriteString("localhost {\n basicauth {\n") for _, u := range users { sb.WriteString(fmt.Sprintf(" %s %s\n", u.Username, u.BcryptHash)) } sb.WriteString(" }\n}\n") result, err := caddyCl.Adapt(sb.String()) if err != nil { log.Printf("caddy sync: basicauth adapt failed: %v", err) return nil } routes := extractAdaptedRoutes(result.Result) if len(routes) == 0 { return nil } handles, _ := routes[0]["handle"].([]any) for _, h := range handles { if m, ok := h.(map[string]any); ok && m["handler"] == "authentication" { if realm != "" && realm != "Restricted" { // Inject the custom realm into the http_basic provider map. if providers, ok := m["providers"].(map[string]any); ok { if httpBasic, ok := providers["http_basic"].(map[string]any); ok { httpBasic["realm"] = realm } } } return m } } return nil } // marshalExtraUpstreams reads the repeated "extra_upstream" form fields, // filters empty values, and marshals the result to JSON (Feature D). func marshalExtraUpstreams(r *http.Request) string { var list []string for _, v := range r.Form["extra_upstream"] { v = strings.TrimSpace(v) if v != "" { list = append(list, v) } } b, _ := json.Marshal(list) if b == nil { return "[]" } return string(b) } // otherManagedServers returns all managed Caddy servers except the one currently // selected in the request cookie. Used to populate the cross-deploy checkbox list. func (s *Server) otherManagedServers(r *http.Request) []models.CaddyServer { current := s.currentServerID(r) out := s.otherManagedServersExcept(current) // v2.57.0 (issue #120): flag the selected server's persistent default // deploy targets so every resource form can show them as always-on. if src, err := models.GetCaddyServer(s.DB, current); err == nil { defaults := src.DefaultDeployTargetSet() for i := range out { out[i].IsDefaultDeployTarget = defaults[out[i].ID] } } return out } // effectiveDeployTargets is the set of servers a web-UI save on sourceServerID // mirrors to: the form's one-off "Also deploy to" picks plus the source // server's persistent DefaultDeployTargets (v2.57.0, issue #120), so a fleet // whose edge nodes are always fed from one source Caddy never has to re-tick // the same boxes on every create and edit. A server is never its own target, // and each target appears once. // // Node-local resources are still never deployed — the cross-deploy functions // refuse them — so marking a resource node-local is how one opts a single // resource out of the defaults. func (s *Server) effectiveDeployTargets(sourceServerID int64, formTargets []int64) []int64 { out := make([]int64, 0, len(formTargets)) seen := map[int64]bool{sourceServerID: true} add := func(ids []int64) { for _, id := range ids { if id > 0 && !seen[id] { seen[id] = true out = append(out, id) } } } add(formTargets) if src, err := models.GetCaddyServer(s.DB, sourceServerID); err == nil { add(src.DefaultDeployTargetIDs()) } return out } // otherManagedServersExcept returns all managed Caddy servers except excludeID. // v2.56.0 (issue #113): the Caddy server form's layer4 fleet-replication // picker needs this directly rather than through otherManagedServers — unlike // a proxy/redirect/raw-route, which always belongs to the currently-selected // server cookie, a CaddyServer row being edited can be ANY server regardless // of which one is currently selected, so excluding the cookie's server would // be wrong (it could list the server being edited as its own deploy target, // or wrongly hide an unrelated one). func (s *Server) otherManagedServersExcept(excludeID int64) []models.CaddyServer { all, _ := models.ListCaddyServers(s.DB) var out []models.CaddyServer for _, srv := range all { if srv.ID != excludeID && srv.Type == models.CaddyServerTypeManaged { out = append(out, srv) } } return out } // parseDeployTo reads the "deploy_to" multi-value form field and returns the // list of server IDs the user wants to mirror the record to. func parseDeployTo(r *http.Request) []int64 { var out []int64 for _, v := range r.Form["deploy_to"] { id, err := strconv.ParseInt(v, 10, 64) if err == nil && id > 0 { out = append(out, id) } } return out } // crossDeployProxyHost creates or updates the source host's paired row on each // target server and triggers one Caddy sync per target. The durable deployment // mapping keeps later edits attached even when the hostname changes. Target // DNS records and custom certificate selections remain server-specific. // Cross-deployed records are always global/admin-owned (ownerID=0). func (s *Server) crossDeployProxyHost(actor string, sourceServerID int64, p *models.ProxyHost, serverIDs []int64) []string { return s.crossDeployProxyHostBatch(actor, sourceServerID, p, serverIDs, nil) } // deployBatch collects the targets that a run of cross-deploys changed, so a // bulk action syncs each target once instead of once per resource. A nil // batch syncs each target straight away. type deployBatch map[int64]bool func (b deployBatch) changed(s *Server, serverID int64, what string) error { if b != nil { b[serverID] = true return nil } if err := s.syncCaddy(serverID, false); err != nil { log.Printf("cross-deploy %s sync server %d: %v", what, serverID, err) return fmt.Errorf("saved there, but pushing it to Caddy failed: %w", err) } return nil } // deployProblem names a target a copy did not reach, and why. A form save // shows these instead of reporting plain success: a refused copy — target // monitoring-only, a domain already claimed there, Caddy unreachable — used // to be recorded only in the Activity Log, so the save looked fine while the // target kept serving its old version of the host (issue #150). func (s *Server) deployProblem(serverID int64, err error) string { name := fmt.Sprintf("server #%d", serverID) if srv, e := models.GetCaddyServer(s.DB, serverID); e == nil && srv != nil { name = srv.Name } if strings.Contains(err.Error(), strconv.Quote(name)) { // validateFleetPair names it already return err.Error() } return name + ": " + err.Error() } // withDeployProblems appends the deploy_error notice to a redirect target // when a save did not reach every target. func withDeployProblems(target string, problems []string) string { if len(problems) == 0 { return target } sep := "?" if strings.Contains(target, "?") { sep = "&" } reasons := strings.Join(problems, "; ") reasons = strings.ToUpper(reasons[:1]) + reasons[1:] msg := "Saved, but not deployed to every server. " + reasons + ". The Activity Log has the details." return target + sep + "deploy_error=" + url.QueryEscape(msg) } func (s *Server) syncDeployBatch(b deployBatch) { for serverID := range b { if err := s.syncCaddy(serverID, false); err != nil { log.Printf("cross-deploy sync server %d: %v", serverID, err) } } } // v2.68.3 (issue #150): a quick change to a resource — the enable toggle, // maintenance mode, a bulk action — follows its server's automatic deployment // targets, as a form save does and as Layer4 proxies already did. Before, a // host disabled on the source kept serving, unchanged, on every target. // Node-local resources stay where they are without a skipped-log entry. func (s *Server) followProxyHostTargets(actor string, p *models.ProxyHost, batch deployBatch) { if p == nil || p.NodeLocal { return } if targets := s.effectiveDeployTargets(p.ServerID, nil); len(targets) > 0 { s.crossDeployProxyHostBatch(actor, p.ServerID, p, targets, batch) } } // Redirections and advanced routes carry no ServerID field; the row says. func (s *Server) followRedirectionHostTargets(actor string, rh *models.RedirectionHost, batch deployBatch) { if rh == nil { return } source := models.FleetResourceServerID(s.DB, models.FleetResourceRedirect, rh.ID) if targets := s.effectiveDeployTargets(source, nil); source > 0 && len(targets) > 0 { s.crossDeployRedirectionHostBatch(actor, source, rh, targets, batch) } } func (s *Server) followRawRouteTargets(actor string, rr *models.RawRoute, batch deployBatch) { if rr == nil || rr.NodeLocal { return } source := models.FleetResourceServerID(s.DB, models.FleetResourceRawRoute, rr.ID) if targets := s.effectiveDeployTargets(source, nil); source > 0 && len(targets) > 0 { s.crossDeployRawRouteBatch(actor, source, rr, targets, batch) } } func (s *Server) crossDeployProxyHostBatch(actor string, sourceServerID int64, p *models.ProxyHost, serverIDs []int64, batch deployBatch) (problems []string) { s.fleetDeployMu.Lock() defer s.fleetDeployMu.Unlock() // v2.33.0: a node-local host is pinned to the node it was created on — its // upstream only resolves there. Refuse to cross-deploy rather than create a // route on the target that points at nothing. Logged so the operator can // see why nothing happened; the form also hides the picker for these. if p.NodeLocal { _ = models.LogActivity(s.DB, sourceServerID, actor, "proxy_cross_deploy", fmt.Sprintf("proxy:%d", p.ID), "skipped: host is marked node-local", false) return nil } sourceCerts, _ := models.ListCertificates(s.DB, sourceServerID) for _, sid := range serverIDs { if _, _, err := s.validateFleetPair(sourceServerID, sid); err != nil { log.Printf("cross-deploy proxy target %d: %v", sid, err) _ = models.LogActivity(s.DB, sourceServerID, actor, "proxy_cross_deploy", fmt.Sprintf("server:%d", sid), err.Error(), false) problems = append(problems, s.deployProblem(sid, err)) continue } // Managed wildcard certificates are not attached by certificate_id; // Caddy selects them automatically by SNI. Copy every source-server // managed certificate that covers this host so the target behaves the // same instead of obtaining an unrelated per-host certificate. for _, cert := range sourceCerts { if cert.Source == models.CertSourceManaged && certificateCoversAnyDomain(cert, p.DomainList()) { if _, err := s.ensureCertificateOnServer(actor, sourceServerID, sid, cert, 0); err != nil { log.Printf("cross-deploy managed certificate to server %d: %v", sid, err) } } } s.ensureReferencedCertificate(actor, sourceServerID, sid, p.CertificateID) result, err := s.upsertFleetProxyHost(sourceServerID, sid, *p, 0) if err != nil { log.Printf("cross-deploy proxy to server %d: %v", sid, err) _ = models.LogActivity(s.DB, sid, actor, "proxy_cross_deploy", "proxy:new", p.Domains+": "+err.Error(), false) problems = append(problems, s.deployProblem(sid, err)) continue } detail := "already current " + p.Domains if result.Created { detail = "created " + p.Domains } else if result.Changed { detail = "updated " + p.Domains } _ = models.LogActivity(s.DB, sid, actor, "proxy_cross_deploy", fmt.Sprintf("proxy:%d", result.ID), detail, true) if result.Changed { if err := batch.changed(s, sid, "proxy"); err != nil { problems = append(problems, s.deployProblem(sid, err)) } } } return problems } func normalizedDomainSet(domains []string) map[string]struct{} { out := make(map[string]struct{}, len(domains)) for _, domain := range domains { domain = models.NormalizeHostname(domain) if domain != "" { out[domain] = struct{}{} } } return out } func sameDomainSet(a, b []string) bool { as, bs := normalizedDomainSet(a), normalizedDomainSet(b) if len(as) != len(bs) { return false } for domain := range as { if _, ok := bs[domain]; !ok { return false } } return true } func managedCertificateCovers(certDomain, host string) bool { certDomain = models.NormalizeHostname(certDomain) host = models.NormalizeHostname(host) if certDomain == "" || host == "" { return false } if certDomain == host { return true } if !strings.HasPrefix(certDomain, "*.") || strings.HasPrefix(host, "*.") { return false } suffix := certDomain[1:] // includes the leading dot if !strings.HasSuffix(host, suffix) { return false } label := strings.TrimSuffix(host, suffix) return label != "" && !strings.Contains(label, ".") } func certificateCoversAnyDomain(cert models.Certificate, hosts []string) bool { for _, certDomain := range cert.DomainList() { for _, host := range hosts { if managedCertificateCovers(certDomain, host) { return true } } } return false } // managedWildcardForHost returns the managed wildcard definition that Auto TLS // will reuse for host. Exact managed subjects are intentionally excluded: the // skip_certificates behavior only suppresses exact-host issuance when a // wildcard already covers that host. func managedWildcardForHost(certs []models.Certificate, host string) *models.Certificate { for i := range certs { if certs[i].Source != models.CertSourceManaged { continue } for _, certDomain := range certs[i].DomainList() { if strings.HasPrefix(models.NormalizeHostname(certDomain), "*.") && managedCertificateCovers(certDomain, host) { return &certs[i] } } } return nil } // ensureCertificateOnServer creates or updates the target's copy of a // certificate (see fleetCertificateCopy). Managed definitions copy their // DNS-01 settings only — each Caddy still orders its own certificate; stored // PEMs copy their content; file-path certificates are copied as PEM when // readable, by path reference otherwise (v2.41.0). func (s *Server) ensureCertificateOnServer(actor string, sourceServerID, targetServerID int64, source models.Certificate, ownerID int64) (bool, error) { result, byPath, err := s.upsertFleetCertificate(sourceServerID, targetServerID, source, ownerID) if err != nil { return false, err } detail := "already current " + source.Domains if result.Created { detail = "created " + source.Domains } else if result.Changed { detail = "updated " + source.Domains } if byPath { detail += " (by file path — the files must exist on the target)" } _ = models.LogActivity(s.DB, targetServerID, actor, "cert_cross_deploy", fmt.Sprintf("cert:%d", result.ID), detail, true) return result.Changed, nil } // ensureReferencedCertificate copies the custom certificate a host refers to // onto the target before the host itself is copied, so the created host can // resolve the reference (mappedCertificateID). v2.41.0. func (s *Server) ensureReferencedCertificate(actor string, sourceServerID, targetServerID, certificateID int64) { if certificateID == 0 { return } cert, err := models.GetCertificate(s.DB, certificateID) if err != nil || cert == nil { return } if _, err := s.ensureCertificateOnServer(actor, sourceServerID, targetServerID, *cert, 0); err != nil { log.Printf("cross-deploy referenced certificate %d to server %d: %v", certificateID, targetServerID, err) } } func (s *Server) crossDeployCertificate(actor string, sourceServerID int64, cert models.Certificate, serverIDs []int64) { s.fleetDeployMu.Lock() defer s.fleetDeployMu.Unlock() for _, targetServerID := range serverIDs { if _, _, err := s.validateFleetPair(sourceServerID, targetServerID); err != nil { log.Printf("cross-deploy certificate target %d: %v", targetServerID, err) continue } changed, err := s.ensureCertificateOnServer(actor, sourceServerID, targetServerID, cert, 0) if err != nil { log.Printf("cross-deploy certificate to server %d: %v", targetServerID, err) _ = models.LogActivity(s.DB, targetServerID, actor, "cert_cross_deploy", "cert:new", cert.Domains, false) continue } if changed { s.trySyncCaddy(targetServerID, true) } } } // crossDeployRedirectionHost creates or updates the paired redirect on each // target server and triggers a Caddy sync on each. // Cross-deployed records are always global/admin-owned (ownerID=0). func (s *Server) crossDeployRedirectionHost(actor string, sourceServerID int64, rh *models.RedirectionHost, serverIDs []int64) []string { return s.crossDeployRedirectionHostBatch(actor, sourceServerID, rh, serverIDs, nil) } func (s *Server) crossDeployRedirectionHostBatch(actor string, sourceServerID int64, rh *models.RedirectionHost, serverIDs []int64, batch deployBatch) (problems []string) { s.fleetDeployMu.Lock() defer s.fleetDeployMu.Unlock() for _, sid := range serverIDs { if _, _, err := s.validateFleetPair(sourceServerID, sid); err != nil { log.Printf("cross-deploy redirect target %d: %v", sid, err) _ = models.LogActivity(s.DB, sourceServerID, actor, "redirect_cross_deploy", fmt.Sprintf("server:%d", sid), err.Error(), false) problems = append(problems, s.deployProblem(sid, err)) continue } s.ensureReferencedCertificate(actor, sourceServerID, sid, rh.CertificateID) result, err := s.upsertFleetRedirectionHost(sourceServerID, sid, *rh, 0) if err != nil { log.Printf("cross-deploy redirect to server %d: %v", sid, err) _ = models.LogActivity(s.DB, sid, actor, "redirect_cross_deploy", "redirect:new", rh.Domains+": "+err.Error(), false) problems = append(problems, s.deployProblem(sid, err)) continue } detail := "already current " + rh.Domains if result.Created { detail = "created " + rh.Domains } else if result.Changed { detail = "updated " + rh.Domains } _ = models.LogActivity(s.DB, sid, actor, "redirect_cross_deploy", fmt.Sprintf("redirect:%d", result.ID), detail, true) if result.Changed { if err := batch.changed(s, sid, "redirect"); err != nil { problems = append(problems, s.deployProblem(sid, err)) } } } return problems } // crossDeployRawRoute mirrors an advanced (raw) route onto the given fleet // targets, exactly as crossDeployProxyHost/crossDeployRedirectionHost do for // their resource types. // // v2.27.0 (issue #38): advanced routes were the only resource with no "Also // deploy to" option. The only way to get one onto a second node was a full // fleet sync of the destination, which overwrites everything else configured // there — a destructive workaround for a routine task. The fleet upsert // primitive (upsertFleetRawRoute) already existed for the whole-fleet sync // path; this just wires it to the per-route form like the other two types. func (s *Server) crossDeployRawRoute(actor string, sourceServerID int64, rr *models.RawRoute, serverIDs []int64) []string { return s.crossDeployRawRouteBatch(actor, sourceServerID, rr, serverIDs, nil) } func (s *Server) crossDeployRawRouteBatch(actor string, sourceServerID int64, rr *models.RawRoute, serverIDs []int64, batch deployBatch) (problems []string) { s.fleetDeployMu.Lock() defer s.fleetDeployMu.Unlock() // v2.33.0 — see crossDeployProxyHost. if rr.NodeLocal { _ = models.LogActivity(s.DB, sourceServerID, actor, "raw_cross_deploy", fmt.Sprintf("raw:%d", rr.ID), "skipped: route is marked node-local", false) return nil } for _, sid := range serverIDs { if _, _, err := s.validateFleetPair(sourceServerID, sid); err != nil { log.Printf("cross-deploy raw route target %d: %v", sid, err) _ = models.LogActivity(s.DB, sourceServerID, actor, "raw_cross_deploy", fmt.Sprintf("server:%d", sid), err.Error(), false) problems = append(problems, s.deployProblem(sid, err)) continue } s.ensureReferencedCertificate(actor, sourceServerID, sid, rr.CertificateID) result, err := s.upsertFleetRawRoute(sourceServerID, sid, *rr, 0) if err != nil { log.Printf("cross-deploy raw route to server %d: %v", sid, err) _ = models.LogActivity(s.DB, sid, actor, "raw_cross_deploy", "raw:new", rr.Label+": "+err.Error(), false) problems = append(problems, s.deployProblem(sid, err)) continue } detail := "already current " + rr.Label if result.Created { detail = "created " + rr.Label } else if result.Changed { detail = "updated " + rr.Label } _ = models.LogActivity(s.DB, sid, actor, "raw_cross_deploy", fmt.Sprintf("raw:%d", result.ID), detail, true) if result.Changed { if err := batch.changed(s, sid, "raw route"); err != nil { problems = append(problems, s.deployProblem(sid, err)) } } } return problems } // parsePEMExpiry decodes the first PEM certificate block in pemData and // returns its NotAfter expiry time, or nil if it cannot be parsed. func parsePEMExpiry(pemData string) *time.Time { cert := parsePEMLeaf(pemData) if cert == nil { return nil } t := cert.NotAfter return &t } // certView wraps a Certificate with computed expiry metadata for the template. // // CanEdit is the per-row ownership verdict precomputed server-side so the // template doesn't have to re-do the (admin || owner.ID == viewer.ID) logic // every row. True for admin on any row; true for user-role on their own // uploads; false on another user's row or on a global/admin row that a // user-role viewer is seeing through the dropdown scope. type certView struct { models.Certificate ExpiresAt *time.Time DaysLeft int // positive = days until expiry; negative = already expired CanEdit bool // per-row ownership (see above) IsUnused bool // custom PEM/path certificate with no resource reference Lifecycle *models.CertificateLifecycleStatus // v2.39.0: where ExpiresAt came from — "stored" (PEM in the DB), "file" // (CertPath readable from this container) or "probe" (the last live TLS // handshake with the node, see certificate_probe.go). Probe is the last // probe result for any custom certificate; ServedDiffers flags a file or // stored PEM whose serial is not what Caddy is serving — typically a // renewed file Caddy has not reloaded yet. ExpirySource string Probe *liveCertificateInfo ServedDiffers bool } type autoDomainView struct { Domain string CertificateName string UsesWildcard bool Lifecycle *models.CertificateLifecycleStatus } func certificateLifecycleForDomains(states []models.CertificateLifecycleStatus, domains []string) *models.CertificateLifecycleStatus { priority := map[string]int{"active": 1, "obtaining": 2, "renewing": 3, "retrying": 4, "error": 5, "revoked": 6} var best *models.CertificateLifecycleStatus for i := range states { identifier := models.NormalizeHostname(states[i].Identifier) matched := false for _, domain := range domains { domain = models.NormalizeHostname(domain) if identifier == domain || managedCertificateCovers(identifier, domain) { matched = true break } } if !matched { continue } if best == nil || priority[states[i].Phase] > priority[best.Phase] || (priority[states[i].Phase] == priority[best.Phase] && states[i].UpdatedAt.After(best.UpdatedAt)) { candidate := states[i] best = &candidate } } return best } // dnsProviderViewData builds the template data describing which DNS // providers have credentials configured. Shared by newProxyHost / // editProxyHost / renderProxyHostFormError so the form picker renders // consistently across all three entry points. // // Returns a list of descriptors for each enabled provider (keyed for // direct use by the form's . Empty/"0" means global (the default). // We never trust this field coming from a non-admin — the branch above // short-circuits them to their own ID regardless of what they posted. if v := strings.TrimSpace(r.FormValue("owner_id")); v != "" { if parsed, err := strconv.ParseInt(v, 10, 64); err == nil && parsed >= 0 { ownerID = parsed } } } // v2.42.1 (issue #74): refuse a host Caddy would not load instead of // saving it and failing every later sync. if errMsg := s.previewProxyHostValidate(s.currentServerID(r), p); errMsg != "" { s.renderProxyHostFormError(w, r, p, errMsg) return } id, err := models.CreateProxyHost(s.DB, s.currentServerID(r), ownerID, p) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } p.ID = id p.ServerID = s.currentServerID(r) _ = models.SetProxyHostProfile(s.DB, id, s.hostProfileFromForm(r)) // v2.60.0 (issue #124) if err := models.UpdateProxyHostDNSProfile(s.DB, id, p.DNSProfileID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Unified DNS: create A record if a provider + zone was selected. // dnsCreateRecord is a no-op when DNSProvider is empty, so no branch // needed here — just call it unconditionally. // // v2.5.6: the override-existing-record path was removed. When the // form's pre-flight check-record sees a collision, the user now has // to clear it by hand in the provider console — CaddyUI never // deletes records it doesn't own. Safer on shared zones, and avoids // any chance of wiping an unrelated service's A record here. dnsWorkflow := p.DNSProvider != "" && p.DNSZoneID != "" if dnsWorkflow && !p.DNSSkipRecord { s.dnsCreateRecord(s.currentServerID(r), id, p) } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "proxy_create", fmt.Sprintf("proxy:%d", id), p.Domains, true) s.trySyncCaddy(s.currentServerID(r), p.CertificateID != 0) { // v2.12.51: sendNotification fans out to every configured channel // (generic webhook + ntfy + future Telegram/Discord/Gotify) and // runs each in its own goroutine, replacing the per-channel // `if URL { go func() }` pattern that used to live here. payload, _ := json.Marshal(map[string]any{ "event": "proxy_host_created", "message": "Proxy host created: " + p.Domains, "domains": p.Domains, }) sendNotification(s.DB, payload) } var deployProblems []string if len(deployTo) > 0 { deployProblems = s.crossDeployProxyHost(s.currentUserEmail(r), s.currentServerID(r), p, deployTo) } // v2.5.2: when a managed DNS record was created, park the user on the // deploying page so they can watch DNS propagate + cert issue instead // of landing on an "active" row that can't actually be opened yet. if dnsWorkflow { http.Redirect(w, r, withDeployProblems(fmt.Sprintf("/proxy-hosts/%d/deploying", id), deployProblems), http.StatusSeeOther) return } http.Redirect(w, r, withDeployProblems("/proxy-hosts", deployProblems), http.StatusSeeOther) } func (s *Server) editProxyHost(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) p, err := models.GetProxyHost(s.DB, id) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } cu := s.currentUser(r) isAdmin := cu != nil && cu.Role == models.RoleAdmin if !isAdmin { if !s.canManageOwned(cu, p.OwnerID) { http.Error(w, "forbidden", http.StatusForbidden) return } } certs, _ := s.certListForRequest(r) s.render(w, r, "proxy_host_form.html", s.withProfileViewData(s.applyDNSViewData(s.currentServerID(r), map[string]any{ "User": s.currentUser(r), "Host": p, "Certificates": certs, "Users": s.adminUserList(r), "OtherServers": s.otherManagedServers(r), "Section": "proxy", }), models.ProxyHostProfileID(s.DB, p.ID))) } func (s *Server) updateProxyHost(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) cu := s.currentUser(r) isAdmin := cu != nil && cu.Role == models.RoleAdmin // Ownership check before parsing form if !isAdmin { existing, err := models.GetProxyHost(s.DB, id) if err != nil || existing == nil || !s.canManageOwned(cu, existing.OwnerID) { http.Error(w, "forbidden", http.StatusForbidden) return } } p, err := parseProxyHostForm(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } p.ID = id s.applyDNSFormSelection(p) if errMsg := validateSSLFlags(p.SSLEnabled, p.SSLForced, p.CertificateID); errMsg != "" { s.renderProxyHostFormError(w, r, p, errMsg) return } if errMsg := validateForwardAuthURL(p.ForwardAuthURL); errMsg != "" { // v2.59.5 (issues #124/#125) s.renderProxyHostFormError(w, r, p, errMsg) return } if pid := s.hostProfileFromForm(r); pid != 0 && s.profileWAFOn(pid) { // v2.61.0: WAF needs the Coraza module if errMsg := s.wafPreflight([]int64{s.currentServerID(r)}); errMsg != "" { s.renderProxyHostFormError(w, r, p, errMsg) return } } if errMsg := s.validateProxyAdvanced(s.caddyForRequest(r), p); errMsg != "" { s.renderProxyHostFormError(w, r, p, errMsg) return } // v2.7.7: refuse to save when the new domain list collides with another // proxy or redirect. excludeProxyID=p.ID lets the user save their own // edit unchanged — only OTHER rows count as conflicts. Symmetric with the // create path above; same rationale (Caddy resolves routes by hostname). if conflict, err := models.DomainsConflict(s.DB, s.currentServerID(r), p.DomainList(), p.ID, 0); err != nil { s.renderProxyHostFormError(w, r, p, "Could not validate domains: "+err.Error()) return } else if conflict != "" { s.renderProxyHostFormError(w, r, p, fmt.Sprintf("Domain %q is already in use by another proxy or redirect on this server. Each domain can only be claimed once.", conflict)) return } // v2.7.8: zone/hostname match check — same as create path. Editing path // matters because users hit this most often when they renamed the // hostname on an existing row but the dropdown stayed on the old zone. if errMsg := validateZoneMatchesHostname(p.DNSProvider, p.DNSZoneID, p.DNSZoneName, p.DomainList()); errMsg != "" { s.renderProxyHostFormError(w, r, p, errMsg) return } if errMsg := s.validateManagedDNSRecordTarget(s.currentServerID(r), p.DNSProvider, p.DNSZoneID, p.DNSSkipRecord); errMsg != "" { s.renderProxyHostFormError(w, r, p, errMsg) return } // Parse and hash basic auth users; preserve existing hashes if password left blank. if r.FormValue("basicauth_enabled") == "on" { p.BasicAuthEnabled = true baUsers, err := parseBasicAuthUsers(r) if err != nil { s.renderProxyHostFormError(w, r, p, "Basic auth error: "+err.Error()) return } usersJSON, _ := json.Marshal(baUsers) p.BasicAuthUsers = string(usersJSON) } else { p.BasicAuthEnabled = false p.BasicAuthUsers = "[]" } // Parse extra upstreams (Feature D). p.ExtraUpstreams = marshalExtraUpstreams(r) // SSRF guard (GHSA-r4wm-rgc5-q834): re-validate on edit so a non-admin can't // switch an existing host's upstream to an internal management address. if msg := s.validateProxyUpstreamsForUser(cu, p); msg != "" { s.renderProxyHostFormError(w, r, p, msg) return } deployTo := s.effectiveDeployTargets(s.currentServerID(r), parseDeployTo(r)) old, _ := models.GetProxyHost(s.DB, id) // Unified DNS lifecycle. A record needs replacing when: // 1. The user switched provider or cleared DNS entirely // 2. The user picked a different zone on the same provider // 3. The Domains list changed in any way — added alias, removed // alias, renamed primary, or reordered // In any of those cases we delete every old record and create a // fresh record per current hostname after the DB save succeeds. // v2.5.10: comparison widened from FirstDomain to the full list // so adding/removing an alias actually provisions/removes the // matching A record — pre-v2.5.10 only the first-domain change // triggered this path, leaving aliases with no DNS. var oldDomains []string if old != nil { oldDomains = old.DomainList() } newDomains := p.DomainList() domainChanged := !slices.Equal(oldDomains, newDomains) providerChanged := old != nil && old.DNSProvider != p.DNSProvider profileChanged := old != nil && old.DNSProfileID != p.DNSProfileID zoneChanged := old != nil && old.DNSZoneID != p.DNSZoneID recordModeChanged := old != nil && old.DNSSkipRecord != p.DNSSkipRecord needDelete := old != nil && old.DNSRecordID != "" && (p.DNSProvider == "" || p.DNSSkipRecord || providerChanged || profileChanged || zoneChanged || domainChanged || recordModeChanged) if needDelete { s.dnsDeleteRecord(old.DNSProvider, old.DNSProfileID, old.DNSZoneID, old.DNSZoneName, old.DNSRecordID) p.DNSRecordID = "" } else if old != nil { // Preserve existing record + zone metadata when nothing routing- // relevant changed. The form doesn't resubmit record IDs, so // without this the DB save would clear it. p.DNSRecordID = old.DNSRecordID } needCreate := p.DNSProvider != "" && p.DNSZoneID != "" && !p.DNSSkipRecord && (p.DNSRecordID == "" || providerChanged || profileChanged || zoneChanged || domainChanged || recordModeChanged) if errMsg := s.previewProxyHostValidate(s.currentServerID(r), p); errMsg != "" { // v2.42.1 (issue #74) s.renderProxyHostFormError(w, r, p, errMsg) return } if err := models.UpdateProxyHost(s.DB, p); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = models.SetProxyHostProfile(s.DB, p.ID, s.hostProfileFromForm(r)) // v2.60.0 (issue #124) if err := models.UpdateProxyHostDNSProfile(s.DB, p.ID, p.DNSProfileID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // v2.7.3: admin-only owner reassignment. Handled here (not inside // UpdateProxyHost) so the user-role edit path — which never reaches this // branch — cannot touch ownership even if a non-admin forges owner_id in // the POST body. An absent/blank owner_id (old form, non-admin upgrade // path) leaves ownership untouched; an explicit "0" is the admin saying // "make it global". if isAdmin { if v := strings.TrimSpace(r.FormValue("owner_id")); v != "" { if parsed, err := strconv.ParseInt(v, 10, 64); err == nil && parsed >= 0 { _ = models.SetProxyHostOwner(s.DB, p.ID, parsed) } } } if needCreate { // v2.5.6: the override path is gone — same rationale as // createProxyHost above. If a record exists at this FQDN the // create call fails and the user clears it manually. s.dnsCreateRecord(s.currentServerID(r), p.ID, p) } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "proxy_update", fmt.Sprintf("proxy:%d", id), p.Domains, true) forceTLS := old != nil && old.CertificateID != p.CertificateID s.trySyncCaddy(s.currentServerID(r), forceTLS) { payload, _ := json.Marshal(map[string]any{ "event": "proxy_host_updated", "message": "Proxy host updated: " + p.Domains, "domains": p.Domains, }) sendNotification(s.DB, payload) } var deployProblems []string if len(deployTo) > 0 { deployProblems = s.crossDeployProxyHost(s.currentUserEmail(r), s.currentServerID(r), p, deployTo) } // v2.5.2: if this edit created a fresh DNS record (first time enabling // Managed DNS, or provider / zone / first-domain changed), show the // deploying page so the user can watch DNS + cert come up. A plain // edit that left DNS untouched goes back to the list as before. if needCreate { http.Redirect(w, r, withDeployProblems(fmt.Sprintf("/proxy-hosts/%d/deploying", p.ID), deployProblems), http.StatusSeeOther) return } http.Redirect(w, r, withDeployProblems("/proxy-hosts", deployProblems), http.StatusSeeOther) } func (s *Server) deleteProxyHost(w http.ResponseWriter, r *http.Request) { id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) cu := s.currentUser(r) isAdmin := cu != nil && cu.Role == models.RoleAdmin old, _ := models.GetProxyHost(s.DB, id) if !isAdmin { if old == nil || !s.canManageOwned(cu, old.OwnerID) { http.Error(w, "forbidden", http.StatusForbidden) return } } note := s.noteFleetDeletion(models.FleetResourceProxy, id, "") if old != nil { note.label = old.Domains } // Unified DNS: delete any managed record before removing the host. // No-op when the host has no DNS-managed record. if old != nil && old.DNSRecordID != "" { s.dnsDeleteRecord(old.DNSProvider, old.DNSProfileID, old.DNSZoneID, old.DNSZoneName, old.DNSRecordID) } if err := models.DeleteProxyHost(s.DB, id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "proxy_delete", fmt.Sprintf("proxy:%d", id), "", true) forceTLS := old != nil && old.CertificateID != 0 s.trySyncCaddyAfterDelete(s.currentServerID(r), forceTLS) s.propagateNotedDeletion(s.currentUserEmail(r), note) // v2.59.0 (issue #120) if old != nil { payload, _ := json.Marshal(map[string]any{ "event": "proxy_host_deleted", "message": "Proxy host deleted: " + old.Domains, "domains": old.Domains, }) sendNotification(s.DB, payload) } http.Redirect(w, r, "/proxy-hosts", http.StatusSeeOther) } // cloneProxyHost creates a copy of a proxy host with Enabled=false and // a "(copy)" suffix on each domain, then redirects to its edit page. func (s *Server) cloneProxyHost(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { http.NotFound(w, r) return } src, err := models.GetProxyHost(s.DB, id) if err != nil || src == nil { http.NotFound(w, r) return } // Clone was missing the ownership check every other access to this // resource enforces, so a non-admin could clone (and thereby read the // full configuration of, including upstream and advanced config) any // other tenant's or the admin's proxy host by guessing its ID. if !s.canManageOwned(s.currentUser(r), src.OwnerID) { http.Error(w, "forbidden", http.StatusForbidden) return } // Build cloned domain list — append "(copy)" to each domain. domains := src.DomainList() cloned := make([]string, len(domains)) for i, d := range domains { cloned[i] = d + " (copy)" } clone := *src // value copy clone.ID = 0 clone.Domains = strings.Join(cloned, ",") clone.Enabled = false // always disabled so it doesn't affect live traffic clone.MaintenanceMode = false clone.CreatedAt = time.Time{} clone.UpdatedAt = time.Time{} cu := s.currentUser(r) var ownerID int64 if cu != nil { ownerID = cu.ID } newID, err := models.CreateProxyHost(s.DB, src.ServerID, ownerID, &clone) if err != nil { http.Error(w, "clone failed: "+err.Error(), http.StatusInternalServerError) return } _ = models.UpdateProxyHostDNSProfile(s.DB, newID, clone.DNSProfileID) _ = models.SetProxyHostProfile(s.DB, newID, models.ProxyHostProfileID(s.DB, src.ID)) // v2.60.0 (issue #124) http.Redirect(w, r, fmt.Sprintf("/proxy-hosts/%d/edit", newID), http.StatusSeeOther) }