Files
sing-box/service/origin_ca/service.go
2026-04-10 16:24:25 +08:00

619 lines
19 KiB
Go

package originca
import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"errors"
"io"
"io/fs"
"net"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/certificate"
"github.com/sagernet/sing-box/common/dialer"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
"github.com/sagernet/sing/common/ntp"
"github.com/caddyserver/certmagic"
)
const (
cloudflareOriginCAEndpoint = "https://api.cloudflare.com/client/v4/certificates"
defaultRequestedValidity = option.CloudflareOriginCARequestValidity5475
// min of 30 days and certmagic's 1/3 lifetime ratio (maintain.go)
defaultRenewBefore = 30 * 24 * time.Hour
// from certmagic retry backoff range (async.go)
minimumRenewRetryDelay = time.Minute
maximumRenewRetryDelay = time.Hour
storageLockPrefix = "cloudflare-origin-ca"
)
func RegisterCertificateProvider(registry *certificate.Registry) {
certificate.Register[option.CloudflareOriginCACertificateProviderOptions](registry, C.TypeCloudflareOriginCA, NewCertificateProvider)
}
var _ adapter.CertificateProviderService = (*Service)(nil)
type Service struct {
certificate.Adapter
logger log.ContextLogger
ctx context.Context
cancel context.CancelFunc
done chan struct{}
timeFunc func() time.Time
httpClient *http.Client
storage certmagic.Storage
storageIssuerKey string
storageNamesKey string
storageLockKey string
apiToken string
originCAKey string
domain []string
requestType option.CloudflareOriginCARequestType
requestedValidity option.CloudflareOriginCARequestValidity
access sync.RWMutex
currentCertificate *tls.Certificate
currentLeaf *x509.Certificate
}
func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag string, options option.CloudflareOriginCACertificateProviderOptions) (adapter.CertificateProviderService, error) {
domain, err := normalizeHostnames(options.Domain)
if err != nil {
return nil, err
}
if len(domain) == 0 {
return nil, E.New("missing domain")
}
apiToken := strings.TrimSpace(options.APIToken)
originCAKey := strings.TrimSpace(options.OriginCAKey)
switch {
case apiToken == "" && originCAKey == "":
return nil, E.New("api_token or origin_ca_key is required")
case apiToken != "" && originCAKey != "":
return nil, E.New("api_token and origin_ca_key are mutually exclusive")
}
requestType := options.RequestType
if requestType == "" {
requestType = option.CloudflareOriginCARequestTypeOriginRSA
}
requestedValidity := options.RequestedValidity
if requestedValidity == 0 {
requestedValidity = defaultRequestedValidity
}
ctx, cancel := context.WithCancel(ctx)
serviceDialer, err := dialer.NewWithOptions(dialer.Options{
Context: ctx,
Options: option.DialerOptions{
Detour: options.Detour,
},
RemoteIsDomain: true,
})
if err != nil {
cancel()
return nil, E.Cause(err, "create Cloudflare Origin CA dialer")
}
var storage certmagic.Storage
if options.DataDirectory != "" {
storage = &certmagic.FileStorage{Path: options.DataDirectory}
} else {
storage = certmagic.Default.Storage
}
timeFunc := ntp.TimeFuncFromContext(ctx)
if timeFunc == nil {
timeFunc = time.Now
}
storageIssuerKey := C.TypeCloudflareOriginCA + "-" + string(requestType)
storageNamesKey := (&certmagic.CertificateResource{SANs: slices.Clone(domain)}).NamesKey()
storageLockKey := strings.Join([]string{
storageLockPrefix,
certmagic.StorageKeys.Safe(storageIssuerKey),
certmagic.StorageKeys.Safe(storageNamesKey),
}, "/")
return &Service{
Adapter: certificate.NewAdapter(C.TypeCloudflareOriginCA, tag),
logger: logger,
ctx: ctx,
cancel: cancel,
timeFunc: timeFunc,
httpClient: &http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return serviceDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
TLSClientConfig: &tls.Config{
RootCAs: adapter.RootPoolFromContext(ctx),
Time: timeFunc,
},
ForceAttemptHTTP2: true,
}},
storage: storage,
storageIssuerKey: storageIssuerKey,
storageNamesKey: storageNamesKey,
storageLockKey: storageLockKey,
apiToken: apiToken,
originCAKey: originCAKey,
domain: domain,
requestType: requestType,
requestedValidity: requestedValidity,
}, nil
}
func (s *Service) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
cachedCertificate, cachedLeaf, err := s.loadCachedCertificate()
if err != nil {
s.logger.Warn(E.Cause(err, "load cached Cloudflare Origin CA certificate"))
} else if cachedCertificate != nil {
s.setCurrentCertificate(cachedCertificate, cachedLeaf)
}
if cachedCertificate == nil {
err = s.issueAndStoreCertificate()
if err != nil {
return err
}
} else if s.shouldRenew(cachedLeaf, s.timeFunc()) {
err = s.issueAndStoreCertificate()
if err != nil {
s.logger.Warn(E.Cause(err, "renew cached Cloudflare Origin CA certificate"))
}
}
s.done = make(chan struct{})
go s.refreshLoop()
return nil
}
func (s *Service) Close() error {
s.cancel()
if done := s.done; done != nil {
<-done
}
if transport, loaded := s.httpClient.Transport.(*http.Transport); loaded {
transport.CloseIdleConnections()
}
return nil
}
func (s *Service) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
s.access.RLock()
certificate := s.currentCertificate
s.access.RUnlock()
if certificate == nil {
return nil, E.New("Cloudflare Origin CA certificate is unavailable")
}
return certificate, nil
}
func (s *Service) refreshLoop() {
defer close(s.done)
var retryDelay time.Duration
for {
waitDuration := retryDelay
if waitDuration == 0 {
s.access.RLock()
leaf := s.currentLeaf
s.access.RUnlock()
if leaf == nil {
waitDuration = minimumRenewRetryDelay
} else {
refreshAt := leaf.NotAfter.Add(-s.effectiveRenewBefore(leaf))
waitDuration = refreshAt.Sub(s.timeFunc())
if waitDuration < minimumRenewRetryDelay {
waitDuration = minimumRenewRetryDelay
}
}
}
timer := time.NewTimer(waitDuration)
select {
case <-s.ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return
case <-timer.C:
}
err := s.issueAndStoreCertificate()
if err != nil {
s.logger.Error(E.Cause(err, "renew Cloudflare Origin CA certificate"))
s.access.RLock()
leaf := s.currentLeaf
s.access.RUnlock()
if leaf == nil {
retryDelay = minimumRenewRetryDelay
} else {
remaining := leaf.NotAfter.Sub(s.timeFunc())
switch {
case remaining <= minimumRenewRetryDelay:
retryDelay = minimumRenewRetryDelay
case remaining < maximumRenewRetryDelay:
retryDelay = max(remaining/2, minimumRenewRetryDelay)
default:
retryDelay = maximumRenewRetryDelay
}
}
continue
}
retryDelay = 0
}
}
func (s *Service) shouldRenew(leaf *x509.Certificate, now time.Time) bool {
return !now.Before(leaf.NotAfter.Add(-s.effectiveRenewBefore(leaf)))
}
func (s *Service) effectiveRenewBefore(leaf *x509.Certificate) time.Duration {
lifetime := leaf.NotAfter.Sub(leaf.NotBefore)
if lifetime <= 0 {
return 0
}
return min(lifetime/3, defaultRenewBefore)
}
func (s *Service) issueAndStoreCertificate() error {
err := s.storage.Lock(s.ctx, s.storageLockKey)
if err != nil {
return E.Cause(err, "lock Cloudflare Origin CA certificate storage")
}
defer func() {
err = s.storage.Unlock(context.WithoutCancel(s.ctx), s.storageLockKey)
if err != nil {
s.logger.Warn(E.Cause(err, "unlock Cloudflare Origin CA certificate storage"))
}
}()
cachedCertificate, cachedLeaf, err := s.loadCachedCertificate()
if err != nil {
s.logger.Warn(E.Cause(err, "load cached Cloudflare Origin CA certificate"))
} else if cachedCertificate != nil && !s.shouldRenew(cachedLeaf, s.timeFunc()) {
s.setCurrentCertificate(cachedCertificate, cachedLeaf)
return nil
}
certificatePEM, privateKeyPEM, tlsCertificate, leaf, err := s.requestCertificate(s.ctx)
if err != nil {
return err
}
issuerData, err := json.Marshal(originCAIssuerData{
RequestType: s.requestType,
RequestedValidity: s.requestedValidity,
})
if err != nil {
return E.Cause(err, "encode Cloudflare Origin CA certificate metadata")
}
err = storeCertificateResource(s.ctx, s.storage, s.storageIssuerKey, certmagic.CertificateResource{
SANs: slices.Clone(s.domain),
CertificatePEM: certificatePEM,
PrivateKeyPEM: privateKeyPEM,
IssuerData: issuerData,
})
if err != nil {
return E.Cause(err, "store Cloudflare Origin CA certificate")
}
s.setCurrentCertificate(tlsCertificate, leaf)
s.logger.Info("updated Cloudflare Origin CA certificate, expires at ", leaf.NotAfter.Format(time.RFC3339))
return nil
}
func (s *Service) requestCertificate(ctx context.Context) ([]byte, []byte, *tls.Certificate, *x509.Certificate, error) {
var privateKey crypto.Signer
switch s.requestType {
case option.CloudflareOriginCARequestTypeOriginRSA:
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, nil, nil, err
}
privateKey = rsaKey
case option.CloudflareOriginCARequestTypeOriginECC:
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, nil, nil, err
}
privateKey = ecKey
default:
return nil, nil, nil, nil, E.New("unsupported Cloudflare Origin CA request type: ", s.requestType)
}
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return nil, nil, nil, nil, E.Cause(err, "encode private key")
}
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privateKeyDER,
})
certificateRequestDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
Subject: pkix.Name{CommonName: s.domain[0]},
DNSNames: s.domain,
}, privateKey)
if err != nil {
return nil, nil, nil, nil, E.Cause(err, "create certificate request")
}
certificateRequestPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: certificateRequestDER,
})
requestBody, err := json.Marshal(originCARequest{
CSR: string(certificateRequestPEM),
Hostnames: s.domain,
RequestType: string(s.requestType),
RequestedValidity: uint16(s.requestedValidity),
})
if err != nil {
return nil, nil, nil, nil, E.Cause(err, "marshal request")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, cloudflareOriginCAEndpoint, bytes.NewReader(requestBody))
if err != nil {
return nil, nil, nil, nil, E.Cause(err, "create request")
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "sing-box/"+C.Version)
if s.apiToken != "" {
request.Header.Set("Authorization", "Bearer "+s.apiToken)
} else {
request.Header.Set("X-Auth-User-Service-Key", s.originCAKey)
}
response, err := s.httpClient.Do(request)
if err != nil {
return nil, nil, nil, nil, E.Cause(err, "request certificate from Cloudflare")
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return nil, nil, nil, nil, E.Cause(err, "read Cloudflare response")
}
var responseEnvelope originCAResponse
err = json.Unmarshal(responseBody, &responseEnvelope)
if err != nil && response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
return nil, nil, nil, nil, E.Cause(err, "decode Cloudflare response")
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return nil, nil, nil, nil, buildOriginCAError(response.StatusCode, responseEnvelope.Errors, responseBody)
}
if !responseEnvelope.Success {
return nil, nil, nil, nil, buildOriginCAError(response.StatusCode, responseEnvelope.Errors, responseBody)
}
if responseEnvelope.Result.Certificate == "" {
return nil, nil, nil, nil, E.New("Cloudflare Origin CA response is missing certificate data")
}
certificatePEM := []byte(responseEnvelope.Result.Certificate)
tlsCertificate, leaf, err := parseKeyPair(certificatePEM, privateKeyPEM)
if err != nil {
return nil, nil, nil, nil, E.Cause(err, "parse issued certificate")
}
if !s.matchesCertificate(leaf) {
return nil, nil, nil, nil, E.New("issued Cloudflare Origin CA certificate does not match requested hostnames or key type")
}
return certificatePEM, privateKeyPEM, tlsCertificate, leaf, nil
}
func (s *Service) loadCachedCertificate() (*tls.Certificate, *x509.Certificate, error) {
certificateResource, err := loadCertificateResource(s.ctx, s.storage, s.storageIssuerKey, s.storageNamesKey)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil, nil
}
return nil, nil, err
}
tlsCertificate, leaf, err := parseKeyPair(certificateResource.CertificatePEM, certificateResource.PrivateKeyPEM)
if err != nil {
return nil, nil, E.Cause(err, "parse cached key pair")
}
if s.timeFunc().After(leaf.NotAfter) {
return nil, nil, nil
}
if !s.matchesCertificate(leaf) {
return nil, nil, nil
}
return tlsCertificate, leaf, nil
}
func (s *Service) matchesCertificate(leaf *x509.Certificate) bool {
if leaf == nil {
return false
}
leafHostnames := leaf.DNSNames
if len(leafHostnames) == 0 && leaf.Subject.CommonName != "" {
leafHostnames = []string{leaf.Subject.CommonName}
}
normalizedLeafHostnames, err := normalizeHostnames(leafHostnames)
if err != nil {
return false
}
if !slices.Equal(normalizedLeafHostnames, s.domain) {
return false
}
switch s.requestType {
case option.CloudflareOriginCARequestTypeOriginRSA:
return leaf.PublicKeyAlgorithm == x509.RSA
case option.CloudflareOriginCARequestTypeOriginECC:
return leaf.PublicKeyAlgorithm == x509.ECDSA
default:
return false
}
}
func (s *Service) setCurrentCertificate(certificate *tls.Certificate, leaf *x509.Certificate) {
s.access.Lock()
s.currentCertificate = certificate
s.currentLeaf = leaf
s.access.Unlock()
}
func normalizeHostnames(hostnames []string) ([]string, error) {
normalizedHostnames := make([]string, 0, len(hostnames))
seen := make(map[string]struct{}, len(hostnames))
for _, hostname := range hostnames {
normalizedHostname := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(hostname, ".")))
if normalizedHostname == "" {
return nil, E.New("hostname is empty")
}
if net.ParseIP(normalizedHostname) != nil {
return nil, E.New("hostname cannot be an IP address: ", normalizedHostname)
}
if strings.Contains(normalizedHostname, "*") {
if !strings.HasPrefix(normalizedHostname, "*.") || strings.Count(normalizedHostname, "*") != 1 {
return nil, E.New("invalid wildcard hostname: ", normalizedHostname)
}
suffix := strings.TrimPrefix(normalizedHostname, "*.")
if strings.Count(suffix, ".") == 0 {
return nil, E.New("wildcard hostname must cover a multi-label domain: ", normalizedHostname)
}
normalizedHostname = "*." + suffix
}
if _, loaded := seen[normalizedHostname]; loaded {
continue
}
seen[normalizedHostname] = struct{}{}
normalizedHostnames = append(normalizedHostnames, normalizedHostname)
}
slices.Sort(normalizedHostnames)
return normalizedHostnames, nil
}
func parseKeyPair(certificatePEM []byte, privateKeyPEM []byte) (*tls.Certificate, *x509.Certificate, error) {
keyPair, err := tls.X509KeyPair(certificatePEM, privateKeyPEM)
if err != nil {
return nil, nil, err
}
if len(keyPair.Certificate) == 0 {
return nil, nil, E.New("certificate chain is empty")
}
leaf, err := x509.ParseCertificate(keyPair.Certificate[0])
if err != nil {
return nil, nil, err
}
keyPair.Leaf = leaf
return &keyPair, leaf, nil
}
func storeCertificateResource(ctx context.Context, storage certmagic.Storage, issuerKey string, certificateResource certmagic.CertificateResource) error {
metaBytes, err := json.MarshalIndent(certificateResource, "", "\t")
if err != nil {
return err
}
namesKey := certificateResource.NamesKey()
keyValueList := []struct {
key string
value []byte
}{
{
key: certmagic.StorageKeys.SitePrivateKey(issuerKey, namesKey),
value: certificateResource.PrivateKeyPEM,
},
{
key: certmagic.StorageKeys.SiteCert(issuerKey, namesKey),
value: certificateResource.CertificatePEM,
},
{
key: certmagic.StorageKeys.SiteMeta(issuerKey, namesKey),
value: metaBytes,
},
}
for i, item := range keyValueList {
err = storage.Store(ctx, item.key, item.value)
if err != nil {
for j := i - 1; j >= 0; j-- {
storage.Delete(ctx, keyValueList[j].key)
}
return err
}
}
return nil
}
func loadCertificateResource(ctx context.Context, storage certmagic.Storage, issuerKey string, namesKey string) (certmagic.CertificateResource, error) {
privateKeyPEM, err := storage.Load(ctx, certmagic.StorageKeys.SitePrivateKey(issuerKey, namesKey))
if err != nil {
return certmagic.CertificateResource{}, err
}
certificatePEM, err := storage.Load(ctx, certmagic.StorageKeys.SiteCert(issuerKey, namesKey))
if err != nil {
return certmagic.CertificateResource{}, err
}
metaBytes, err := storage.Load(ctx, certmagic.StorageKeys.SiteMeta(issuerKey, namesKey))
if err != nil {
return certmagic.CertificateResource{}, err
}
var certificateResource certmagic.CertificateResource
err = json.Unmarshal(metaBytes, &certificateResource)
if err != nil {
return certmagic.CertificateResource{}, E.Cause(err, "decode Cloudflare Origin CA certificate metadata")
}
certificateResource.PrivateKeyPEM = privateKeyPEM
certificateResource.CertificatePEM = certificatePEM
return certificateResource, nil
}
func buildOriginCAError(statusCode int, responseErrors []originCAResponseError, responseBody []byte) error {
if len(responseErrors) > 0 {
messageList := make([]string, 0, len(responseErrors))
for _, responseError := range responseErrors {
if responseError.Message == "" {
continue
}
if responseError.Code != 0 {
messageList = append(messageList, responseError.Message+" (code "+strconv.Itoa(responseError.Code)+")")
} else {
messageList = append(messageList, responseError.Message)
}
}
if len(messageList) > 0 {
return E.New("Cloudflare Origin CA request failed: HTTP ", statusCode, " ", strings.Join(messageList, ", "))
}
}
responseText := strings.TrimSpace(string(responseBody))
if responseText == "" {
return E.New("Cloudflare Origin CA request failed: HTTP ", statusCode)
}
return E.New("Cloudflare Origin CA request failed: HTTP ", statusCode, " ", responseText)
}
type originCARequest struct {
CSR string `json:"csr"`
Hostnames []string `json:"hostnames"`
RequestType string `json:"request_type"`
RequestedValidity uint16 `json:"requested_validity"`
}
type originCAResponse struct {
Success bool `json:"success"`
Errors []originCAResponseError `json:"errors"`
Result originCAResponseResult `json:"result"`
}
type originCAResponseError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type originCAResponseResult struct {
Certificate string `json:"certificate"`
}
type originCAIssuerData struct {
RequestType option.CloudflareOriginCARequestType `json:"request_type,omitempty"`
RequestedValidity option.CloudflareOriginCARequestValidity `json:"requested_validity,omitempty"`
}