//go:build with_acme package acme import ( "bytes" "context" "crypto/tls" "encoding/json" "net" "net/http" "net/url" "reflect" "strings" "time" "unsafe" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/certificate" "github.com/sagernet/sing-box/common/dialer" boxtls "github.com/sagernet/sing-box/common/tls" 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" "github.com/caddyserver/zerossl" "github.com/libdns/alidns" "github.com/libdns/cloudflare" "github.com/libdns/libdns" "github.com/mholt/acmez/v3/acme" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) func RegisterCertificateProvider(registry *certificate.Registry) { certificate.Register[option.ACMECertificateProviderOptions](registry, C.TypeACME, NewCertificateProvider) } var ( _ adapter.CertificateProviderService = (*Service)(nil) _ adapter.ACMECertificateProvider = (*Service)(nil) ) type Service struct { certificate.Adapter ctx context.Context config *certmagic.Config cache *certmagic.Cache domain []string nextProtos []string } func NewCertificateProvider(ctx context.Context, logger log.ContextLogger, tag string, options option.ACMECertificateProviderOptions) (adapter.CertificateProviderService, error) { if len(options.Domain) == 0 { return nil, E.New("missing domain") } var acmeServer string switch options.Provider { case "", "letsencrypt": acmeServer = certmagic.LetsEncryptProductionCA case "zerossl": acmeServer = certmagic.ZeroSSLProductionCA default: if !strings.HasPrefix(options.Provider, "https://") { return nil, E.New("unsupported ACME provider: ", options.Provider) } acmeServer = options.Provider } if acmeServer == certmagic.ZeroSSLProductionCA && (options.ExternalAccount == nil || options.ExternalAccount.KeyID == "") && strings.TrimSpace(options.Email) == "" && strings.TrimSpace(options.AccountKey) == "" { return nil, E.New("email is required to use the ZeroSSL ACME endpoint without external_account or account_key") } var storage certmagic.Storage if options.DataDirectory != "" { storage = &certmagic.FileStorage{Path: options.DataDirectory} } else { storage = certmagic.Default.Storage } zapLogger := zap.New(zapcore.NewCore( zapcore.NewConsoleEncoder(boxtls.ACMEEncoderConfig()), &boxtls.ACMELogWriter{Logger: logger}, zap.DebugLevel, )) config := &certmagic.Config{ DefaultServerName: options.DefaultServerName, Storage: storage, Logger: zapLogger, } if options.KeyType != "" { var keyType certmagic.KeyType switch options.KeyType { case option.ACMEKeyTypeED25519: keyType = certmagic.ED25519 case option.ACMEKeyTypeP256: keyType = certmagic.P256 case option.ACMEKeyTypeP384: keyType = certmagic.P384 case option.ACMEKeyTypeRSA2048: keyType = certmagic.RSA2048 case option.ACMEKeyTypeRSA4096: keyType = certmagic.RSA4096 default: return nil, E.New("unsupported ACME key type: ", options.KeyType) } config.KeySource = certmagic.StandardKeyGenerator{KeyType: keyType} } acmeIssuer := certmagic.ACMEIssuer{ CA: acmeServer, Email: options.Email, AccountKeyPEM: options.AccountKey, Agreed: true, DisableHTTPChallenge: options.DisableHTTPChallenge, DisableTLSALPNChallenge: options.DisableTLSALPNChallenge, AltHTTPPort: int(options.AlternativeHTTPPort), AltTLSALPNPort: int(options.AlternativeTLSPort), Logger: zapLogger, } acmeHTTPClient, err := newACMEHTTPClient(ctx, options.Detour) if err != nil { return nil, err } dnsSolver, err := newDNSSolver(options.DNS01Challenge, zapLogger, acmeHTTPClient) if err != nil { return nil, err } if dnsSolver != nil { acmeIssuer.DNS01Solver = dnsSolver } if options.ExternalAccount != nil && options.ExternalAccount.KeyID != "" { acmeIssuer.ExternalAccount = (*acme.EAB)(options.ExternalAccount) } if acmeServer == certmagic.ZeroSSLProductionCA { acmeIssuer.NewAccountFunc = func(ctx context.Context, acmeIssuer *certmagic.ACMEIssuer, account acme.Account) (acme.Account, error) { if acmeIssuer.ExternalAccount != nil { return account, nil } var err error acmeIssuer.ExternalAccount, account, err = createZeroSSLExternalAccountBinding(ctx, acmeIssuer, account, acmeHTTPClient) return account, err } } certmagicIssuer := certmagic.NewACMEIssuer(config, acmeIssuer) httpClientField := reflect.ValueOf(certmagicIssuer).Elem().FieldByName("httpClient") if !httpClientField.IsValid() || !httpClientField.CanAddr() { return nil, E.New("certmagic ACME issuer HTTP client field is unavailable") } reflect.NewAt(httpClientField.Type(), unsafe.Pointer(httpClientField.UnsafeAddr())).Elem().Set(reflect.ValueOf(acmeHTTPClient)) config.Issuers = []certmagic.Issuer{certmagicIssuer} cache := certmagic.NewCache(certmagic.CacheOptions{ GetConfigForCert: func(certificate certmagic.Certificate) (*certmagic.Config, error) { return config, nil }, Logger: zapLogger, }) config = certmagic.New(cache, *config) var nextProtos []string if !acmeIssuer.DisableTLSALPNChallenge && acmeIssuer.DNS01Solver == nil { nextProtos = []string{C.ACMETLS1Protocol} } return &Service{ Adapter: certificate.NewAdapter(C.TypeACME, tag), ctx: ctx, config: config, cache: cache, domain: options.Domain, nextProtos: nextProtos, }, nil } func (s *Service) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } return s.config.ManageAsync(s.ctx, s.domain) } func (s *Service) Close() error { if s.cache != nil { s.cache.Stop() } return nil } func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { return s.config.GetCertificate(hello) } func (s *Service) GetACMENextProtos() []string { return s.nextProtos } func newDNSSolver(dnsOptions *option.ACMEProviderDNS01ChallengeOptions, logger *zap.Logger, httpClient *http.Client) (*certmagic.DNS01Solver, error) { if dnsOptions == nil || dnsOptions.Provider == "" { return nil, nil } if dnsOptions.TTL < 0 { return nil, E.New("invalid ACME DNS01 ttl: ", dnsOptions.TTL) } if dnsOptions.PropagationDelay < 0 { return nil, E.New("invalid ACME DNS01 propagation_delay: ", dnsOptions.PropagationDelay) } if dnsOptions.PropagationTimeout < -1 { return nil, E.New("invalid ACME DNS01 propagation_timeout: ", dnsOptions.PropagationTimeout) } solver := &certmagic.DNS01Solver{ DNSManager: certmagic.DNSManager{ TTL: time.Duration(dnsOptions.TTL), PropagationDelay: time.Duration(dnsOptions.PropagationDelay), PropagationTimeout: time.Duration(dnsOptions.PropagationTimeout), Resolvers: dnsOptions.Resolvers, OverrideDomain: dnsOptions.OverrideDomain, Logger: logger.Named("dns_manager"), }, } switch dnsOptions.Provider { case C.DNSProviderAliDNS: solver.DNSProvider = &alidns.Provider{ CredentialInfo: alidns.CredentialInfo{ AccessKeyID: dnsOptions.AliDNSOptions.AccessKeyID, AccessKeySecret: dnsOptions.AliDNSOptions.AccessKeySecret, RegionID: dnsOptions.AliDNSOptions.RegionID, SecurityToken: dnsOptions.AliDNSOptions.SecurityToken, }, } case C.DNSProviderCloudflare: solver.DNSProvider = &cloudflare.Provider{ APIToken: dnsOptions.CloudflareOptions.APIToken, ZoneToken: dnsOptions.CloudflareOptions.ZoneToken, HTTPClient: httpClient, } case C.DNSProviderACMEDNS: solver.DNSProvider = &acmeDNSProvider{ username: dnsOptions.ACMEDNSOptions.Username, password: dnsOptions.ACMEDNSOptions.Password, subdomain: dnsOptions.ACMEDNSOptions.Subdomain, serverURL: dnsOptions.ACMEDNSOptions.ServerURL, httpClient: httpClient, } default: return nil, E.New("unsupported ACME DNS01 provider type: ", dnsOptions.Provider) } return solver, nil } func createZeroSSLExternalAccountBinding(ctx context.Context, acmeIssuer *certmagic.ACMEIssuer, account acme.Account, httpClient *http.Client) (*acme.EAB, acme.Account, error) { email := strings.TrimSpace(acmeIssuer.Email) if email == "" { return nil, acme.Account{}, E.New("email is required to use the ZeroSSL ACME endpoint without external_account") } if len(account.Contact) == 0 { account.Contact = []string{"mailto:" + email} } if acmeIssuer.CertObtainTimeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, acmeIssuer.CertObtainTimeout) defer cancel() } form := url.Values{"email": []string{email}} request, err := http.NewRequestWithContext(ctx, http.MethodPost, zerossl.BaseURL+"/acme/eab-credentials-email", strings.NewReader(form.Encode())) if err != nil { return nil, account, E.Cause(err, "create ZeroSSL EAB request") } request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("User-Agent", certmagic.UserAgent) response, err := httpClient.Do(request) if err != nil { return nil, account, E.Cause(err, "request ZeroSSL EAB") } defer response.Body.Close() var result struct { Success bool `json:"success"` Error struct { Code int `json:"code"` Type string `json:"type"` } `json:"error"` EABKID string `json:"eab_kid"` EABHMACKey string `json:"eab_hmac_key"` } err = json.NewDecoder(response.Body).Decode(&result) if err != nil { return nil, account, E.Cause(err, "decode ZeroSSL EAB response") } if response.StatusCode != http.StatusOK { return nil, account, E.New("failed getting ZeroSSL EAB credentials: HTTP ", response.StatusCode) } if result.Error.Code != 0 { return nil, account, E.New("failed getting ZeroSSL EAB credentials: ", result.Error.Type, " (code ", result.Error.Code, ")") } acmeIssuer.Logger.Info("generated ZeroSSL EAB credentials", zap.String("key_id", result.EABKID)) return &acme.EAB{ KeyID: result.EABKID, MACKey: result.EABHMACKey, }, account, nil } func newACMEHTTPClient(ctx context.Context, detour string) (*http.Client, error) { outboundDialer, err := dialer.NewWithOptions(dialer.Options{ Context: ctx, Options: option.DialerOptions{ Detour: detour, }, RemoteIsDomain: true, }) if err != nil { return nil, E.Cause(err, "create ACME provider dialer") } return &http.Client{ Transport: &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return outboundDialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) }, TLSClientConfig: &tls.Config{ RootCAs: adapter.RootPoolFromContext(ctx), Time: ntp.TimeFuncFromContext(ctx), }, // from certmagic defaults (acmeissuer.go) TLSHandshakeTimeout: 30 * time.Second, ResponseHeaderTimeout: 30 * time.Second, ExpectContinueTimeout: 2 * time.Second, ForceAttemptHTTP2: true, }, Timeout: certmagic.HTTPTimeout, }, nil } type acmeDNSProvider struct { username string password string subdomain string serverURL string httpClient *http.Client } type acmeDNSRecord struct { resourceRecord libdns.RR } func (r acmeDNSRecord) RR() libdns.RR { return r.resourceRecord } func (p *acmeDNSProvider) AppendRecords(ctx context.Context, _ string, records []libdns.Record) ([]libdns.Record, error) { if p.username == "" { return nil, E.New("ACME-DNS username cannot be empty") } if p.password == "" { return nil, E.New("ACME-DNS password cannot be empty") } if p.subdomain == "" { return nil, E.New("ACME-DNS subdomain cannot be empty") } if p.serverURL == "" { return nil, E.New("ACME-DNS server_url cannot be empty") } appendedRecords := make([]libdns.Record, 0, len(records)) for _, record := range records { resourceRecord := record.RR() if resourceRecord.Type != "TXT" { return appendedRecords, E.New("ACME-DNS only supports adding TXT records") } requestBody, err := json.Marshal(map[string]string{ "subdomain": p.subdomain, "txt": resourceRecord.Data, }) if err != nil { return appendedRecords, E.Cause(err, "marshal ACME-DNS update request") } request, err := http.NewRequestWithContext(ctx, http.MethodPost, p.serverURL+"/update", bytes.NewReader(requestBody)) if err != nil { return appendedRecords, E.Cause(err, "create ACME-DNS update request") } request.Header.Set("X-Api-User", p.username) request.Header.Set("X-Api-Key", p.password) request.Header.Set("Content-Type", "application/json") response, err := p.httpClient.Do(request) if err != nil { return appendedRecords, E.Cause(err, "update ACME-DNS record") } _ = response.Body.Close() if response.StatusCode != http.StatusOK { return appendedRecords, E.New("update ACME-DNS record: HTTP ", response.StatusCode) } appendedRecords = append(appendedRecords, acmeDNSRecord{resourceRecord: libdns.RR{ Type: "TXT", Name: resourceRecord.Name, Data: resourceRecord.Data, }}) } return appendedRecords, nil } func (p *acmeDNSProvider) DeleteRecords(context.Context, string, []libdns.Record) ([]libdns.Record, error) { return nil, nil }