internal/server/proxy_hosts.go
Rama main · réplica de lectura del repositorio original.
// 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: <email>" 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: <email>" 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: "<redacted>"})
}
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 = "<redacted>"
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 <select>) plus a boolean "AnyDNSEnabled"
// the template uses to hide the whole DNS section when nothing is
// configured. serverID is the Caddy server this proxy host will be
// created on — used to resolve the per-server public IP (v2.4.0) and
// expose it to the template so the user sees which IP will be written.
func (s *Server) dnsProviderViewData(serverID int64) map[string]any {
ip := s.serverIPFor(serverID)
type providerEntry struct {
ID string
DisplayName string
}
type profileEntry struct {
ID string
ProviderID string
DisplayName string
Legacy bool
}
enabled := []providerEntry{}
profiles := []profileEntry{}
for _, d := range dns.Descriptors() {
if dns.CredsComplete(d.ID, s.dnsCreds(d.ID)) {
enabled = append(enabled, providerEntry{ID: d.ID, DisplayName: d.DisplayName})
profiles = append(profiles, profileEntry{
ID: "legacy:" + d.ID,
ProviderID: d.ID,
DisplayName: d.DisplayName + " (default settings)",
Legacy: true,
})
}
}
for _, p := range s.loadDNSProfiles() {
if !dns.CredsComplete(p.ProviderID, p.Credentials) {
continue
}
providerName := p.ProviderID
if d, ok := dns.Lookup(p.ProviderID); ok {
providerName = d.DisplayName
}
profiles = append(profiles, profileEntry{
ID: p.ID,
ProviderID: p.ProviderID,
DisplayName: p.Name + " (" + providerName + ")",
})
enabled = append(enabled, providerEntry{ID: p.ProviderID, DisplayName: providerName})
}
return map[string]any{
"DNSProviders": enabled,
"DNSProfiles": profiles,
"AnyDNSEnabled": len(profiles) > 0,
"CurrentServerIP": ip, // shown in the form so users see the A-record target
}
}
// applyDNSViewData merges the DNS picker view data into the given map.
// serverID scopes the per-server IP lookup (v2.4.0) — pass the request's
// current server so the form renders the right A-record target.
func (s *Server) applyDNSViewData(serverID int64, m map[string]any) map[string]any {
for k, v := range s.dnsProviderViewData(serverID) {
m[k] = v
}
return m
}
func (s *Server) newProxyHost(w http.ResponseWriter, r *http.Request) {
// v2.7.2: cert dropdown scoped to the current viewer — user-role sees
// only their own + global admin-owned certs, not other users' private
// material.
certs, _ := s.certListForRequest(r)
// New hosts open in the guided publish workflow by default. Experienced
// operators can opt into the full several-hundred-field editor explicitly;
// edit routes remain advanced so existing configuration is never hidden.
guided := r.URL.Query().Get("mode") != "advanced"
s.render(w, r, "proxy_host_form.html", s.withProfileViewData(s.applyDNSViewData(s.currentServerID(r), map[string]any{
"User": s.currentUser(r),
"Host": &models.ProxyHost{Enabled: true, SSLEnabled: true, SSLForced: true, HTTP2Support: true, ForwardScheme: "http"},
"Certificates": certs,
"Users": s.adminUserList(r),
"OtherServers": s.otherManagedServers(r),
"Guided": guided,
"Section": "proxy",
}), 0))
}
func (s *Server) createProxyHost(w http.ResponseWriter, r *http.Request) {
p, err := parseProxyHostForm(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
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 create a proxy whose domain list collides with an
// existing proxy or redirect on the same server. Without this guard the
// row goes in fine, but Caddy's route table only keeps one match per
// hostname — so the newer entry silently shadows the older one and users
// see "my domain got overridden" complaints. The check is global across
// owners (admin view) because Caddy routes are resolved by hostname, not
// by who owns the row in the UI.
if conflict, err := models.DomainsConflict(s.DB, s.currentServerID(r), p.DomainList(), 0, 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 — edit the existing entry or remove it before reusing the name.", conflict))
return
}
// v2.7.8: refuse to save a proxy whose first hostname doesn't live in the
// selected DNS zone. The form's amber mismatch warning was advisory only
// — users were saving anyway and ending up with rows that either failed
// at the provider API on the next dnsCreateRecord call or quietly put the
// A record in the wrong zone. Validate at save time so the row never
// reaches the DB in a half-broken state.
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.
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)
deployTo := s.effectiveDeployTargets(s.currentServerID(r), parseDeployTo(r))
cu := s.currentUser(r)
// SSRF guard (GHSA-r4wm-rgc5-q834): block non-admins from pointing the
// upstream (host, extra upstreams, or Host override) at loopback/link-local/
// internal management addresses such as the Caddy admin API.
if msg := s.validateProxyUpstreamsForUser(cu, p); msg != "" {
s.renderProxyHostFormError(w, r, p, msg)
return
}
var ownerID int64
if cu != nil && cu.Role != models.RoleAdmin {
ownerID = cu.ID
} else if cu != nil && cu.Role == models.RoleAdmin {
// v2.7.3: admin can assign the new host to a specific user at create
// time via the Owner <select>. 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)
}