Compare commits

...

1 Commits

Author SHA1 Message Date
世界
734f3c9a21 Refactor ACME support to certificate provider system 2026-03-14 22:43:20 +08:00
15 changed files with 504 additions and 39 deletions

View File

@@ -0,0 +1,14 @@
package adapter
import (
"crypto/tls"
)
type CertificateProvider interface {
GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
}
type ACMECertificateProvider interface {
CertificateProvider
GetACMENextProtos() []string
}

36
box.go
View File

@@ -272,6 +272,24 @@ func New(options Options) (*Box, error) {
return nil, E.Cause(err, "initialize inbound[", i, "]")
}
}
for i, serviceOptions := range options.Services {
var tag string
if serviceOptions.Tag != "" {
tag = serviceOptions.Tag
} else {
tag = F.ToString(i)
}
err = serviceManager.Create(
ctx,
logFactory.NewLogger(F.ToString("service/", serviceOptions.Type, "[", tag, "]")),
tag,
serviceOptions.Type,
serviceOptions.Options,
)
if err != nil {
return nil, E.Cause(err, "initialize service[", i, "]")
}
}
for i, outboundOptions := range options.Outbounds {
var tag string
if outboundOptions.Tag != "" {
@@ -298,24 +316,6 @@ func New(options Options) (*Box, error) {
return nil, E.Cause(err, "initialize outbound[", i, "]")
}
}
for i, serviceOptions := range options.Services {
var tag string
if serviceOptions.Tag != "" {
tag = serviceOptions.Tag
} else {
tag = F.ToString(i)
}
err = serviceManager.Create(
ctx,
logFactory.NewLogger(F.ToString("service/", serviceOptions.Type, "[", tag, "]")),
tag,
serviceOptions.Type,
serviceOptions.Options,
)
if err != nil {
return nil, E.Cause(err, "initialize service[", i, "]")
}
}
outboundManager.Initialize(func() (adapter.Outbound, error) {
return direct.NewOutbound(
ctx,

View File

@@ -1,3 +1,5 @@
package tls
const ACMETLS1Protocol = "acme-tls/1"
import C "github.com/sagernet/sing-box/constant"
const ACMETLS1Protocol = C.ACMETLS1Protocol

View File

@@ -18,14 +18,77 @@ import (
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/ntp"
"github.com/sagernet/sing/service"
)
var errInsecureUnused = E.New("tls: insecure unused")
type managedCertificateProvider interface {
adapter.CertificateProvider
Start() error
Close() error
}
type acmeServiceCertificateProvider struct {
ctx context.Context
serviceTag string
once sync.Once
provider adapter.ACMECertificateProvider
resolveErr error
}
func (p *acmeServiceCertificateProvider) Start() error {
_, err := p.resolveProvider()
return err
}
func (p *acmeServiceCertificateProvider) Close() error {
return nil
}
func (p *acmeServiceCertificateProvider) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
provider, err := p.resolveProvider()
if err != nil {
return nil, err
}
return provider.GetCertificate(hello)
}
func (p *acmeServiceCertificateProvider) GetACMENextProtos() []string {
provider, err := p.resolveProvider()
if err != nil {
return nil
}
return provider.GetACMENextProtos()
}
func (p *acmeServiceCertificateProvider) resolveProvider() (adapter.ACMECertificateProvider, error) {
p.once.Do(func() {
serviceManager := service.FromContext[adapter.ServiceManager](p.ctx)
if serviceManager == nil {
p.resolveErr = E.New("missing service manager in context")
return
}
providerService, found := serviceManager.Get(p.serviceTag)
if !found {
p.resolveErr = E.New("certificate provider service not found: ", p.serviceTag)
return
}
provider, ok := providerService.(adapter.ACMECertificateProvider)
if !ok {
p.resolveErr = E.New("service ", p.serviceTag, " is not an ACME certificate service")
return
}
p.provider = provider
})
return p.provider, p.resolveErr
}
type STDServerConfig struct {
access sync.RWMutex
config *tls.Config
logger log.Logger
certificateProvider managedCertificateProvider
acmeService adapter.SimpleLifecycle
certificate []byte
key []byte
@@ -53,18 +116,17 @@ func (c *STDServerConfig) SetServerName(serverName string) {
func (c *STDServerConfig) NextProtos() []string {
c.access.RLock()
defer c.access.RUnlock()
if c.acmeService != nil && len(c.config.NextProtos) > 1 && c.config.NextProtos[0] == ACMETLS1Protocol {
if c.hasACMEALPN() && len(c.config.NextProtos) > 1 && c.config.NextProtos[0] == ACMETLS1Protocol {
return c.config.NextProtos[1:]
} else {
return c.config.NextProtos
}
return c.config.NextProtos
}
func (c *STDServerConfig) SetNextProtos(nextProto []string) {
c.access.Lock()
defer c.access.Unlock()
config := c.config.Clone()
if c.acmeService != nil && len(c.config.NextProtos) > 1 && c.config.NextProtos[0] == ACMETLS1Protocol {
if c.hasACMEALPN() && len(c.config.NextProtos) > 1 && c.config.NextProtos[0] == ACMETLS1Protocol {
config.NextProtos = append(c.config.NextProtos[:1], nextProto...)
} else {
config.NextProtos = nextProto
@@ -72,6 +134,18 @@ func (c *STDServerConfig) SetNextProtos(nextProto []string) {
c.config = config
}
func (c *STDServerConfig) hasACMEALPN() bool {
if c.acmeService != nil {
return true
}
if c.certificateProvider != nil {
if acmeProvider, isACME := c.certificateProvider.(adapter.ACMECertificateProvider); isACME {
return len(acmeProvider.GetACMENextProtos()) > 0
}
}
return false
}
func (c *STDServerConfig) STDConfig() (*STDConfig, error) {
return c.config, nil
}
@@ -91,15 +165,24 @@ func (c *STDServerConfig) Clone() Config {
}
func (c *STDServerConfig) Start() error {
if c.acmeService != nil {
return c.acmeService.Start()
} else {
err := c.startWatcher()
if c.certificateProvider != nil {
err := c.certificateProvider.Start()
if err != nil {
c.logger.Warn("create fsnotify watcher: ", err)
return err
}
return nil
c.updateProviderNextProtos()
}
if c.acmeService != nil {
err := c.acmeService.Start()
if err != nil {
return err
}
}
err := c.startWatcher()
if err != nil {
c.logger.Warn("create fsnotify watcher: ", err)
}
return nil
}
func (c *STDServerConfig) startWatcher() error {
@@ -203,23 +286,58 @@ func (c *STDServerConfig) certificateUpdated(path string) error {
}
func (c *STDServerConfig) Close() error {
if c.acmeService != nil {
return c.acmeService.Close()
return common.Close(c.certificateProvider, c.acmeService, c.watcher)
}
func (c *STDServerConfig) updateProviderNextProtos() {
if c.certificateProvider == nil {
return
}
if c.watcher != nil {
return c.watcher.Close()
acmeProvider, isACME := c.certificateProvider.(adapter.ACMECertificateProvider)
if !isACME {
return
}
return nil
nextProtos := acmeProvider.GetACMENextProtos()
if len(nextProtos) == 0 {
return
}
c.access.Lock()
defer c.access.Unlock()
config := c.config.Clone()
mergedNextProtos := append([]string{}, nextProtos...)
for _, nextProto := range config.NextProtos {
if !common.Contains(mergedNextProtos, nextProto) {
mergedNextProtos = append(mergedNextProtos, nextProto)
}
}
config.NextProtos = mergedNextProtos
c.config = config
}
func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option.InboundTLSOptions) (ServerConfig, error) {
if !options.Enabled {
return nil, nil
}
if options.CertificateProvider != nil && options.ACME != nil {
return nil, E.New("certificate_provider and acme are mutually exclusive")
}
var tlsConfig *tls.Config
var certificateProvider managedCertificateProvider
var acmeService adapter.SimpleLifecycle
var err error
if options.ACME != nil && len(options.ACME.Domain) > 0 {
if options.CertificateProvider != nil {
certificateProvider, err = newCertificateProvider(ctx, options.CertificateProvider)
if err != nil {
return nil, err
}
tlsConfig = &tls.Config{
GetCertificate: certificateProvider.GetCertificate,
}
if options.Insecure {
return nil, errInsecureUnused
}
} else if options.ACME != nil && len(options.ACME.Domain) > 0 {
logger.Warn("inline acme configuration is deprecated, use certificate_provider with an ACME service instead")
//nolint:staticcheck
tlsConfig, acmeService, err = startACME(ctx, logger, common.PtrValueOrDefault(options.ACME))
if err != nil {
@@ -272,7 +390,7 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option.
certificate []byte
key []byte
)
if acmeService == nil {
if certificateProvider == nil && acmeService == nil {
if len(options.Certificate) > 0 {
certificate = []byte(strings.Join(options.Certificate, "\n"))
} else if options.CertificatePath != "" {
@@ -360,6 +478,7 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option.
serverConfig := &STDServerConfig{
config: tlsConfig,
logger: logger,
certificateProvider: certificateProvider,
acmeService: acmeService,
certificate: certificate,
key: key,
@@ -387,3 +506,19 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option.
}
return config, nil
}
func newCertificateProvider(ctx context.Context, options *option.CertificateProviderOptions) (managedCertificateProvider, error) {
switch options.Type {
case C.TypeACME:
serviceTag := options.ACMEOptions.Service
if serviceTag == "" {
return nil, E.New("missing ACME service tag in certificate_provider")
}
return &acmeServiceCertificateProvider{
ctx: ctx,
serviceTag: serviceTag,
}, nil
default:
return nil, E.New("unknown certificate provider type: ", options.Type)
}
}

View File

@@ -31,6 +31,7 @@ const (
TypeCCM = "ccm"
TypeOCM = "ocm"
TypeOOMKiller = "oom-killer"
TypeACME = "acme"
)
const (

3
constant/tls.go Normal file
View File

@@ -0,0 +1,3 @@
package constant
const ACMETLS1Protocol = "acme-tls/1"

12
include/acme.go Normal file
View File

@@ -0,0 +1,12 @@
//go:build with_acme
package include
import (
"github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/service/acme"
)
func registerACMEService(registry *service.Registry) {
acme.RegisterService(registry)
}

20
include/acme_stub.go Normal file
View File

@@ -0,0 +1,20 @@
//go:build !with_acme
package include
import (
"context"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/service"
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"
)
func registerACMEService(registry *service.Registry) {
service.Register[option.ACMEServiceOptions](registry, C.TypeACME, func(ctx context.Context, logger log.ContextLogger, tag string, options option.ACMEServiceOptions) (adapter.Service, error) {
return nil, E.New(`ACME is not included in this build, rebuild with -tags with_acme`)
})
}

View File

@@ -130,6 +130,7 @@ func ServiceRegistry() *service.Registry {
resolved.RegisterService(registry)
ssmapi.RegisterService(registry)
registerACMEService(registry)
registerDERPService(registry)
registerCCMService(registry)

17
option/acme.go Normal file
View File

@@ -0,0 +1,17 @@
package option
import "github.com/sagernet/sing/common/json/badoption"
type ACMEServiceOptions struct {
Domain badoption.Listable[string] `json:"domain,omitempty"`
DataDirectory string `json:"data_directory,omitempty"`
DefaultServerName string `json:"default_server_name,omitempty"`
Email string `json:"email,omitempty"`
Provider string `json:"provider,omitempty"`
DisableHTTPChallenge bool `json:"disable_http_challenge,omitempty"`
DisableTLSALPNChallenge bool `json:"disable_tls_alpn_challenge,omitempty"`
AlternativeHTTPPort uint16 `json:"alternative_http_port,omitempty"`
AlternativeTLSPort uint16 `json:"alternative_tls_port,omitempty"`
ExternalAccount *ACMEExternalAccountOptions `json:"external_account,omitempty"`
DNS01Challenge *ACMEDNS01ChallengeOptions `json:"dns01_challenge,omitempty"`
}

View File

@@ -0,0 +1,53 @@
package option
import (
C "github.com/sagernet/sing-box/constant"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/json"
"github.com/sagernet/sing/common/json/badjson"
)
type _CertificateProviderOptions struct {
Type string `json:"type"`
ACMEOptions CertificateProviderACMEOptions `json:"-"`
}
type CertificateProviderOptions _CertificateProviderOptions
func (o CertificateProviderOptions) MarshalJSON() ([]byte, error) {
var v any
switch o.Type {
case C.TypeACME:
v = o.ACMEOptions
case "":
return nil, E.New("missing certificate provider type")
default:
return nil, E.New("unknown certificate provider type: ", o.Type)
}
return badjson.MarshallObjects((_CertificateProviderOptions)(o), v)
}
func (o *CertificateProviderOptions) UnmarshalJSON(bytes []byte) error {
err := json.Unmarshal(bytes, (*_CertificateProviderOptions)(o))
if err != nil {
return err
}
var v any
switch o.Type {
case C.TypeACME:
v = &o.ACMEOptions
case "":
return E.New("missing certificate provider type")
default:
return E.New("unknown certificate provider type: ", o.Type)
}
err = badjson.UnmarshallExcluded(bytes, (*_CertificateProviderOptions)(o), v)
if err != nil {
return err
}
return nil
}
type CertificateProviderACMEOptions struct {
Service string `json:"service"`
}

View File

@@ -28,9 +28,12 @@ type InboundTLSOptions struct {
KeyPath string `json:"key_path,omitempty"`
KernelTx bool `json:"kernel_tx,omitempty"`
KernelRx bool `json:"kernel_rx,omitempty"`
ACME *InboundACMEOptions `json:"acme,omitempty"`
ECH *InboundECHOptions `json:"ech,omitempty"`
Reality *InboundRealityOptions `json:"reality,omitempty"`
CertificateProvider *CertificateProviderOptions `json:"certificate_provider,omitempty"`
// Deprecated: use certificate_provider
ACME *InboundACMEOptions `json:"acme,omitempty"`
ECH *InboundECHOptions `json:"ech,omitempty"`
Reality *InboundRealityOptions `json:"reality,omitempty"`
}
type ClientAuthType tls.ClientAuthType

43
service/acme/logger.go Normal file
View File

@@ -0,0 +1,43 @@
//go:build with_acme
package acme
import (
"strings"
"github.com/sagernet/sing/common/logger"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
type logWriter struct {
logger logger.Logger
}
func (w *logWriter) Write(p []byte) (n int, err error) {
logLine := strings.ReplaceAll(string(p), " ", ": ")
switch {
case strings.HasPrefix(logLine, "error: "):
w.logger.Error(logLine[7:])
case strings.HasPrefix(logLine, "warn: "):
w.logger.Warn(logLine[6:])
case strings.HasPrefix(logLine, "info: "):
w.logger.Info(logLine[6:])
case strings.HasPrefix(logLine, "debug: "):
w.logger.Debug(logLine[7:])
default:
w.logger.Debug(logLine)
}
return len(p), nil
}
func (w *logWriter) Sync() error {
return nil
}
func encoderConfig() zapcore.EncoderConfig {
config := zap.NewProductionEncoderConfig()
config.TimeKey = zapcore.OmitKey
return config
}

158
service/acme/service.go Normal file
View File

@@ -0,0 +1,158 @@
//go:build with_acme
package acme
import (
"context"
"crypto/tls"
"strings"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
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"
"github.com/caddyserver/certmagic"
"github.com/libdns/acmedns"
"github.com/libdns/alidns"
"github.com/libdns/cloudflare"
"github.com/mholt/acmez/v3/acme"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func RegisterService(registry *boxService.Registry) {
boxService.Register[option.ACMEServiceOptions](registry, C.TypeACME, NewService)
}
var _ adapter.ACMECertificateProvider = (*Service)(nil)
type Service struct {
boxService.Adapter
ctx context.Context
logger log.ContextLogger
config *certmagic.Config
cache *certmagic.Cache
domain []string
nextProtos []string
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.ACMEServiceOptions) (adapter.Service, error) {
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
}
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(encoderConfig()),
&logWriter{logger: logger},
zap.DebugLevel,
))
config := &certmagic.Config{
DefaultServerName: options.DefaultServerName,
Storage: storage,
Logger: zapLogger,
}
acmeIssuer := certmagic.ACMEIssuer{
CA: acmeServer,
Email: options.Email,
Agreed: true,
DisableHTTPChallenge: options.DisableHTTPChallenge,
DisableTLSALPNChallenge: options.DisableTLSALPNChallenge,
AltHTTPPort: int(options.AlternativeHTTPPort),
AltTLSALPNPort: int(options.AlternativeTLSPort),
Logger: zapLogger,
}
if dnsOptions := options.DNS01Challenge; dnsOptions != nil && dnsOptions.Provider != "" {
var solver certmagic.DNS01Solver
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,
}
case C.DNSProviderACMEDNS:
solver.DNSProvider = &acmedns.Provider{
Username: dnsOptions.ACMEDNSOptions.Username,
Password: dnsOptions.ACMEDNSOptions.Password,
Subdomain: dnsOptions.ACMEDNSOptions.Subdomain,
ServerURL: dnsOptions.ACMEDNSOptions.ServerURL,
}
default:
return nil, E.New("unsupported ACME DNS01 provider type: ", dnsOptions.Provider)
}
acmeIssuer.DNS01Solver = &solver
}
if options.ExternalAccount != nil && options.ExternalAccount.KeyID != "" {
acmeIssuer.ExternalAccount = (*acme.EAB)(options.ExternalAccount)
}
config.Issuers = []certmagic.Issuer{certmagic.NewACMEIssuer(config, acmeIssuer)}
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: boxService.NewAdapter(C.TypeACME, tag),
ctx: ctx,
logger: logger,
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
}

3
service/acme/stub.go Normal file
View File

@@ -0,0 +1,3 @@
//go:build !with_acme
package acme