Issue real certificates via ACME DNS-01
Tunnels now get a per-tunnel certificate from the configured ACME CA (Let's Encrypt by default) with DNS-01 through Porkbun, keeping the client's CSR and key. Issuance is asynchronous (issuing/ready/failed); the account persists in the store, renewals run in a 30-day window with retries, and a failed renewal keeps the old certificate serving. Switching from the local CA reissues existing tunnels automatically, and the admin dashboard shows the issuer and certificate state. Verified against Pebble and Let's Encrypt staging.
This commit is contained in:
+26
-16
@@ -120,29 +120,39 @@ func (e errCSRHostname) Error() string {
|
||||
return "CSR hostname " + e.provided + " does not match tunnel hostname " + e.expected
|
||||
}
|
||||
|
||||
// parseCSR decodes and validates a tunnel CSR: the common name must be the
|
||||
// tunnel hostname and every DNS name must be the hostname or its wildcard.
|
||||
func parseCSR(csrPEM, hostname string) (*x509.CertificateRequest, error) {
|
||||
block, _ := pem.Decode([]byte(csrPEM))
|
||||
if block == nil || block.Type != "CERTIFICATE REQUEST" {
|
||||
return nil, errors.New("not a PEM certificate request")
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, fmt.Errorf("CSR signature: %w", err)
|
||||
}
|
||||
if csr.Subject.CommonName != hostname {
|
||||
return nil, errCSRHostname{provided: csr.Subject.CommonName, expected: hostname}
|
||||
}
|
||||
for _, name := range csr.DNSNames {
|
||||
if name != hostname && name != "*."+hostname {
|
||||
return nil, errCSRHostname{provided: name, expected: hostname}
|
||||
}
|
||||
}
|
||||
return csr, nil
|
||||
}
|
||||
|
||||
// signCSR validates a tunnel CSR and issues the certificate. The rules mirror
|
||||
// the reference's job runner: the common name must be the tunnel hostname and
|
||||
// every DNS name must be the hostname or its wildcard.
|
||||
func (ca *localCA) signCSR(csrPEM, hostname string) (certPEM, chainPEM []byte, expiry time.Time, err error) {
|
||||
block, _ := pem.Decode([]byte(csrPEM))
|
||||
if block == nil || block.Type != "CERTIFICATE REQUEST" {
|
||||
return nil, nil, time.Time{}, errors.New("not a PEM certificate request")
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
csr, err := parseCSR(csrPEM, hostname)
|
||||
if err != nil {
|
||||
return nil, nil, time.Time{}, err
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, nil, time.Time{}, fmt.Errorf("CSR signature: %w", err)
|
||||
}
|
||||
if csr.Subject.CommonName != hostname {
|
||||
return nil, nil, time.Time{}, errCSRHostname{provided: csr.Subject.CommonName, expected: hostname}
|
||||
}
|
||||
for _, name := range csr.DNSNames {
|
||||
if name != hostname && name != "*."+hostname {
|
||||
return nil, nil, time.Time{}, errCSRHostname{provided: name, expected: hostname}
|
||||
}
|
||||
}
|
||||
expiry = time.Now().Add(90 * 24 * time.Hour)
|
||||
der, err := ca.sign(&x509.Certificate{
|
||||
SerialNumber: newSerial(),
|
||||
|
||||
Reference in New Issue
Block a user