Ctrl K
ComunidadGuías…

internal/server/certificate_export.go

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

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

package server

import (
	"context"
	"crypto/x509"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"

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

// v2.42.0: export a managed certificate to a directory.
//
// Caddy keeps the certificates it obtains in its data directory
// (<data>/caddy/certificates/<issuer>/<name>/<name>.crt and .key). Other
// services on the same host — a mail server is the usual case — need those
// files, and they need them again after every renewal. When a node's data
// volume is mounted into the CaddyUI container (Caddy Fleet → edit server →
// Data directory), CaddyUI can read the files and copy them wherever the
// certificate's export settings point, atomically, with the private key
// readable by owner only. Exports run when the node reports an issuance or
// renewal, on a timer as a safety net, after the certificate is saved, and
// on Export now. The last result is kept in settings as
// certificate_export_<id> and shown on the certificate form.

const (
	certificateExportEvery         = 10 * time.Minute
	certificateExportSettingPrefix = "certificate_export_"
	// Caddy logs cert_obtained after writing storage, but give the write a
	// moment on slow disks before reading it back.
	certificateExportEventDelay = 5 * time.Second
)

type certificateExportStatus struct {
	CertificateID int64      `json:"certificate_id"`
	ServerID      int64      `json:"server_id"`
	Source        string     `json:"source,omitempty"` // storage .crt the export came from
	Files         []string   `json:"files,omitempty"`
	SerialNumber  string     `json:"serial,omitempty"`
	NotAfter      *time.Time `json:"not_after,omitempty"`
	ExportedAt    *time.Time `json:"exported_at,omitempty"`
	CheckedAt     time.Time  `json:"checked_at"`
	Error         string     `json:"error,omitempty"`
}

func (st *certificateExportStatus) DaysLeft() int {
	if st == nil || st.NotAfter == nil {
		return 0
	}
	return int(time.Until(*st.NotAfter).Hours() / 24)
}

// storageSafeName is certmagic's file-name form of a certificate subject:
// "*" becomes "wildcard_", everything else is kept lower-case.
func storageSafeName(domain string) string {
	return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(domain)), "*", "wildcard_")
}

// caddyStorageCertificateRoots lists where the certificates tree may sit
// under a mounted data directory: the official image keeps storage in
// <volume>/caddy, a host install may mount .../caddy itself, and an
// operator may point straight at the certificates directory.
func caddyStorageCertificateRoots(dataDir string) []string {
	dataDir = strings.TrimRight(strings.TrimSpace(dataDir), "/")
	if dataDir == "" {
		return nil
	}
	return []string{
		filepath.Join(dataDir, "caddy", "certificates"),
		filepath.Join(dataDir, "certificates"),
		dataDir,
	}
}

// Test seams: the real thing reads the filesystem as the CaddyUI user.
var (
	storageReadDir  = os.ReadDir
	storageReadFile = readCertificateFile
	storageStat     = os.Stat
)

// storagePermissionError explains the one failure operators actually hit:
// Caddy keeps its data directory private to the user it runs as (mode 0700,
// owned by root in the official image) and the CaddyUI container runs as an
// unprivileged user, so it cannot even list the certificates directory. That used
// to be swallowed and reported as "Caddy has not stored a certificate yet".
func storagePermissionError(path string) error {
	return fmt.Errorf("CaddyUI is not allowed to read Caddy's certificate storage (permission denied on %s). "+
		"Caddy keeps its data directory private to the user it runs as (mode 0700, owned by root in the official image) "+
		"and CaddyUI runs as uid %d. Run the caddyui container as that user — with the official images add `user: \"0:0\"` "+
		"to the caddyui service (the data volume can stay mounted read-only) — then export again", path, os.Getuid())
}

// exportWriteError turns a permission failure on the export directory into
// advice; anything else is returned as it was.
func exportWriteError(err error, dir string) error {
	if errors.Is(err, fs.ErrPermission) {
		return fmt.Errorf("CaddyUI is not allowed to write to %s (permission denied; CaddyUI runs as uid %d). "+
			"Make that directory writable by that user — or run the caddyui container as root — then export again: %w", dir, os.Getuid(), err)
	}
	return err
}

type storageCertificate struct {
	CertPath string
	KeyPath  string
	Leaf     *x509.Certificate
	// Uncovered lists the requested names this certificate does not present,
	// so a caller shipping it somewhere can say so (v2.68.6, issue #153).
	Uncovered []string
}

// certificateCoversName reports whether leaf presents name, honouring a
// single-label wildcard the way TLS does. Caddy stores a wildcard and its apex
// as separate certificates, so "*.example.com" must not be treated as cover
// for the bare "example.com".
func certificateCoversName(leaf *x509.Certificate, name string) bool {
	name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
	if leaf == nil || name == "" {
		return false
	}
	presented := leaf.DNSNames
	if len(presented) == 0 && strings.TrimSpace(leaf.Subject.CommonName) != "" {
		presented = []string{leaf.Subject.CommonName}
	}
	for _, san := range presented {
		san = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(san)), ".")
		if san == "" {
			continue
		}
		if san == name {
			return true
		}
		suffix, isWildcard := strings.CutPrefix(san, "*.")
		if !isWildcard {
			continue
		}
		// A requested "*.example.com" is satisfied only by a wildcard SAN for
		// the same suffix, which the equality check above already covers. A
		// concrete name is covered when the wildcard matches its single
		// leftmost label.
		if strings.HasPrefix(name, "*.") {
			continue
		}
		if host, found := strings.CutSuffix(name, "."+suffix); found && host != "" && !strings.Contains(host, ".") {
			return true
		}
	}
	return false
}

// storageCertificateCoverage scores how well leaf serves domains and returns
// the requested names it does not present.
//
// A wildcard name scores higher than a concrete one because it protects every
// subdomain behind it. Without that weighting a definition of
// "*.example.com" + "example.com" is a 1-1 tie between the two certificates
// Caddy stored, and the tie-break (expiry) picks whichever was obtained last
// — usually the apex, which serves no subdomain at all (issue #153).
func storageCertificateCoverage(leaf *x509.Certificate, domains []string) (score int, uncovered []string) {
	for _, domain := range domains {
		if strings.TrimSpace(domain) == "" {
			continue
		}
		if !certificateCoversName(leaf, domain) {
			uncovered = append(uncovered, strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), "."))
			continue
		}
		if strings.HasPrefix(strings.TrimSpace(domain), "*.") {
			score += 2
			continue
		}
		score++
	}
	return score, uncovered
}

// findStorageCertificate locates the stored certificate that best serves
// domains: the one covering the most of them, with the latest NotAfter
// breaking a tie. Every candidate is read through readCertificateFile, so
// roots must include dataDir — certificateReadRoots adds each node's DataDir
// for exactly this.
//
// v2.68.6 (issue #153): selection was previously latest-NotAfter alone. A
// definition listing both "*.example.com" and "example.com" is stored by Caddy
// as two certificates, so the apex — newer, hence later-expiring — was picked
// and pushed to fleet targets, breaking TLS for every subdomain. Coverage now
// decides, and the names the winner misses come back in Uncovered.
func findStorageCertificate(dataDir string, domains []string, roots []string) (*storageCertificate, error) {
	var best *storageCertificate
	bestScore := -1
	var looked []string
	denied := "" // first path CaddyUI was not allowed to read
	deny := func(path string, err error) {
		if denied == "" && errors.Is(err, fs.ErrPermission) {
			denied = path
		}
	}
	for _, root := range caddyStorageCertificateRoots(dataDir) {
		root, err := safeAbsolutePath(root)
		if err != nil {
			continue
		}
		issuers, err := storageReadDir(root)
		if err != nil {
			deny(root, err)
			continue
		}
		looked = append(looked, root)
		for _, issuer := range issuers {
			if !issuer.IsDir() {
				continue
			}
			for _, domain := range domains {
				name := storageSafeName(domain)
				if name == "" {
					continue
				}
				if strings.Contains(issuer.Name(), "..") || strings.Contains(name, "..") {
					continue
				}
				crt := filepath.Join(root, issuer.Name(), name, name+".crt")
				key := filepath.Join(root, issuer.Name(), name, name+".key")
				raw, err := storageReadFile(crt, roots)
				if err != nil {
					deny(crt, err)
					continue
				}
				leaf := parsePEMLeaf(string(raw))
				if leaf == nil {
					continue
				}
				if _, err := storageStat(key); err != nil {
					deny(key, err)
					continue
				}
				score, uncovered := storageCertificateCoverage(leaf, domains)
				if best == nil || score > bestScore ||
					(score == bestScore && leaf.NotAfter.After(best.Leaf.NotAfter)) {
					best = &storageCertificate{CertPath: crt, KeyPath: key, Leaf: leaf, Uncovered: uncovered}
					bestScore = score
				}
			}
		}
	}
	if best != nil {
		return best, nil
	}
	if denied != "" {
		return nil, storagePermissionError(denied)
	}
	if len(looked) == 0 {
		return nil, fmt.Errorf("no Caddy certificate storage found under %s — is the node's data volume mounted into the CaddyUI container at that path?", dataDir)
	}
	return nil, fmt.Errorf("Caddy has not stored a certificate for %s yet (looked in %s)", strings.Join(domains, ", "), strings.Join(looked, ", "))
}

// writeFileAtomic writes data next to path and renames it into place, so a
// reader never sees a half-written certificate.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
	tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	cleanup := func() { _ = os.Remove(tmpName) }
	if _, err := tmp.Write(data); err != nil {
		_ = tmp.Close()
		cleanup()
		return err
	}
	if err := tmp.Chmod(perm); err != nil {
		_ = tmp.Close()
		cleanup()
		return err
	}
	if err := tmp.Close(); err != nil {
		cleanup()
		return err
	}
	if err := os.Rename(tmpName, path); err != nil {
		cleanup()
		return err
	}
	return nil
}

func certificateExportKey(certID int64) string {
	return certificateExportSettingPrefix + strconv.FormatInt(certID, 10)
}

func (s *Server) storeCertificateExportStatus(st certificateExportStatus) error {
	raw, err := json.Marshal(st)
	if err != nil {
		return err
	}
	return models.SetSetting(s.DB, certificateExportKey(st.CertificateID), string(raw))
}

func (s *Server) certificateExportStatusFor(certID int64) *certificateExportStatus {
	raw, err := models.GetSetting(s.DB, certificateExportKey(certID))
	if err != nil || strings.TrimSpace(raw) == "" {
		return nil
	}
	var st certificateExportStatus
	if err := json.Unmarshal([]byte(raw), &st); err != nil {
		return nil
	}
	return &st
}

func (s *Server) deleteCertificateExportStatus(certID int64) {
	_ = models.DeleteSetting(s.DB, certificateExportKey(certID))
}

// exportCertificate copies the certificate Caddy stored for cert to the
// configured directory. Unless force is set, nothing is written when the
// exported files already hold the stored serial. The returned status is
// also persisted; err mirrors status.Error.
func (s *Server) exportCertificate(serverID int64, cert models.Certificate, force bool) (certificateExportStatus, error) {
	st := certificateExportStatus{CertificateID: cert.ID, ServerID: serverID, CheckedAt: time.Now().UTC()}
	if prev := s.certificateExportStatusFor(cert.ID); prev != nil {
		st.Source, st.Files, st.SerialNumber, st.NotAfter, st.ExportedAt = prev.Source, prev.Files, prev.SerialNumber, prev.NotAfter, prev.ExportedAt
	}
	fail := func(err error) (certificateExportStatus, error) {
		st.Error = err.Error()
		_ = s.storeCertificateExportStatus(st)
		return st, err
	}
	cfg := cert.ExportConfig()
	if cert.Source != models.CertSourceManaged || !cfg.Enabled() {
		return fail(fmt.Errorf("export is not configured for this certificate"))
	}
	srv, err := models.GetCaddyServer(s.DB, serverID)
	if err != nil || srv == nil {
		return fail(fmt.Errorf("load server %d: %v", serverID, err))
	}
	if strings.TrimSpace(srv.DataDir) == "" {
		return fail(fmt.Errorf("no Data directory is set for %s — mount the node's Caddy data volume into the CaddyUI container and enter its path under Caddy Fleet → edit server", srv.Name))
	}
	// Both directories are operator input (server form, certificate form):
	// vet them here, right before any file access, like certificate paths.
	dataDir, err := safeAbsolutePath(srv.DataDir)
	if err != nil {
		return fail(fmt.Errorf("Data directory: %w", err))
	}
	exportDir, err := safeAbsolutePath(cfg.Dir)
	if err != nil {
		return fail(fmt.Errorf("export directory: %w", err))
	}
	readRoots := s.certificateReadRoots()
	stored, err := findStorageCertificate(dataDir, cert.DomainList(), readRoots)
	if err != nil {
		return fail(err)
	}
	serial := stored.Leaf.SerialNumber.Text(16)
	certOut := filepath.Join(exportDir, filepath.Base(cfg.CertFile))
	keyOut := filepath.Join(exportDir, filepath.Base(cfg.KeyFile))
	if !force && st.SerialNumber == serial {
		if _, certErr := os.Stat(certOut); certErr == nil {
			if _, keyErr := os.Stat(keyOut); keyErr == nil {
				st.Error = ""
				_ = s.storeCertificateExportStatus(st)
				return st, nil
			}
		}
	}
	certPEM, err := readCertificateFile(stored.CertPath, readRoots)
	if err != nil {
		return fail(fmt.Errorf("read %s: %w", stored.CertPath, err))
	}
	keyPEM, err := readCertificateFile(stored.KeyPath, readRoots)
	if err != nil {
		return fail(fmt.Errorf("read %s: %w", stored.KeyPath, err))
	}
	if err := os.MkdirAll(exportDir, 0o755); err != nil {
		return fail(fmt.Errorf("create %s: %w", exportDir, exportWriteError(err, exportDir)))
	}
	if err := writeFileAtomic(certOut, certPEM, 0o644); err != nil {
		return fail(fmt.Errorf("write %s: %w", certOut, exportWriteError(err, exportDir)))
	}
	if err := writeFileAtomic(keyOut, keyPEM, 0o600); err != nil {
		return fail(fmt.Errorf("write %s: %w", keyOut, exportWriteError(err, exportDir)))
	}
	now := time.Now().UTC()
	notAfter := stored.Leaf.NotAfter.UTC()
	st.Source, st.Files, st.SerialNumber, st.NotAfter, st.ExportedAt, st.Error = stored.CertPath, []string{certOut, keyOut}, serial, &notAfter, &now, ""
	_ = s.storeCertificateExportStatus(st)
	_ = models.LogActivity(s.DB, serverID, "system", "cert_export", fmt.Sprintf("cert:%d", cert.ID),
		fmt.Sprintf("exported %s (serial %s, expires %s) to %s", cert.Domains, serial, notAfter.Format("2006-01-02"), exportDir), true)
	return st, nil
}

// runCertificateExports exports every managed certificate with export
// settings on serverID (0 = every managed server with a data directory).
// Returns how many certificates were checked.
func (s *Server) runCertificateExports(serverID int64, force bool) (int, error) {
	s.certExportRunMu.Lock()
	defer s.certExportRunMu.Unlock()
	servers, err := models.ListCaddyServers(s.DB)
	if err != nil {
		return 0, err
	}
	checked := 0
	var failures []string
	for _, srv := range servers {
		if srv.Type != models.CaddyServerTypeManaged || (serverID != 0 && srv.ID != serverID) || strings.TrimSpace(srv.DataDir) == "" {
			continue
		}
		certs, err := models.ListCertificates(s.DB, srv.ID)
		if err != nil {
			return checked, fmt.Errorf("%s: list certificates: %w", srv.Name, err)
		}
		for _, c := range certs {
			if c.Source != models.CertSourceManaged || !c.ExportConfig().Enabled() {
				continue
			}
			checked++
			if _, err := s.exportCertificate(srv.ID, c, force); err != nil {
				failures = append(failures, fmt.Sprintf("%s/%s: %v", srv.Name, c.Name, err))
			}
		}
	}
	if len(failures) > 0 {
		sort.Strings(failures)
		return checked, fmt.Errorf("%s", strings.Join(failures, "; "))
	}
	return checked, nil
}

// exportCertificatesForIdentifier exports every exporting certificate on
// serverID that covers identifier — the hook behind Caddy's cert_obtained /
// renewal events. Returns how many were exported or re-checked.
func (s *Server) exportCertificatesForIdentifier(serverID int64, identifier string) int {
	certs, err := models.ListCertificates(s.DB, serverID)
	if err != nil {
		return 0
	}
	n := 0
	for _, c := range certs {
		if c.Source != models.CertSourceManaged || !c.ExportConfig().Enabled() || !certificateCoversAnyDomain(c, []string{identifier}) {
			continue
		}
		n++
		if _, err := s.exportCertificate(serverID, c, false); err != nil {
			log.Printf("certificate export: %s on server %d after %s became active: %v", c.Name, serverID, identifier, err)
		}
	}
	return n
}

// handleCertificateActive is wired into the log hub (SetCaddyLogHub): a
// node reported a certificate as obtained/renewed/loaded.
func (s *Server) handleCertificateActive(serverID int64, identifier string) {
	if s.DB == nil || serverID <= 0 {
		return
	}
	go func() {
		time.Sleep(certificateExportEventDelay)
		s.exportCertificatesForIdentifier(serverID, identifier)
	}()
}

// exportCertificateSoon runs an export shortly after the certificate was
// saved, so a freshly configured export does not wait for the next pass.
func (s *Server) exportCertificateSoon(serverID int64, cert models.Certificate) {
	if cert.Source != models.CertSourceManaged || !cert.ExportConfig().Enabled() || cert.ID == 0 {
		return
	}
	go func() {
		time.Sleep(2 * time.Second)
		if _, err := s.exportCertificate(serverID, cert, false); err != nil {
			log.Printf("certificate export: %s after save: %v", cert.Name, err)
		}
	}()
}

// StartCertificateExports runs the safety-net pass on a timer until ctx ends.
func (s *Server) StartCertificateExports(ctx context.Context) {
	go func() {
		run := func() {
			n, err := s.runCertificateExports(0, false)
			if err != nil {
				log.Printf("certificate export: %v", err)
			} else if n > 0 {
				log.Printf("certificate export: checked %d certificate(s)", n)
			}
		}
		select {
		case <-ctx.Done():
			return
		case <-time.After(20 * time.Second):
		}
		run()
		ticker := time.NewTicker(certificateExportEvery)
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				run()
			}
		}
	}()
}

// exportCertificateHandler: POST /certificates/{id}/export — Export now on
// the certificate form. Uses the saved export settings and always rewrites.
func (s *Server) exportCertificateHandler(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
	}
	cert, err := models.GetCertificate(s.DB, id)
	if err != nil || cert == nil {
		http.NotFound(w, r)
		return
	}
	cu := s.currentUser(r)
	if cu == nil || (cu.Role != models.RoleAdmin && (!s.canManageOwned(cu, cert.OwnerID))) {
		http.Error(w, "forbidden", http.StatusForbidden)
		return
	}
	serverID, _ := models.CertificateServerID(s.DB, id)
	if _, err := s.exportCertificate(serverID, *cert, true); err != nil {
		_ = models.LogActivity(s.DB, serverID, s.currentUserEmail(r), "cert_export", fmt.Sprintf("cert:%d", id), err.Error(), false)
	}
	http.Redirect(w, r, fmt.Sprintf("/certificates/%d/edit#export", id), http.StatusSeeOther)
}

// serverDataDir is the node's configured data directory, "" when unset.
func (s *Server) serverDataDir(serverID int64) string {
	srv, err := models.GetCaddyServer(s.DB, serverID)
	if err != nil || srv == nil {
		return ""
	}
	return strings.TrimSpace(srv.DataDir)
}

// addCertificateExportViewData adds the export settings, last status and the
// node's data directory to a certificate form's template data.
func (s *Server) addCertificateExportViewData(data map[string]any, id int64, cert models.Certificate) {
	data["Export"] = cert.ExportConfig()
	if id > 0 {
		data["ExportStatus"] = s.certificateExportStatusFor(id)
		serverID, _ := models.CertificateServerID(s.DB, id)
		data["ExportDataDir"] = s.serverDataDir(serverID)
	}
}