Ctrl K
ComunidadGuías…

internal/server/certificates.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: Apache-2.0

package server

import (
	"crypto/x509"
	"fmt"
	"net"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/X4Applegate/caddyui/internal/models"
	"github.com/go-chi/chi/v5"
)

// --- Certificates ---

// adminUserList returns the full user list for admins (used by the admin-only
// Owner pickers on new/edit forms so an admin can provision a resource for a
// specific customer). Returns an empty slice for non-admin viewers so we
// don't waste a DB round-trip and so a template leak can't surface email
// addresses to non-admins. v2.7.3.
// groupPeerIDs returns the other user IDs that share at least one group with
// the viewer. Used to expand a non-admin viewer's visibility scope in every
// List* call: viewer sees their own rows + globals + any row owned by a peer.
// Admins skip this path entirely — their List* branch is unfiltered — so we
// return nil immediately for admin/view and for signed-out requests. DB
// errors are swallowed to nil so a transient groups-table hiccup degrades to
// "no group visibility" rather than breaking the whole list page.
func (s *Server) groupPeerIDs(r *http.Request) []int64 {
	cu := s.currentUser(r)
	if cu == nil || cu.Role != models.RoleUser {
		return nil
	}
	ids, err := models.GroupPeerIDs(s.DB, cu.ID)
	if err != nil {
		return nil
	}
	return ids
}

// adminUserList returns the user-role accounts an admin can assign resources
// to. Only admins see anything — nil for non-admin viewers so their templates
// can't accidentally leak the user roster via the Owner <select>. View-role
// and admin accounts are filtered out because neither can "own" a resource in
// the data model (admin ownership is represented as NULL / global; view can't
// manage anything at all), so they'd be dead options in the picker.
func (s *Server) adminUserList(r *http.Request) []models.User {
	cu := s.currentUser(r)
	if cu == nil || cu.Role != models.RoleAdmin {
		return nil
	}
	users, err := models.ListUsers(s.DB)
	if err != nil {
		return nil
	}
	out := users[:0]
	for _, u := range users {
		if u.Role == models.RoleUser {
			out = append(out, u)
		}
	}
	return out
}

// certListForRequest returns the certificate list scoped to the signed-in
// viewer. Admin sees every row; a user-role account sees their own uploads
// plus any global (admin-owned, owner_id IS NULL) certs. Used by the
// proxy-host / redirection / raw-route form dropdowns so a non-admin never
// sees another tenant's private TLS material in the picker.
//
// Back-end callers that build Caddy's tls.certificates config (sync paths,
// cert-expiry background job, dashboard across servers) should keep using
// models.ListCertificates — they need the complete set regardless of who's
// looking.
func (s *Server) certListForRequest(r *http.Request) ([]models.Certificate, error) {
	cu := s.currentUser(r)
	isAdmin := cu != nil && cu.Role == models.RoleAdmin
	var viewerID int64
	if cu != nil {
		viewerID = cu.ID
	}
	return models.ListCertificatesForUser(s.DB, s.currentServerID(r), viewerID, isAdmin, s.groupPeerIDs(r))
}

func (s *Server) certOptionListForRequest(r *http.Request) ([]models.Certificate, error) {
	cu := s.currentUser(r)
	isAdmin := cu != nil && cu.Role == models.RoleAdmin
	var viewerID int64
	if cu != nil {
		viewerID = cu.ID
	}
	return models.ListCertificateOptionsForUser(s.DB, s.currentServerID(r), viewerID, isAdmin, s.groupPeerIDs(r))
}

func (s *Server) listCertificates(w http.ResponseWriter, r *http.Request) {
	sid := s.currentServerID(r)
	cu := s.currentUser(r)
	var viewerID int64
	isAdmin := cu != nil && cu.Role == models.RoleAdmin
	if cu != nil {
		viewerID = cu.ID
	}
	// v2.7.2: non-admin sees only their own uploads + global (admin-owned)
	// certs. Admin view still gets every row plus the owner email via JOIN
	// for the Owner column in certificates.html.
	// v2.7.4: ...plus any certs owned by a teammate (shared group member).
	certs, err := models.ListCertificatesForUser(s.DB, sid, viewerID, isAdmin, s.groupPeerIDs(r))
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	lifecycleStates, _ := models.ListCertificateLifecycle(s.DB, sid)
	// Usage must be calculated against the complete environment, not only
	// resources visible to the current user. Otherwise a global certificate
	// used by another tenant could be incorrectly labeled as unused.
	allHosts, _ := models.ListProxyHosts(s.DB, sid, 0, true, nil)
	allRedirs, _ := models.ListRedirectionHosts(s.DB, sid, 0, true, nil)
	allRoutes, _ := models.ListRawRoutes(s.DB, sid, 0, true, nil)
	referencedCerts := referencedCertificateIDs(allHosts, allRedirs, allRoutes)
	usageFilter := ""
	if r.URL.Query().Get("usage") == "unused" {
		usageFilter = "unused"
	}

	// Precompute the group-peer set once so the per-row edit predicate does
	// not issue a DB query per certificate.
	peerSet := map[int64]bool{}
	for _, id := range s.groupPeerIDs(r) {
		peerSet[id] = true
	}
	views := make([]certView, 0, len(certs))
	unusedCount := 0
	// Resolved once for the whole page rather than per row (v2.53.0).
	readRoots := s.certificateReadRoots()
	for _, c := range certs {
		// Edit/delete predicate: admin → always; user-role → their own rows
		// and rows owned by a group peer (access-groups grant collaborative
		// management, v2.52.5). Global admin-owned (NULL owner) rows stay
		// admin-only even though they're visible for the dropdown-reference case.
		canEdit := isAdmin || (c.OwnerID.Valid && viewerID != 0 && (c.OwnerID.Int64 == viewerID || peerSet[c.OwnerID.Int64]))
		view := certView{
			Certificate: c,
			CanEdit:     canEdit,
			IsUnused:    isUnusedCustomCertificate(c, referencedCerts),
		}
		if c.Source == models.CertSourceManaged {
			view.Lifecycle = certificateLifecycleForDomains(lifecycleStates, c.DomainList())
		}
		if view.IsUnused {
			unusedCount++
		}
		var exp *time.Time
		if isCustomCertificate(c) {
			var leaf *x509.Certificate
			if pemData, readErr := customCertificatePEM(c, readRoots); readErr == nil {
				leaf = parsePEMLeaf(pemData)
			}
			if leaf != nil {
				t := leaf.NotAfter
				exp = &t
				view.ExpirySource = "file"
				if c.Source == models.CertSourcePEM {
					view.ExpirySource = "stored"
				}
			}
			// v2.39.0: a file-path certificate CaddyUI cannot read falls back
			// to what the node actually serves for its domain.
			view.Probe = s.certificateProbeFor(c.ID)
			if view.Probe.HasCertificate() {
				if exp == nil {
					exp = view.Probe.NotAfter
					view.ExpirySource = "probe"
				} else if leaf != nil && leaf.SerialNumber.Text(16) != view.Probe.SerialNumber {
					view.ServedDiffers = true
				}
			}
		}
		if exp != nil {
			view.ExpiresAt = exp
			view.DaysLeft = int(time.Until(*exp).Hours() / 24)
		}
		if usageFilter == "" || view.IsUnused {
			views = append(views, view)
		}
	}
	unusedDismissed := unusedCount > 0 &&
		mustGetSetting(s.DB, unusedCertificateDismissalKey(sid)) == unusedCertificateFingerprint(certs, referencedCerts)

	// Collect domains auto-managed by Caddy (ssl_enabled, no custom cert).
	// Use admin view to see all hosts regardless of owner. v2.36.2 (issue
	// #65): Advanced routes are included — see collectAutoManagedDomains.
	autoDomains := collectAutoManagedDomains(certs, lifecycleStates, allHosts, allRedirs, allRoutes)

	pbAPIKey, _ := models.GetSetting(s.DB, settingPBAPIKey)
	pbSecretKey, _ := models.GetSetting(s.DB, settingPBSecretKey)
	s.render(w, r, "certificates.html", map[string]any{
		"User":              s.currentUser(r),
		"Certs":             views,
		"UnusedCount":       unusedCount,
		"UsageFilter":       usageFilter,
		"UnusedDismissed":   unusedDismissed,
		"AutoDomains":       autoDomains,
		"PorkbunConfigured": pbAPIKey != "" && pbSecretKey != "",
		"Section":           "certs",
	})
}

// dismissTOTPPolicyRecommendation acknowledges the dashboard's "two-factor
// authentication is not required" recommendation. v2.64.2 (issue #138):
// choosing not to require site-wide TOTP is legitimate, and the dashboard
// should be able to reach a clean state once that choice is recorded.
func (s *Server) dismissTOTPPolicyRecommendation(w http.ResponseWriter, r *http.Request) {
	cu := s.currentUser(r)
	if cu == nil || !cu.IsAdmin {
		http.Error(w, "forbidden", http.StatusForbidden)
		return
	}
	if err := models.SetSetting(s.DB, settingTOTPPolicyDismissed, "1"); err != nil {
		http.Error(w, "save dismissal: "+err.Error(), http.StatusInternalServerError)
		return
	}
	_ = models.LogActivity(s.DB, s.currentServerID(r), cu.Email, "recommendation_dismiss", "two_factor_policy", "", true)
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (s *Server) dismissUnusedCertificateRecommendation(w http.ResponseWriter, r *http.Request) {
	sid := s.currentServerID(r)
	certs, err := models.ListCertificates(s.DB, sid)
	if err != nil {
		http.Error(w, "load certificates: "+err.Error(), http.StatusInternalServerError)
		return
	}
	hosts, _ := models.ListProxyHosts(s.DB, sid, 0, true, nil)
	redirs, _ := models.ListRedirectionHosts(s.DB, sid, 0, true, nil)
	routes, _ := models.ListRawRoutes(s.DB, sid, 0, true, nil)
	fingerprint := unusedCertificateFingerprint(certs, referencedCertificateIDs(hosts, redirs, routes))
	if err := models.SetSetting(s.DB, unusedCertificateDismissalKey(sid), fingerprint); err != nil {
		http.Error(w, "save dismissal: "+err.Error(), http.StatusInternalServerError)
		return
	}
	actor := ""
	if user := s.currentUser(r); user != nil {
		actor = user.Email
	}
	_ = models.LogActivity(s.DB, sid, actor, "recommendation_dismiss", "unused_custom_certificates", fingerprint, true)
	http.Redirect(w, r, "/certificates?usage=unused", http.StatusSeeOther)
}

// getCertificateInspect parses the stored PEM and renders detailed certificate
// information (subject, issuer, SANs, validity window, key type, serial,
// SHA-256 fingerprint). Accessible to all authenticated users, not just admins,
// so each user can inspect certs they uploaded.
func (s *Server) getCertificateInspect(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if err != nil {
		http.Error(w, "invalid id", http.StatusBadRequest)
		return
	}
	// v2.57.1: only certificates the current user may see in the list. This
	// handler loaded the row by ID alone, so any account could read another
	// tenant's certificate name, domains and file paths.
	cert, err := s.visibleCertificate(r, id)
	if err != nil || cert == nil {
		http.NotFound(w, r)
		return
	}

	data := map[string]any{
		"User":    s.currentUser(r),
		"Cert":    cert,
		"Section": "certs",
	}

	s.fillCertificateInspectData(data, id, *cert)
	s.render(w, r, "certificate_inspect.html", data)
}

// fillCertificateInspectData adds the parsed certificate details to data:
// from the stored PEM or the readable file when there is one, else (v2.39.0)
// from a live TLS probe of the node — refreshed here when the stored result
// is stale, so Inspect always shows something recent. Keys are the ones
// certificate_inspect.html reads; FromProbe marks the fallback.
func (s *Server) fillCertificateInspectData(data map[string]any, id int64, cert models.Certificate) {
	if cert.ID == 0 {
		cert.ID = id // probe results are keyed by certificate ID
	}
	pemData, readErr := customCertificatePEM(cert, s.certificateReadRoots())
	if readErr != nil {
		data["ReadError"] = readErr.Error()
	}
	var probe *liveCertificateInfo
	if isCustomCertificate(cert) {
		probe = s.certificateProbeFor(id)
		if probe.Stale() {
			serverID, _ := models.CertificateServerID(s.DB, id)
			fresh := s.probeCustomCertificate(serverID, cert)
			_ = s.storeCertificateProbe(fresh)
			probe = &fresh
		}
		data["Probe"] = probe
	}

	fill := func(sum x509Summary) {
		data["Subject"] = sum.Subject
		data["Issuer"] = sum.Issuer
		data["SANs"] = sum.SANs
		data["NotBefore"] = sum.NotBefore
		data["NotAfter"] = sum.NotAfter
		data["DaysLeft"] = int(time.Until(sum.NotAfter).Hours() / 24)
		data["KeyType"] = sum.KeyType
		data["KeyBits"] = sum.KeyBits
		data["SerialNumber"] = sum.SerialNumber
		data["Fingerprint"] = sum.Fingerprint
	}

	leaf := parsePEMLeaf(pemData)
	if leaf == nil {
		// Nothing readable locally: show what the node serves for the
		// certificate's domain instead, and say so.
		if probe.HasCertificate() {
			data["FromProbe"] = true
			fill(x509Summary{
				Subject: probe.Subject, Issuer: probe.Issuer, SANs: probe.SANs,
				NotBefore: *probe.NotBefore, NotAfter: *probe.NotAfter,
				KeyType: probe.KeyType, KeyBits: probe.KeyBits,
				SerialNumber: probe.SerialNumber, Fingerprint: probe.Fingerprint,
			})
		}
		return
	}
	sum := summarizeX509(leaf)
	fill(sum)
	if probe.HasCertificate() {
		data["ServedKnown"] = true
		data["ServedMatches"] = probe.Fingerprint == sum.Fingerprint
	}
}

type managedCertificateServerStatus struct {
	ServerID         int64      `json:"server_id"`
	ServerName       string     `json:"server_name"`
	Deployed         bool       `json:"deployed"`
	Status           string     `json:"status"`
	ProbeName        string     `json:"probe_name,omitempty"`
	ExpiresAt        *time.Time `json:"expires_at,omitempty"`
	DaysLeft         int        `json:"days_left,omitempty"`
	Issuer           string     `json:"issuer,omitempty"`
	Error            string     `json:"error,omitempty"`
	LifecyclePhase   string     `json:"lifecycle_phase,omitempty"`
	LifecycleMessage string     `json:"lifecycle_message,omitempty"`
	LifecycleAt      *time.Time `json:"lifecycle_at,omitempty"`
}

func managedCertificateProbeName(cert models.Certificate) string {
	for _, domain := range cert.DomainList() {
		domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
		if strings.HasPrefix(domain, "*.") {
			return "caddyui-probe." + strings.TrimPrefix(domain, "*.")
		}
	}
	for _, domain := range cert.DomainList() {
		if domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), "."); domain != "" {
			return domain
		}
	}
	return ""
}

func (s *Server) probeManagedCertificate(server models.CaddyServer, cert models.Certificate) managedCertificateServerStatus {
	result := managedCertificateServerStatus{
		ServerID: server.ID, ServerName: server.Name, Deployed: true, Status: "unavailable",
		ProbeName: managedCertificateProbeName(cert),
	}
	if result.ProbeName == "" {
		result.Error = "no certificate subject configured"
		return result
	}
	host := s.caddyDialHost(server.ID)
	if host == "" {
		host = result.ProbeName
	}
	target := net.JoinHostPort(strings.Trim(host, "[]"), "443")
	leaf, err := dialLeafCertificate(target, result.ProbeName, customCertificateProbeTimeout)
	if err != nil {
		result.Error = err.Error()
		return result
	}
	if err := leaf.VerifyHostname(result.ProbeName); err != nil {
		result.Status = "mismatch"
		result.Error = err.Error()
		return result
	}
	expires := leaf.NotAfter.UTC()
	result.ExpiresAt = &expires
	result.DaysLeft = int(time.Until(expires).Hours() / 24)
	result.Issuer = leaf.Issuer.CommonName
	switch {
	case result.DaysLeft < 0:
		result.Status = "expired"
	case result.DaysLeft < 30:
		result.Status = "expiring"
	default:
		result.Status = "healthy"
	}
	return result
}

// getManagedCertificateStatus reports the live certificate served by every
// managed Caddy instance for an SNI name covered by this managed definition.
// A synthetic child name lets wildcard-only certificates be inspected without
// requiring public DNS for a real proxy hostname.
func (s *Server) getManagedCertificateStatus(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if err != nil {
		writeJSONError(w, http.StatusBadRequest, "invalid certificate id")
		return
	}
	visible, err := s.certListForRequest(r)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, err.Error())
		return
	}
	var cert *models.Certificate
	for i := range visible {
		if visible[i].ID == id {
			cert = &visible[i]
			break
		}
	}
	if cert == nil {
		writeJSONError(w, http.StatusNotFound, "certificate not found")
		return
	}
	if cert.Source != models.CertSourceManaged {
		writeJSONError(w, http.StatusBadRequest, "certificate is not managed by ACME")
		return
	}
	servers, err := models.ListCaddyServers(s.DB)
	if err != nil {
		writeJSONError(w, http.StatusInternalServerError, err.Error())
		return
	}
	results := make([]managedCertificateServerStatus, len(servers))
	var wg sync.WaitGroup
	for i, server := range servers {
		if server.Type != models.CaddyServerTypeManaged {
			results[i] = managedCertificateServerStatus{
				ServerID: server.ID, ServerName: server.Name, Status: "external",
			}
			continue
		}
		deployed := false
		if serverCerts, listErr := models.ListCertificates(s.DB, server.ID); listErr == nil {
			for _, serverCert := range serverCerts {
				if serverCert.Source == models.CertSourceManaged && sameDomainSet(serverCert.DomainList(), cert.DomainList()) {
					deployed = true
					break
				}
			}
		}
		if !deployed {
			results[i] = managedCertificateServerStatus{
				ServerID: server.ID, ServerName: server.Name, Status: "not_configured",
			}
			continue
		}
		wg.Add(1)
		go func(index int, srv models.CaddyServer) {
			defer wg.Done()
			results[index] = s.probeManagedCertificate(srv, *cert)
		}(i, server)
	}
	wg.Wait()
	states, _ := models.ListCertificateLifecycle(s.DB, 0)
	for i := range results {
		serverStates := make([]models.CertificateLifecycleStatus, 0)
		for _, state := range states {
			if state.ServerID == results[i].ServerID {
				serverStates = append(serverStates, state)
			}
		}
		state := certificateLifecycleForDomains(serverStates, cert.DomainList())
		if state == nil {
			continue
		}
		results[i].LifecyclePhase = state.Phase
		results[i].LifecycleMessage = state.Message
		updated := state.UpdatedAt
		results[i].LifecycleAt = &updated
		switch state.Phase {
		case "obtaining", "renewing", "retrying", "error", "revoked":
			results[i].Status = state.Phase
			if state.Error != "" {
				results[i].Error = state.Error
			}
		}
	}
	writeJSON(w, http.StatusOK, map[string]any{"servers": results})
}

func (s *Server) newCertificate(w http.ResponseWriter, r *http.Request) {
	// v2.7.3: Users list fuels the admin-only Owner picker on the form.
	// Non-admins get nil — the template skips rendering the picker.
	data := map[string]any{
		"ExportDataDir": s.serverDataDir(s.currentServerID(r)), // v2.42.0
		"Export":        models.CertificateExport{},
		"User":          s.currentUser(r),
		"Cert":          &models.Certificate{Source: models.CertSourcePEM},
		"Users":         s.adminUserList(r),
		"OtherServers":  s.otherManagedServers(r),
		"Section":       "certs",
	}
	s.render(w, r, "certificate_form.html", s.applyDNSViewData(s.currentServerID(r), data))
}

func (s *Server) parseCertificateForm(r *http.Request) (*models.Certificate, string) {
	_ = r.ParseForm()
	name := strings.TrimSpace(r.FormValue("name"))
	domains := strings.TrimSpace(r.FormValue("domains"))
	source := r.FormValue("source")
	if source != models.CertSourcePEM && source != models.CertSourcePath && source != models.CertSourceManaged {
		source = models.CertSourcePEM
	}
	c := &models.Certificate{
		Name:    name,
		Domains: domains,
		Source:  source,
	}
	if name == "" {
		return nil, "Name is required"
	}
	if domains == "" {
		return nil, "At least one domain is required (comma-separated)"
	}
	if source == models.CertSourcePEM {
		c.CertPEM = strings.TrimSpace(r.FormValue("cert_pem"))
		c.KeyPEM = strings.TrimSpace(r.FormValue("key_pem"))
		if c.CertPEM == "" || c.KeyPEM == "" {
			return nil, "Certificate PEM and Private key PEM are required when source is 'pem'"
		}
		if !strings.Contains(c.CertPEM, "BEGIN CERTIFICATE") {
			return nil, "Certificate PEM doesn't look like a PEM block"
		}
		if !strings.Contains(c.KeyPEM, "PRIVATE KEY") {
			return nil, "Private key PEM doesn't look like a PEM block"
		}
	} else if source == models.CertSourcePath {
		c.CertPath = strings.TrimSpace(r.FormValue("cert_path"))
		c.KeyPath = strings.TrimSpace(r.FormValue("key_path"))
		if c.CertPath == "" || c.KeyPath == "" {
			return nil, "Certificate path and Key path are required when source is 'path'"
		}
	} else {
		// Security (v2.57.1): export writes the certificate and its private key
		// to a directory of the requester's choosing, and source push makes
		// CaddyUI read a domain's key out of Caddy's storage and copy it to
		// other servers. Both act with CaddyUI's authority over the host, not
		// the requester's own resources, so they are admin-only.
		if !isAdminUser(s.currentUser(r)) {
			if strings.TrimSpace(r.FormValue("export_dir")) != "" {
				return nil, "Exporting a certificate to a directory is admin-only"
			}
			if r.FormValue("fleet_distribution_mode") == models.CertFleetDistributionSourcePush {
				return nil, "Pushing a certificate to other servers is admin-only"
			}
		}
		c.DNSProvider, c.DNSProfileID = s.normalizeDNSFormSelection(
			r.FormValue("dns_provider"), r.FormValue("dns_profile_id"))
		if c.DNSProvider == "" {
			return nil, "Choose DNS credentials for a managed ACME certificate"
		}
		if caddyDNSProviderConfig(c.DNSProvider, s.dnsCredsFor(c.DNSProvider, c.DNSProfileID), "") == nil {
			return nil, "The selected DNS credential profile is missing required credentials"
		}
		// v2.42.0: export to a directory after every issuance/renewal.
		exportJSON, err := models.NormalizeCertificateExportJSON(models.CertificateExport{
			Dir:      r.FormValue("export_dir"),
			CertFile: r.FormValue("export_cert_file"),
			KeyFile:  r.FormValue("export_key_file"),
		})
		if err != nil {
			return nil, err.Error()
		}
		if exportJSON != "" {
			if _, err := safeAbsolutePath(r.FormValue("export_dir")); err != nil {
				return nil, "Export directory: " + err.Error()
			}
		}
		c.Export = exportJSON
		// v2.55.0 (issue #115): opt-in "source push" fleet distribution. A
		// checkbox submits its value only when checked, so anything other
		// than the expected value (including absent) means "off" — the
		// original, default behavior. Only a Managed ACME certificate can
		// carry this; PEM/path certificates never reach this branch, so
		// FleetDistributionMode/FleetPushTargets stay at their zero value for
		// every other certificate, exactly like today.
		if r.FormValue("fleet_distribution_mode") == models.CertFleetDistributionSourcePush {
			c.FleetDistributionMode = models.CertFleetDistributionSourcePush
			c.FleetPushTargets = joinFleetPushTargets(parseDeployTo(r))
		}
	}
	return c, ""
}

// certificateDeployTargets is the "Also configure on" selection for a
// certificate. Copying a certificate definition to other servers acts with the
// admin's DNS credentials and creates global rows there, so a non-admin's
// selection is ignored (v2.57.1).
func (s *Server) certificateDeployTargets(r *http.Request) []int64 {
	if !isAdminUser(s.currentUser(r)) {
		return nil
	}
	return parseDeployTo(r)
}

// joinFleetPushTargets formats target CaddyServer IDs as the comma-separated
// string Certificate.FleetPushTargets/FleetPushTargetIDs expect.
func joinFleetPushTargets(ids []int64) string {
	parts := make([]string, len(ids))
	for i, id := range ids {
		parts[i] = strconv.FormatInt(id, 10)
	}
	return strings.Join(parts, ",")
}

func (s *Server) createCertificate(w http.ResponseWriter, r *http.Request) {
	c, errMsg := s.parseCertificateForm(r)
	if errMsg != "" {
		// Re-render with whatever the user typed
		fallback := &models.Certificate{
			Name:         r.FormValue("name"),
			Domains:      r.FormValue("domains"),
			Source:       r.FormValue("source"),
			CertPEM:      r.FormValue("cert_pem"),
			KeyPEM:       r.FormValue("key_pem"),
			CertPath:     r.FormValue("cert_path"),
			KeyPath:      r.FormValue("key_path"),
			DNSProvider:  r.FormValue("dns_provider"),
			DNSProfileID: r.FormValue("dns_profile_id"),
		}
		// v2.55.0 (issue #115): don't lose the fleet-push toggle/picker
		// selections just because some other field failed validation.
		if r.FormValue("fleet_distribution_mode") == models.CertFleetDistributionSourcePush {
			fallback.FleetDistributionMode = models.CertFleetDistributionSourcePush
			fallback.FleetPushTargets = joinFleetPushTargets(parseDeployTo(r))
		}
		data := map[string]any{
			"User":         s.currentUser(r),
			"Cert":         fallback,
			"Users":        s.adminUserList(r),
			"OtherServers": s.otherManagedServers(r),
			"Error":        errMsg,
			"Section":      "certs",
		}
		s.render(w, r, "certificate_form.html", s.applyDNSViewData(s.currentServerID(r), data))
		return
	}
	// v2.7.2: non-admin uploads are tagged with their user ID so they land in
	// their own scoped list. Admin uploads get owner_id = NULL → shared/global
	// (visible from every user's proxy-host dropdown). Matches the pattern
	// used by createProxyHost/createRedirectionHost/createRawRoute.
	cu := s.currentUser(r)
	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 upload-and-assign a cert directly to a specific
		// user from the new-cert form.
		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): a file-path certificate Caddy cannot open is
	// refused here, with the container-path explanation, instead of being
	// saved and breaking every later sync of this server.
	if errMsg := s.previewCertificateValidate(s.currentServerID(r), c); errMsg != "" {
		s.renderCertificateFormError(w, r, c, errMsg)
		return
	}
	id, err := models.CreateCertificate(s.DB, s.currentServerID(r), ownerID, c)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	c.ID = id
	_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "cert_create", fmt.Sprintf("cert:%d", id), c.Name, true)
	s.trySyncCaddy(s.currentServerID(r), true)
	s.probeCertificateSoon(s.currentServerID(r), *c)
	s.exportCertificateSoon(s.currentServerID(r), *c)
	s.crossDeployCertificate(s.currentUserEmail(r), s.currentServerID(r), *c, s.certificateDeployTargets(r))
	http.Redirect(w, r, "/certificates", http.StatusSeeOther)
}

func (s *Server) editCertificate(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	c, err := models.GetCertificate(s.DB, id)
	if err != nil {
		http.Error(w, err.Error(), http.StatusNotFound)
		return
	}
	// v2.7.2: ownership gate. Admin edits anything; a user can edit a cert
	// they uploaded but not one owned by somebody else or a global/admin
	// cert (owner_id NULL). Mirrors the check pattern used by
	// editProxyHost / updateRedirectionHost etc. so all four resource types
	// enforce the same rule.
	cu := s.currentUser(r)
	isAdmin := cu != nil && cu.Role == models.RoleAdmin
	if !isAdmin {
		if !s.canManageOwned(cu, c.OwnerID) {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
	}
	data := map[string]any{
		"User":         s.currentUser(r),
		"Cert":         c,
		"Users":        s.adminUserList(r),
		"OtherServers": s.otherManagedServers(r),
		"Section":      "certs",
	}
	s.addCertificateExportViewData(data, id, *c)
	s.render(w, r, "certificate_form.html", s.applyDNSViewData(s.currentServerID(r), data))
}

func (s *Server) updateCertificate(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	// Re-fetch to enforce ownership server-side — never trust the form.
	existing, err := models.GetCertificate(s.DB, id)
	if err != nil || existing == nil {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	cu := s.currentUser(r)
	isAdmin := cu != nil && cu.Role == models.RoleAdmin
	if !isAdmin {
		if !s.canManageOwned(cu, existing.OwnerID) {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
	}
	c, errMsg := s.parseCertificateForm(r)
	if errMsg != "" {
		existing, _ := models.GetCertificate(s.DB, id)
		if existing == nil {
			existing = &models.Certificate{}
		}
		existing.Name = r.FormValue("name")
		existing.Domains = r.FormValue("domains")
		existing.Source = r.FormValue("source")
		existing.CertPEM = r.FormValue("cert_pem")
		existing.KeyPEM = r.FormValue("key_pem")
		existing.CertPath = r.FormValue("cert_path")
		existing.KeyPath = r.FormValue("key_path")
		existing.DNSProvider = r.FormValue("dns_provider")
		existing.DNSProfileID = r.FormValue("dns_profile_id")
		// v2.55.0 (issue #115): don't lose the fleet-push toggle/picker
		// selections just because some other field failed validation.
		if r.FormValue("fleet_distribution_mode") == models.CertFleetDistributionSourcePush {
			existing.FleetDistributionMode = models.CertFleetDistributionSourcePush
			existing.FleetPushTargets = joinFleetPushTargets(parseDeployTo(r))
		} else {
			existing.FleetDistributionMode = ""
			existing.FleetPushTargets = ""
		}
		data := map[string]any{
			"User":         s.currentUser(r),
			"Cert":         existing,
			"Users":        s.adminUserList(r),
			"OtherServers": s.otherManagedServers(r),
			"Error":        errMsg,
			"Section":      "certs",
		}
		s.render(w, r, "certificate_form.html", s.applyDNSViewData(s.currentServerID(r), data))
		return
	}
	c.ID = id
	if errMsg := s.previewCertificateValidate(s.currentServerID(r), c); errMsg != "" { // v2.42.1 (issue #74)
		s.renderCertificateFormError(w, r, c, errMsg)
		return
	}
	if err := models.UpdateCertificate(s.DB, c); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	// v2.7.3: admin-only owner reassignment. Note: when admin moves a cert
	// from "global" to a specific user, any existing proxy/redirect/raw rows
	// referencing this cert continue to work — the syncCaddy call below
	// rewrites the running Caddy config from the DB, and cert references are
	// resolved by ID not ownership. This is by design: handing a cert off
	// should not break whatever's already using it.
	if isAdmin {
		if v := strings.TrimSpace(r.FormValue("owner_id")); v != "" {
			if parsed, err := strconv.ParseInt(v, 10, 64); err == nil && parsed >= 0 {
				_ = models.SetCertificateOwner(s.DB, c.ID, parsed)
			}
		}
	}
	_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "cert_update", fmt.Sprintf("cert:%d", id), c.Name, true)
	s.trySyncCaddy(s.currentServerID(r), true)
	s.probeCertificateSoon(s.currentServerID(r), *c)
	s.exportCertificateSoon(s.currentServerID(r), *c)
	s.crossDeployCertificate(s.currentUserEmail(r), s.currentServerID(r), *c, s.certificateDeployTargets(r))
	http.Redirect(w, r, "/certificates", http.StatusSeeOther)
}

func (s *Server) deleteCertificate(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	// v2.7.2: ownership-aware delete. DeleteCertificate NULLs every
	// certificate_id that points at this row across proxy_hosts,
	// redirection_hosts, and raw_routes — cross-owner fan-out that we have
	// to gate carefully.
	//
	//   admin  → can delete anything (global + anyone's private).
	//   user   → can delete only a cert they own (owner_id = their ID) AND
	//            only when no site they don't own still references it.
	//            Global (owner_id NULL) stays admin-only because tearing
	//            it off the shared wildcard would silently break other
	//            tenants. The CertificateInUse check below is stricter
	//            than "zero refs" — it lets a user delete even if some of
	//            their own sites use it (they'll fall back to auto-ssl)
	//            but blocks delete when any other owner's site would be
	//            collaterally affected.
	//   view   → never reaches here; requireWrite already 403'd at the
	//            route layer.
	cu := s.currentUser(r)
	if cu == nil {
		http.Error(w, "forbidden", http.StatusForbidden)
		return
	}
	isAdmin := cu.Role == models.RoleAdmin
	if !isAdmin {
		cert, err := models.GetCertificate(s.DB, id)
		if err != nil || cert == nil {
			http.Error(w, "not found", http.StatusNotFound)
			return
		}
		if !cert.OwnerID.Valid {
			http.Error(w, "only admins can delete global certificates", http.StatusForbidden)
			return
		}
		if !s.canManageOwned(cu, cert.OwnerID) {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		// Block when a site the caller doesn't own still references this
		// cert — prevents accidental TLS removal from a co-tenant's host.
		if foreign, _ := models.CertificateInUseByOthers(s.DB, id, cu.ID); foreign > 0 {
			http.Error(w, "this certificate is in use by another user's site — ask an admin to delete it", http.StatusForbidden)
			return
		}
	}
	if err := models.DeleteCertificate(s.DB, id); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	s.deleteCertificateProbe(id)
	s.deleteCertificateExportStatus(id)
	_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "cert_delete", fmt.Sprintf("cert:%d", id), "", true)
	s.trySyncCaddy(s.currentServerID(r), true)
	http.Redirect(w, r, "/certificates", http.StatusSeeOther)
}