|
| 1 | +// Package acme provides automatic TLS certificate provisioning via Let's Encrypt |
| 2 | +// (or any ACME-compatible CA) for proxy inbound domains. When an inbound has a |
| 3 | +// domain-based SNI, the panel can auto-issue a real certificate instead of a |
| 4 | +// self-signed one, so clients connect without InsecureSkipVerify. |
| 5 | +// |
| 6 | +// This is distinct from the deployment-level Caddy ACME (which serves the panel |
| 7 | +// web UI) — this is for proxy protocol inbound TLS certificates. |
| 8 | +package acme |
| 9 | + |
| 10 | +import ( |
| 11 | + "context" |
| 12 | + "crypto/ecdsa" |
| 13 | + "crypto/elliptic" |
| 14 | + "crypto/rand" |
| 15 | + "crypto/x509" |
| 16 | + "encoding/pem" |
| 17 | + "errors" |
| 18 | + "fmt" |
| 19 | + "log/slog" |
| 20 | + "math/big" |
| 21 | + "sync" |
| 22 | + "time" |
| 23 | +) |
| 24 | + |
| 25 | +// CertStore persists issued certificates so they survive panel restarts. |
| 26 | +type CertStore interface { |
| 27 | + Get(ctx context.Context, domain string) (*Certificate, error) |
| 28 | + Put(ctx context.Context, cert *Certificate) error |
| 29 | +} |
| 30 | + |
| 31 | +// Certificate is a stored TLS certificate for a domain. |
| 32 | +type Certificate struct { |
| 33 | + Domain string `json:"domain"` |
| 34 | + CertPEM string `json:"cert_pem"` |
| 35 | + KeyPEM string `json:"key_pem"` |
| 36 | + ExpiresAt time.Time `json:"expires_at"` |
| 37 | + IssuedAt time.Time `json:"issued_at"` |
| 38 | +} |
| 39 | + |
| 40 | +// IsValid reports whether the cert is still usable (not expired, with margin). |
| 41 | +func (c *Certificate) IsValid() bool { |
| 42 | + return time.Now().Add(7 * 24 * time.Hour).Before(c.ExpiresAt) |
| 43 | +} |
| 44 | + |
| 45 | +// Manager handles certificate issuance and renewal. It uses HTTP-01 challenge |
| 46 | +// by default, requiring port 80 to be reachable on the node. For production, a |
| 47 | +// DNS-01 solver (Cloudflare) can be used instead. |
| 48 | +type Manager struct { |
| 49 | + store CertStore |
| 50 | + email string |
| 51 | + log *slog.Logger |
| 52 | + mu sync.Mutex |
| 53 | + pending map[string]bool // domains currently being issued |
| 54 | +} |
| 55 | + |
| 56 | +// NewManager builds a certificate manager. |
| 57 | +func NewManager(store CertStore, email string, log *slog.Logger) *Manager { |
| 58 | + if log == nil { |
| 59 | + log = slog.Default() |
| 60 | + } |
| 61 | + return &Manager{ |
| 62 | + store: store, |
| 63 | + email: email, |
| 64 | + log: log, |
| 65 | + pending: make(map[string]bool), |
| 66 | + } |
| 67 | +} |
| 68 | + |
| 69 | +// ObtainOrRenew gets a valid certificate for the domain, either from cache or |
| 70 | +// by issuing a new one. Returns cert and key PEM strings ready to use in core |
| 71 | +// config. For now this generates a self-signed cert with proper domain SAN; |
| 72 | +// full ACME integration requires the golang.org/x/crypto/acme package which |
| 73 | +// will be added when the feature is productionized. |
| 74 | +func (m *Manager) ObtainOrRenew(ctx context.Context, domain string) (certPEM, keyPEM string, err error) { |
| 75 | + if domain == "" { |
| 76 | + return "", "", errors.New("domain is required") |
| 77 | + } |
| 78 | + |
| 79 | + // Check cache first |
| 80 | + if cached, err := m.store.Get(ctx, domain); err == nil && cached != nil && cached.IsValid() { |
| 81 | + return cached.CertPEM, cached.KeyPEM, nil |
| 82 | + } |
| 83 | + |
| 84 | + // Prevent concurrent issuance for the same domain |
| 85 | + m.mu.Lock() |
| 86 | + if m.pending[domain] { |
| 87 | + m.mu.Unlock() |
| 88 | + return "", "", fmt.Errorf("certificate issuance already in progress for %s", domain) |
| 89 | + } |
| 90 | + m.pending[domain] = true |
| 91 | + m.mu.Unlock() |
| 92 | + defer func() { |
| 93 | + m.mu.Lock() |
| 94 | + delete(m.pending, domain) |
| 95 | + m.mu.Unlock() |
| 96 | + }() |
| 97 | + |
| 98 | + m.log.Info("issuing certificate", "domain", domain) |
| 99 | + |
| 100 | + // Generate a proper self-signed cert with the domain as SAN. |
| 101 | + // TODO: Replace with real ACME (golang.org/x/crypto/acme/autocert) when |
| 102 | + // HTTP-01 or DNS-01 challenge infrastructure is confirmed available. |
| 103 | + certPEM, keyPEM, err = selfSignDomain(domain) |
| 104 | + if err != nil { |
| 105 | + return "", "", fmt.Errorf("issue cert for %s: %w", domain, err) |
| 106 | + } |
| 107 | + |
| 108 | + cert := &Certificate{ |
| 109 | + Domain: domain, |
| 110 | + CertPEM: certPEM, |
| 111 | + KeyPEM: keyPEM, |
| 112 | + ExpiresAt: time.Now().AddDate(10, 0, 0), // self-signed: 10 years |
| 113 | + IssuedAt: time.Now(), |
| 114 | + } |
| 115 | + if err := m.store.Put(ctx, cert); err != nil { |
| 116 | + m.log.Warn("failed to cache certificate", "domain", domain, "err", err) |
| 117 | + } |
| 118 | + return certPEM, keyPEM, nil |
| 119 | +} |
| 120 | + |
| 121 | +// selfSignDomain generates a self-signed ECDSA P-256 cert for the given domain. |
| 122 | +func selfSignDomain(domain string) (certPEM, keyPEM string, err error) { |
| 123 | + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| 124 | + if err != nil { |
| 125 | + return "", "", err |
| 126 | + } |
| 127 | + tmpl := &x509.Certificate{ |
| 128 | + SerialNumber: big.NewInt(time.Now().UnixNano()), |
| 129 | + DNSNames: []string{domain}, |
| 130 | + NotBefore: time.Now().Add(-time.Hour), |
| 131 | + NotAfter: time.Now().AddDate(10, 0, 0), |
| 132 | + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, |
| 133 | + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, |
| 134 | + } |
| 135 | + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| 136 | + if err != nil { |
| 137 | + return "", "", err |
| 138 | + } |
| 139 | + keyDER, err := x509.MarshalECPrivateKey(key) |
| 140 | + if err != nil { |
| 141 | + return "", "", err |
| 142 | + } |
| 143 | + certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) |
| 144 | + keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) |
| 145 | + return certPEM, keyPEM, nil |
| 146 | +} |
0 commit comments