Files
TZGyn 437a2e447e 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.
2026-10-25 23:33:52 +08:00

185 lines
5.5 KiB
Go

package server
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"time"
)
// localCA is the insecure built-in issuer for development and self-hosting,
// the equivalent of ISSUER=local in the reference server. The CA and every
// certificate it issues use ECDSA P-256, like the ACME-issued ones.
type localCA struct {
key *ecdsa.PrivateKey
cert *x509.Certificate
certPEM []byte
}
func newLocalCA() (*localCA, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "tunnel local CA"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, err
}
return &localCA{
key: key,
cert: cert,
certPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
}, nil
}
func loadLocalCA(keyPEM, certPEM []byte) (*localCA, error) {
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return nil, errors.New("no PEM block in CA key")
}
key, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, err
}
ecKey, ok := key.(*ecdsa.PrivateKey)
if !ok {
return nil, errors.New("CA key is not ECDSA")
}
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil {
return nil, errors.New("no PEM block in CA certificate")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, err
}
return &localCA{key: ecKey, cert: cert, certPEM: pem.EncodeToMemory(certBlock)}, nil
}
func (ca *localCA) keyPEM() ([]byte, error) {
der, err := x509.MarshalPKCS8PrivateKey(ca.key)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil
}
// issue signs a certificate for names with a fresh P-256 key. Used for the
// server's own API certificate.
func (ca *localCA) issue(names ...string) (certPEM, keyPEM []byte, err error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
der, err := ca.sign(&x509.Certificate{
SerialNumber: newSerial(),
Subject: pkix.Name{CommonName: names[0]},
DNSNames: names,
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(90 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}, &key.PublicKey)
if err != nil {
return nil, nil, err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), nil
}
// errCSRHostname reports a CSR whose names are not the tunnel's own.
type errCSRHostname struct {
provided string
expected string
}
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) {
csr, err := parseCSR(csrPEM, hostname)
if err != nil {
return nil, nil, time.Time{}, err
}
expiry = time.Now().Add(90 * 24 * time.Hour)
der, err := ca.sign(&x509.Certificate{
SerialNumber: newSerial(),
Subject: pkix.Name{CommonName: hostname},
DNSNames: csr.DNSNames,
NotBefore: time.Now().Add(-time.Minute),
NotAfter: expiry,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}, csr.PublicKey)
if err != nil {
return nil, nil, time.Time{}, err
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), ca.certPEM, expiry, nil
}
func (ca *localCA) sign(template *x509.Certificate, pub any) ([]byte, error) {
return x509.CreateCertificate(rand.Reader, template, ca.cert, pub, ca.key)
}
var serialLimit = new(big.Int).Lsh(big.NewInt(1), 128)
func newSerial() *big.Int {
n, err := rand.Int(rand.Reader, serialLimit)
if err != nil {
panic(err)
}
return n
}