diff --git a/adapter/certificate_provider.go b/adapter/certificate_provider.go new file mode 100644 index 000000000..4ccebb812 --- /dev/null +++ b/adapter/certificate_provider.go @@ -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 +} diff --git a/box.go b/box.go index fe116b317..f66387f6b 100644 --- a/box.go +++ b/box.go @@ -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, diff --git a/common/tls/acme_contstant.go b/common/tls/acme_contstant.go index c5cd2ff16..fcfa385b8 100644 --- a/common/tls/acme_contstant.go +++ b/common/tls/acme_contstant.go @@ -1,3 +1,5 @@ package tls -const ACMETLS1Protocol = "acme-tls/1" +import C "github.com/sagernet/sing-box/constant" + +const ACMETLS1Protocol = C.ACMETLS1Protocol diff --git a/common/tls/std_server.go b/common/tls/std_server.go index 760c4b3a7..4ff04bfdd 100644 --- a/common/tls/std_server.go +++ b/common/tls/std_server.go @@ -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) + } +} diff --git a/constant/proxy.go b/constant/proxy.go index 278a46c2f..cba978e27 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -31,6 +31,7 @@ const ( TypeCCM = "ccm" TypeOCM = "ocm" TypeOOMKiller = "oom-killer" + TypeACME = "acme" ) const ( diff --git a/constant/tls.go b/constant/tls.go new file mode 100644 index 000000000..2d4f64bc3 --- /dev/null +++ b/constant/tls.go @@ -0,0 +1,3 @@ +package constant + +const ACMETLS1Protocol = "acme-tls/1" diff --git a/include/acme.go b/include/acme.go new file mode 100644 index 000000000..b27f51b3a --- /dev/null +++ b/include/acme.go @@ -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) +} diff --git a/include/acme_stub.go b/include/acme_stub.go new file mode 100644 index 000000000..4dbd9ae46 --- /dev/null +++ b/include/acme_stub.go @@ -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`) + }) +} diff --git a/include/registry.go b/include/registry.go index f090845b5..3777cce3c 100644 --- a/include/registry.go +++ b/include/registry.go @@ -130,6 +130,7 @@ func ServiceRegistry() *service.Registry { resolved.RegisterService(registry) ssmapi.RegisterService(registry) + registerACMEService(registry) registerDERPService(registry) registerCCMService(registry) diff --git a/option/acme.go b/option/acme.go new file mode 100644 index 000000000..bf603e92d --- /dev/null +++ b/option/acme.go @@ -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"` +} diff --git a/option/certificate_provider.go b/option/certificate_provider.go new file mode 100644 index 000000000..447b0ba9c --- /dev/null +++ b/option/certificate_provider.go @@ -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"` +} diff --git a/option/tls.go b/option/tls.go index 60343a15f..f97cfba2f 100644 --- a/option/tls.go +++ b/option/tls.go @@ -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 diff --git a/service/acme/logger.go b/service/acme/logger.go new file mode 100644 index 000000000..3226f8fe8 --- /dev/null +++ b/service/acme/logger.go @@ -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 +} diff --git a/service/acme/service.go b/service/acme/service.go new file mode 100644 index 000000000..a1f523f23 --- /dev/null +++ b/service/acme/service.go @@ -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 +} diff --git a/service/acme/stub.go b/service/acme/stub.go new file mode 100644 index 000000000..43a58d644 --- /dev/null +++ b/service/acme/stub.go @@ -0,0 +1,3 @@ +//go:build !with_acme + +package acme