diff --git a/adapter/certificate/adapter.go b/adapter/certificate/adapter.go new file mode 100644 index 000000000..802020c1e --- /dev/null +++ b/adapter/certificate/adapter.go @@ -0,0 +1,21 @@ +package certificate + +type Adapter struct { + providerType string + providerTag string +} + +func NewAdapter(providerType string, providerTag string) Adapter { + return Adapter{ + providerType: providerType, + providerTag: providerTag, + } +} + +func (a *Adapter) Type() string { + return a.providerType +} + +func (a *Adapter) Tag() string { + return a.providerTag +} diff --git a/adapter/certificate/manager.go b/adapter/certificate/manager.go new file mode 100644 index 000000000..e4b9b535b --- /dev/null +++ b/adapter/certificate/manager.go @@ -0,0 +1,158 @@ +package certificate + +import ( + "context" + "os" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/taskmonitor" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +var _ adapter.CertificateProviderManager = (*Manager)(nil) + +type Manager struct { + logger log.ContextLogger + registry adapter.CertificateProviderRegistry + access sync.Mutex + started bool + stage adapter.StartStage + providers []adapter.CertificateProviderService + providerByTag map[string]adapter.CertificateProviderService +} + +func NewManager(logger log.ContextLogger, registry adapter.CertificateProviderRegistry) *Manager { + return &Manager{ + logger: logger, + registry: registry, + providerByTag: make(map[string]adapter.CertificateProviderService), + } +} + +func (m *Manager) Start(stage adapter.StartStage) error { + m.access.Lock() + if m.started && m.stage >= stage { + panic("already started") + } + m.started = true + m.stage = stage + providers := m.providers + m.access.Unlock() + for _, provider := range providers { + name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]" + m.logger.Trace(stage, " ", name) + startTime := time.Now() + err := adapter.LegacyStart(provider, stage) + if err != nil { + return E.Cause(err, stage, " ", name) + } + m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + return nil +} + +func (m *Manager) Close() error { + m.access.Lock() + defer m.access.Unlock() + if !m.started { + return nil + } + m.started = false + providers := m.providers + m.providers = nil + monitor := taskmonitor.New(m.logger, C.StopTimeout) + var err error + for _, provider := range providers { + name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]" + m.logger.Trace("close ", name) + startTime := time.Now() + monitor.Start("close ", name) + err = E.Append(err, provider.Close(), func(err error) error { + return E.Cause(err, "close ", name) + }) + monitor.Finish() + m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + return err +} + +func (m *Manager) CertificateProviders() []adapter.CertificateProviderService { + m.access.Lock() + defer m.access.Unlock() + return m.providers +} + +func (m *Manager) Get(tag string) (adapter.CertificateProviderService, bool) { + m.access.Lock() + provider, found := m.providerByTag[tag] + m.access.Unlock() + return provider, found +} + +func (m *Manager) Remove(tag string) error { + m.access.Lock() + provider, found := m.providerByTag[tag] + if !found { + m.access.Unlock() + return os.ErrInvalid + } + delete(m.providerByTag, tag) + index := common.Index(m.providers, func(it adapter.CertificateProviderService) bool { + return it == provider + }) + if index == -1 { + panic("invalid certificate provider index") + } + m.providers = append(m.providers[:index], m.providers[index+1:]...) + started := m.started + m.access.Unlock() + if started { + return provider.Close() + } + return nil +} + +func (m *Manager) Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) error { + provider, err := m.registry.Create(ctx, logger, tag, providerType, options) + if err != nil { + return err + } + m.access.Lock() + defer m.access.Unlock() + if m.started { + name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]" + for _, stage := range adapter.ListStartStages { + m.logger.Trace(stage, " ", name) + startTime := time.Now() + err = adapter.LegacyStart(provider, stage) + if err != nil { + return E.Cause(err, stage, " ", name) + } + m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } + } + if existsProvider, loaded := m.providerByTag[tag]; loaded { + if m.started { + err = existsProvider.Close() + if err != nil { + return E.Cause(err, "close certificate-provider/", existsProvider.Type(), "[", existsProvider.Tag(), "]") + } + } + existsIndex := common.Index(m.providers, func(it adapter.CertificateProviderService) bool { + return it == existsProvider + }) + if existsIndex == -1 { + panic("invalid certificate provider index") + } + m.providers = append(m.providers[:existsIndex], m.providers[existsIndex+1:]...) + } + m.providers = append(m.providers, provider) + m.providerByTag[tag] = provider + return nil +} diff --git a/adapter/certificate/registry.go b/adapter/certificate/registry.go new file mode 100644 index 000000000..5a080f2cc --- /dev/null +++ b/adapter/certificate/registry.go @@ -0,0 +1,72 @@ +package certificate + +import ( + "context" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +type ConstructorFunc[T any] func(ctx context.Context, logger log.ContextLogger, tag string, options T) (adapter.CertificateProviderService, error) + +func Register[Options any](registry *Registry, providerType string, constructor ConstructorFunc[Options]) { + registry.register(providerType, func() any { + return new(Options) + }, func(ctx context.Context, logger log.ContextLogger, tag string, rawOptions any) (adapter.CertificateProviderService, error) { + var options *Options + if rawOptions != nil { + options = rawOptions.(*Options) + } + return constructor(ctx, logger, tag, common.PtrValueOrDefault(options)) + }) +} + +var _ adapter.CertificateProviderRegistry = (*Registry)(nil) + +type ( + optionsConstructorFunc func() any + constructorFunc func(ctx context.Context, logger log.ContextLogger, tag string, options any) (adapter.CertificateProviderService, error) +) + +type Registry struct { + access sync.Mutex + optionsType map[string]optionsConstructorFunc + constructor map[string]constructorFunc +} + +func NewRegistry() *Registry { + return &Registry{ + optionsType: make(map[string]optionsConstructorFunc), + constructor: make(map[string]constructorFunc), + } +} + +func (m *Registry) CreateOptions(providerType string) (any, bool) { + m.access.Lock() + defer m.access.Unlock() + optionsConstructor, loaded := m.optionsType[providerType] + if !loaded { + return nil, false + } + return optionsConstructor(), true +} + +func (m *Registry) Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) (adapter.CertificateProviderService, error) { + m.access.Lock() + defer m.access.Unlock() + constructor, loaded := m.constructor[providerType] + if !loaded { + return nil, E.New("certificate provider type not found: " + providerType) + } + return constructor(ctx, logger, tag, options) +} + +func (m *Registry) register(providerType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) { + m.access.Lock() + defer m.access.Unlock() + m.optionsType[providerType] = optionsConstructor + m.constructor[providerType] = constructor +} diff --git a/adapter/certificate_provider.go b/adapter/certificate_provider.go new file mode 100644 index 000000000..70bdeb883 --- /dev/null +++ b/adapter/certificate_provider.go @@ -0,0 +1,38 @@ +package adapter + +import ( + "context" + "crypto/tls" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" +) + +type CertificateProvider interface { + GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) +} + +type ACMECertificateProvider interface { + CertificateProvider + GetACMENextProtos() []string +} + +type CertificateProviderService interface { + Lifecycle + Type() string + Tag() string + CertificateProvider +} + +type CertificateProviderRegistry interface { + option.CertificateProviderOptionsRegistry + Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) (CertificateProviderService, error) +} + +type CertificateProviderManager interface { + Lifecycle + CertificateProviders() []CertificateProviderService + Get(tag string) (CertificateProviderService, bool) + Remove(tag string) error + Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) error +} diff --git a/box.go b/box.go index fe116b317..a765e21d8 100644 --- a/box.go +++ b/box.go @@ -9,6 +9,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + boxCertificate "github.com/sagernet/sing-box/adapter/certificate" "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" @@ -37,20 +38,21 @@ import ( var _ adapter.SimpleLifecycle = (*Box)(nil) type Box struct { - createdAt time.Time - logFactory log.Factory - logger log.ContextLogger - network *route.NetworkManager - endpoint *endpoint.Manager - inbound *inbound.Manager - outbound *outbound.Manager - service *boxService.Manager - dnsTransport *dns.TransportManager - dnsRouter *dns.Router - connection *route.ConnectionManager - router *route.Router - internalService []adapter.LifecycleService - done chan struct{} + createdAt time.Time + logFactory log.Factory + logger log.ContextLogger + network *route.NetworkManager + endpoint *endpoint.Manager + inbound *inbound.Manager + outbound *outbound.Manager + service *boxService.Manager + certificateProvider *boxCertificate.Manager + dnsTransport *dns.TransportManager + dnsRouter *dns.Router + connection *route.ConnectionManager + router *route.Router + internalService []adapter.LifecycleService + done chan struct{} } type Options struct { @@ -66,6 +68,7 @@ func Context( endpointRegistry adapter.EndpointRegistry, dnsTransportRegistry adapter.DNSTransportRegistry, serviceRegistry adapter.ServiceRegistry, + certificateProviderRegistry adapter.CertificateProviderRegistry, ) context.Context { if service.FromContext[option.InboundOptionsRegistry](ctx) == nil || service.FromContext[adapter.InboundRegistry](ctx) == nil { @@ -90,6 +93,10 @@ func Context( ctx = service.ContextWith[option.ServiceOptionsRegistry](ctx, serviceRegistry) ctx = service.ContextWith[adapter.ServiceRegistry](ctx, serviceRegistry) } + if service.FromContext[adapter.CertificateProviderRegistry](ctx) == nil { + ctx = service.ContextWith[option.CertificateProviderOptionsRegistry](ctx, certificateProviderRegistry) + ctx = service.ContextWith[adapter.CertificateProviderRegistry](ctx, certificateProviderRegistry) + } return ctx } @@ -106,6 +113,7 @@ func New(options Options) (*Box, error) { outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) dnsTransportRegistry := service.FromContext[adapter.DNSTransportRegistry](ctx) serviceRegistry := service.FromContext[adapter.ServiceRegistry](ctx) + certificateProviderRegistry := service.FromContext[adapter.CertificateProviderRegistry](ctx) if endpointRegistry == nil { return nil, E.New("missing endpoint registry in context") @@ -122,6 +130,9 @@ func New(options Options) (*Box, error) { if serviceRegistry == nil { return nil, E.New("missing service registry in context") } + if certificateProviderRegistry == nil { + return nil, E.New("missing certificate provider registry in context") + } ctx = pause.WithDefaultManager(ctx) experimentalOptions := common.PtrValueOrDefault(options.Experimental) @@ -179,11 +190,13 @@ func New(options Options) (*Box, error) { outboundManager := outbound.NewManager(logFactory.NewLogger("outbound"), outboundRegistry, endpointManager, routeOptions.Final) dnsTransportManager := dns.NewTransportManager(logFactory.NewLogger("dns/transport"), dnsTransportRegistry, outboundManager, dnsOptions.Final) serviceManager := boxService.NewManager(logFactory.NewLogger("service"), serviceRegistry) + certificateProviderManager := boxCertificate.NewManager(logFactory.NewLogger("certificate-provider"), certificateProviderRegistry) service.MustRegister[adapter.EndpointManager](ctx, endpointManager) service.MustRegister[adapter.InboundManager](ctx, inboundManager) service.MustRegister[adapter.OutboundManager](ctx, outboundManager) service.MustRegister[adapter.DNSTransportManager](ctx, dnsTransportManager) service.MustRegister[adapter.ServiceManager](ctx, serviceManager) + service.MustRegister[adapter.CertificateProviderManager](ctx, certificateProviderManager) dnsRouter := dns.NewRouter(ctx, logFactory, dnsOptions) service.MustRegister[adapter.DNSRouter](ctx, dnsRouter) networkManager, err := route.NewNetworkManager(ctx, logFactory.NewLogger("network"), routeOptions, dnsOptions) @@ -272,6 +285,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,22 +329,22 @@ func New(options Options) (*Box, error) { return nil, E.Cause(err, "initialize outbound[", i, "]") } } - for i, serviceOptions := range options.Services { + for i, certificateProviderOptions := range options.CertificateProviders { var tag string - if serviceOptions.Tag != "" { - tag = serviceOptions.Tag + if certificateProviderOptions.Tag != "" { + tag = certificateProviderOptions.Tag } else { tag = F.ToString(i) } - err = serviceManager.Create( + err = certificateProviderManager.Create( ctx, - logFactory.NewLogger(F.ToString("service/", serviceOptions.Type, "[", tag, "]")), + logFactory.NewLogger(F.ToString("certificate-provider/", certificateProviderOptions.Type, "[", tag, "]")), tag, - serviceOptions.Type, - serviceOptions.Options, + certificateProviderOptions.Type, + certificateProviderOptions.Options, ) if err != nil { - return nil, E.Cause(err, "initialize service[", i, "]") + return nil, E.Cause(err, "initialize certificate provider[", i, "]") } } outboundManager.Initialize(func() (adapter.Outbound, error) { @@ -383,20 +414,21 @@ func New(options Options) (*Box, error) { internalServices = append(internalServices, adapter.NewLifecycleService(ntpService, "ntp service")) } return &Box{ - network: networkManager, - endpoint: endpointManager, - inbound: inboundManager, - outbound: outboundManager, - dnsTransport: dnsTransportManager, - service: serviceManager, - dnsRouter: dnsRouter, - connection: connectionManager, - router: router, - createdAt: createdAt, - logFactory: logFactory, - logger: logFactory.Logger(), - internalService: internalServices, - done: make(chan struct{}), + network: networkManager, + endpoint: endpointManager, + inbound: inboundManager, + outbound: outboundManager, + dnsTransport: dnsTransportManager, + service: serviceManager, + certificateProvider: certificateProviderManager, + dnsRouter: dnsRouter, + connection: connectionManager, + router: router, + createdAt: createdAt, + logFactory: logFactory, + logger: logFactory.Logger(), + internalService: internalServices, + done: make(chan struct{}), }, nil } @@ -450,7 +482,7 @@ func (s *Box) preStart() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service, s.certificateProvider) if err != nil { return err } @@ -470,11 +502,19 @@ func (s *Box) start() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateStart, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStateStart, s.endpoint) if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStateStart, s.certificateProvider) + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStateStart, s.inbound, s.service) + if err != nil { + return err + } + err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.endpoint, s.certificateProvider, s.inbound, s.service) if err != nil { return err } @@ -482,7 +522,7 @@ func (s *Box) start() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.service) + err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.endpoint, s.certificateProvider, s.inbound, s.service) if err != nil { return err } @@ -506,8 +546,9 @@ func (s *Box) Close() error { service adapter.Lifecycle }{ {"service", s.service}, - {"endpoint", s.endpoint}, {"inbound", s.inbound}, + {"certificate-provider", s.certificateProvider}, + {"endpoint", s.endpoint}, {"outbound", s.outbound}, {"router", s.router}, {"connection", s.connection}, diff --git a/common/tls/acme.go b/common/tls/acme.go index c96e002c8..d576fc6b1 100644 --- a/common/tls/acme.go +++ b/common/tls/acme.go @@ -38,37 +38,6 @@ func (w *acmeWrapper) Close() error { return nil } -type acmeLogWriter struct { - logger logger.Logger -} - -func (w *acmeLogWriter) 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 *acmeLogWriter) Sync() error { - return nil -} - -func encoderConfig() zapcore.EncoderConfig { - config := zap.NewProductionEncoderConfig() - config.TimeKey = zapcore.OmitKey - return config -} - func startACME(ctx context.Context, logger logger.Logger, options option.InboundACMEOptions) (*tls.Config, adapter.SimpleLifecycle, error) { var acmeServer string switch options.Provider { @@ -91,8 +60,8 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound storage = certmagic.Default.Storage } zapLogger := zap.New(zapcore.NewCore( - zapcore.NewConsoleEncoder(encoderConfig()), - &acmeLogWriter{logger: logger}, + zapcore.NewConsoleEncoder(ACMEEncoderConfig()), + &ACMELogWriter{Logger: logger}, zap.DebugLevel, )) config := &certmagic.Config{ @@ -158,7 +127,7 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound } else { tlsConfig = &tls.Config{ GetCertificate: config.GetCertificate, - NextProtos: []string{ACMETLS1Protocol}, + NextProtos: []string{C.ACMETLS1Protocol}, } } return tlsConfig, &acmeWrapper{ctx: ctx, cfg: config, cache: cache, domain: options.Domain}, nil diff --git a/common/tls/acme_logger.go b/common/tls/acme_logger.go new file mode 100644 index 000000000..cb3a1e3ce --- /dev/null +++ b/common/tls/acme_logger.go @@ -0,0 +1,41 @@ +package tls + +import ( + "strings" + + "github.com/sagernet/sing/common/logger" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type ACMELogWriter struct { + Logger logger.Logger +} + +func (w *ACMELogWriter) 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 *ACMELogWriter) Sync() error { + return nil +} + +func ACMEEncoderConfig() zapcore.EncoderConfig { + config := zap.NewProductionEncoderConfig() + config.TimeKey = zapcore.OmitKey + return config +} diff --git a/common/tls/reality_server.go b/common/tls/reality_server.go index 5fc684756..c2e70733a 100644 --- a/common/tls/reality_server.go +++ b/common/tls/reality_server.go @@ -32,6 +32,10 @@ type RealityServerConfig struct { func NewRealityServer(ctx context.Context, logger log.ContextLogger, options option.InboundTLSOptions) (ServerConfig, error) { var tlsConfig utls.RealityConfig + if options.CertificateProvider != nil { + return nil, E.New("certificate_provider is unavailable in reality") + } + //nolint:staticcheck if options.ACME != nil && len(options.ACME.Domain) > 0 { return nil, E.New("acme is unavailable in reality") } diff --git a/common/tls/std_server.go b/common/tls/std_server.go index 760c4b3a7..86584cd48 100644 --- a/common/tls/std_server.go +++ b/common/tls/std_server.go @@ -13,19 +13,87 @@ import ( "github.com/sagernet/fswatch" "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/deprecated" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "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 + adapter.SimpleLifecycle +} + +type sharedCertificateProvider struct { + tag string + manager adapter.CertificateProviderManager + provider adapter.CertificateProviderService +} + +func (p *sharedCertificateProvider) Start() error { + provider, found := p.manager.Get(p.tag) + if !found { + return E.New("certificate provider not found: ", p.tag) + } + p.provider = provider + return nil +} + +func (p *sharedCertificateProvider) Close() error { + return nil +} + +func (p *sharedCertificateProvider) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + return p.provider.GetCertificate(hello) +} + +func (p *sharedCertificateProvider) GetACMENextProtos() []string { + return getACMENextProtos(p.provider) +} + +type inlineCertificateProvider struct { + provider adapter.CertificateProviderService +} + +func (p *inlineCertificateProvider) Start() error { + for _, stage := range adapter.ListStartStages { + err := adapter.LegacyStart(p.provider, stage) + if err != nil { + return err + } + } + return nil +} + +func (p *inlineCertificateProvider) Close() error { + return p.provider.Close() +} + +func (p *inlineCertificateProvider) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + return p.provider.GetCertificate(hello) +} + +func (p *inlineCertificateProvider) GetACMENextProtos() []string { + return getACMENextProtos(p.provider) +} + +func getACMENextProtos(provider adapter.CertificateProvider) []string { + if acmeProvider, isACME := provider.(adapter.ACMECertificateProvider); isACME { + return acmeProvider.GetACMENextProtos() + } + return nil +} + type STDServerConfig struct { access sync.RWMutex config *tls.Config logger log.Logger + certificateProvider managedCertificateProvider acmeService adapter.SimpleLifecycle certificate []byte key []byte @@ -53,18 +121,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] == C.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] == C.ACMETLS1Protocol { config.NextProtos = append(c.config.NextProtos[:1], nextProto...) } else { config.NextProtos = nextProto @@ -72,6 +139,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 +170,39 @@ 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 + } + if acmeProvider, isACME := c.certificateProvider.(adapter.ACMECertificateProvider); isACME { + nextProtos := acmeProvider.GetACMENextProtos() + if len(nextProtos) > 0 { + c.access.Lock() + 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 + c.access.Unlock() + } } - return nil } + 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 +306,34 @@ func (c *STDServerConfig) certificateUpdated(path string) error { } func (c *STDServerConfig) Close() error { - if c.acmeService != nil { - return c.acmeService.Close() - } - if c.watcher != nil { - return c.watcher.Close() - } - return nil + return common.Close(c.certificateProvider, c.acmeService, c.watcher) } func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option.InboundTLSOptions) (ServerConfig, error) { if !options.Enabled { return nil, nil } + //nolint:staticcheck + 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, logger, 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 { //nolint:staticcheck + deprecated.Report(ctx, deprecated.OptionInlineACME) //nolint:staticcheck tlsConfig, acmeService, err = startACME(ctx, logger, common.PtrValueOrDefault(options.ACME)) if err != nil { @@ -272,7 +386,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 +474,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, @@ -369,8 +484,8 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. echKeyPath: echKeyPath, } serverConfig.config.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) { - serverConfig.access.Lock() - defer serverConfig.access.Unlock() + serverConfig.access.RLock() + defer serverConfig.access.RUnlock() return serverConfig.config, nil } var config ServerConfig = serverConfig @@ -387,3 +502,27 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. } return config, nil } + +func newCertificateProvider(ctx context.Context, logger log.ContextLogger, options *option.CertificateProviderOptions) (managedCertificateProvider, error) { + if options.IsShared() { + manager := service.FromContext[adapter.CertificateProviderManager](ctx) + if manager == nil { + return nil, E.New("missing certificate provider manager in context") + } + return &sharedCertificateProvider{ + tag: options.Tag, + manager: manager, + }, nil + } + registry := service.FromContext[adapter.CertificateProviderRegistry](ctx) + if registry == nil { + return nil, E.New("missing certificate provider registry in context") + } + provider, err := registry.Create(ctx, logger, "", options.Type, options.Options) + if err != nil { + return nil, E.Cause(err, "create inline certificate provider") + } + return &inlineCertificateProvider{ + provider: provider, + }, nil +} diff --git a/constant/proxy.go b/constant/proxy.go index 278a46c2f..add66c95e 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -1,36 +1,38 @@ package constant const ( - TypeTun = "tun" - TypeRedirect = "redirect" - TypeTProxy = "tproxy" - TypeDirect = "direct" - TypeBlock = "block" - TypeDNS = "dns" - TypeSOCKS = "socks" - TypeHTTP = "http" - TypeMixed = "mixed" - TypeShadowsocks = "shadowsocks" - TypeVMess = "vmess" - TypeTrojan = "trojan" - TypeNaive = "naive" - TypeWireGuard = "wireguard" - TypeHysteria = "hysteria" - TypeTor = "tor" - TypeSSH = "ssh" - TypeShadowTLS = "shadowtls" - TypeAnyTLS = "anytls" - TypeShadowsocksR = "shadowsocksr" - TypeVLESS = "vless" - TypeTUIC = "tuic" - TypeHysteria2 = "hysteria2" - TypeTailscale = "tailscale" - TypeDERP = "derp" - TypeResolved = "resolved" - TypeSSMAPI = "ssm-api" - TypeCCM = "ccm" - TypeOCM = "ocm" - TypeOOMKiller = "oom-killer" + TypeTun = "tun" + TypeRedirect = "redirect" + TypeTProxy = "tproxy" + TypeDirect = "direct" + TypeBlock = "block" + TypeDNS = "dns" + TypeSOCKS = "socks" + TypeHTTP = "http" + TypeMixed = "mixed" + TypeShadowsocks = "shadowsocks" + TypeVMess = "vmess" + TypeTrojan = "trojan" + TypeNaive = "naive" + TypeWireGuard = "wireguard" + TypeHysteria = "hysteria" + TypeTor = "tor" + TypeSSH = "ssh" + TypeShadowTLS = "shadowtls" + TypeAnyTLS = "anytls" + TypeShadowsocksR = "shadowsocksr" + TypeVLESS = "vless" + TypeTUIC = "tuic" + TypeHysteria2 = "hysteria2" + TypeTailscale = "tailscale" + TypeDERP = "derp" + TypeResolved = "resolved" + TypeSSMAPI = "ssm-api" + TypeCCM = "ccm" + TypeOCM = "ocm" + TypeOOMKiller = "oom-killer" + TypeACME = "acme" + TypeCloudflareOriginCA = "cloudflare-origin-ca" ) const ( diff --git a/common/tls/acme_contstant.go b/constant/tls.go similarity index 69% rename from common/tls/acme_contstant.go rename to constant/tls.go index c5cd2ff16..2d4f64bc3 100644 --- a/common/tls/acme_contstant.go +++ b/constant/tls.go @@ -1,3 +1,3 @@ -package tls +package constant const ACMETLS1Protocol = "acme-tls/1" diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md index 5a2f58d3d..6dae06e18 100644 --- a/docs/configuration/inbound/tun.md +++ b/docs/configuration/inbound/tun.md @@ -4,7 +4,7 @@ icon: material/new-box !!! quote "Changes in sing-box 1.14.0" - :material-plus: [include_mac_address](#include_mac_address) + :material-plus: [include_mac_address](#include_mac_address) :material-plus: [exclude_mac_address](#exclude_mac_address) !!! quote "Changes in sing-box 1.13.3" diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 1f6eec137..81cb8f386 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -1,7 +1,6 @@ # Introduction sing-box uses JSON for configuration files. - ### Structure ```json @@ -10,6 +9,7 @@ sing-box uses JSON for configuration files. "dns": {}, "ntp": {}, "certificate": {}, + "certificate_providers": [], "endpoints": [], "inbounds": [], "outbounds": [], @@ -27,6 +27,7 @@ sing-box uses JSON for configuration files. | `dns` | [DNS](./dns/) | | `ntp` | [NTP](./ntp/) | | `certificate` | [Certificate](./certificate/) | +| `certificate_providers` | [Certificate Provider](./shared/certificate-provider/) | | `endpoints` | [Endpoint](./endpoint/) | | `inbounds` | [Inbound](./inbound/) | | `outbounds` | [Outbound](./outbound/) | @@ -50,4 +51,4 @@ sing-box format -w -c config.json -D config_directory ```bash sing-box merge output.json -c config.json -D config_directory -``` \ No newline at end of file +``` diff --git a/docs/configuration/index.zh.md b/docs/configuration/index.zh.md index 3bdc35218..350db5d4c 100644 --- a/docs/configuration/index.zh.md +++ b/docs/configuration/index.zh.md @@ -1,7 +1,6 @@ # 引言 sing-box 使用 JSON 作为配置文件格式。 - ### 结构 ```json @@ -10,6 +9,7 @@ sing-box 使用 JSON 作为配置文件格式。 "dns": {}, "ntp": {}, "certificate": {}, + "certificate_providers": [], "endpoints": [], "inbounds": [], "outbounds": [], @@ -27,6 +27,7 @@ sing-box 使用 JSON 作为配置文件格式。 | `dns` | [DNS](./dns/) | | `ntp` | [NTP](./ntp/) | | `certificate` | [证书](./certificate/) | +| `certificate_providers` | [证书提供者](./shared/certificate-provider/) | | `endpoints` | [端点](./endpoint/) | | `inbounds` | [入站](./inbound/) | | `outbounds` | [出站](./outbound/) | @@ -50,4 +51,4 @@ sing-box format -w -c config.json -D config_directory ```bash sing-box merge output.json -c config.json -D config_directory -``` \ No newline at end of file +``` diff --git a/docs/configuration/shared/certificate-provider/acme.md b/docs/configuration/shared/certificate-provider/acme.md new file mode 100644 index 000000000..440ed1568 --- /dev/null +++ b/docs/configuration/shared/certificate-provider/acme.md @@ -0,0 +1,150 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [account_key](#account_key) + :material-plus: [key_type](#key_type) + :material-plus: [detour](#detour) + +# ACME + +!!! quote "" + + `with_acme` build tag required. + +### Structure + +```json +{ + "type": "acme", + "tag": "", + + "domain": [], + "data_directory": "", + "default_server_name": "", + "email": "", + "provider": "", + "account_key": "", + "disable_http_challenge": false, + "disable_tls_alpn_challenge": false, + "alternative_http_port": 0, + "alternative_tls_port": 0, + "external_account": { + "key_id": "", + "mac_key": "" + }, + "dns01_challenge": {}, + "key_type": "", + "detour": "" +} +``` + +### Fields + +#### domain + +==Required== + +List of domains. + +#### data_directory + +The directory to store ACME data. + +`$XDG_DATA_HOME/certmagic|$HOME/.local/share/certmagic` will be used if empty. + +#### default_server_name + +Server name to use when choosing a certificate if the ClientHello's ServerName field is empty. + +#### email + +The email address to use when creating or selecting an existing ACME server account. + +#### provider + +The ACME CA provider to use. + +| Value | Provider | +|-------------------------|---------------| +| `letsencrypt (default)` | Let's Encrypt | +| `zerossl` | ZeroSSL | +| `https://...` | Custom | + +When `provider` is `zerossl`, sing-box will automatically request ZeroSSL EAB credentials if `email` is set and +`external_account` is empty. + +When `provider` is `zerossl`, at least one of `external_account`, `email`, or `account_key` is required. + +#### account_key + +!!! question "Since sing-box 1.14.0" + +The PEM-encoded private key of an existing ACME account. + +#### disable_http_challenge + +Disable all HTTP challenges. + +#### disable_tls_alpn_challenge + +Disable all TLS-ALPN challenges + +#### alternative_http_port + +The alternate port to use for the ACME HTTP challenge; if non-empty, this port will be used instead of 80 to spin up a +listener for the HTTP challenge. + +#### alternative_tls_port + +The alternate port to use for the ACME TLS-ALPN challenge; the system must forward 443 to this port for challenge to +succeed. + +#### external_account + +EAB (External Account Binding) contains information necessary to bind or map an ACME account to some other account known +by the CA. + +External account bindings are used to associate an ACME account with an existing account in a non-ACME system, such as +a CA customer database. + +To enable ACME account binding, the CA operating the ACME server needs to provide the ACME client with a MAC key and a +key identifier, using some mechanism outside of ACME. §7.3.4 + +#### external_account.key_id + +The key identifier. + +#### external_account.mac_key + +The MAC key. + +#### dns01_challenge + +ACME DNS01 challenge field. If configured, other challenge methods will be disabled. + +See [DNS01 Challenge Fields](/configuration/shared/dns01_challenge/) for details. + +#### key_type + +!!! question "Since sing-box 1.14.0" + +The private key type to generate for new certificates. + +| Value | Type | +|------------|---------| +| `ed25519` | Ed25519 | +| `p256` | P-256 | +| `p384` | P-384 | +| `rsa2048` | RSA | +| `rsa4096` | RSA | + +#### detour + +!!! question "Since sing-box 1.14.0" + +The tag of the upstream outbound. + +All provider HTTP requests will use this outbound. diff --git a/docs/configuration/shared/certificate-provider/acme.zh.md b/docs/configuration/shared/certificate-provider/acme.zh.md new file mode 100644 index 000000000..d95930a55 --- /dev/null +++ b/docs/configuration/shared/certificate-provider/acme.zh.md @@ -0,0 +1,145 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [account_key](#account_key) + :material-plus: [key_type](#key_type) + :material-plus: [detour](#detour) + +# ACME + +!!! quote "" + + 需要 `with_acme` 构建标签。 + +### 结构 + +```json +{ + "type": "acme", + "tag": "", + + "domain": [], + "data_directory": "", + "default_server_name": "", + "email": "", + "provider": "", + "account_key": "", + "disable_http_challenge": false, + "disable_tls_alpn_challenge": false, + "alternative_http_port": 0, + "alternative_tls_port": 0, + "external_account": { + "key_id": "", + "mac_key": "" + }, + "dns01_challenge": {}, + "key_type": "", + "detour": "" +} +``` + +### 字段 + +#### domain + +==必填== + +域名列表。 + +#### data_directory + +ACME 数据存储目录。 + +如果为空则使用 `$XDG_DATA_HOME/certmagic|$HOME/.local/share/certmagic`。 + +#### default_server_name + +如果 ClientHello 的 ServerName 字段为空,则选择证书时要使用的服务器名称。 + +#### email + +创建或选择现有 ACME 服务器帐户时使用的电子邮件地址。 + +#### provider + +要使用的 ACME CA 提供商。 + +| 值 | 提供商 | +|--------------------|---------------| +| `letsencrypt (默认)` | Let's Encrypt | +| `zerossl` | ZeroSSL | +| `https://...` | 自定义 | + +当 `provider` 为 `zerossl` 时,如果设置了 `email` 且未设置 `external_account`, +sing-box 会自动向 ZeroSSL 请求 EAB 凭据。 + +当 `provider` 为 `zerossl` 时,必须至少设置 `external_account`、`email` 或 `account_key` 之一。 + +#### account_key + +!!! question "自 sing-box 1.14.0 起" + +现有 ACME 帐户的 PEM 编码私钥。 + +#### disable_http_challenge + +禁用所有 HTTP 质询。 + +#### disable_tls_alpn_challenge + +禁用所有 TLS-ALPN 质询。 + +#### alternative_http_port + +用于 ACME HTTP 质询的备用端口;如果非空,将使用此端口而不是 80 来启动 HTTP 质询的侦听器。 + +#### alternative_tls_port + +用于 ACME TLS-ALPN 质询的备用端口; 系统必须将 443 转发到此端口以使质询成功。 + +#### external_account + +EAB(外部帐户绑定)包含将 ACME 帐户绑定或映射到 CA 已知的其他帐户所需的信息。 + +外部帐户绑定用于将 ACME 帐户与非 ACME 系统中的现有帐户相关联,例如 CA 客户数据库。 + +为了启用 ACME 帐户绑定,运行 ACME 服务器的 CA 需要使用 ACME 之外的某种机制向 ACME 客户端提供 MAC 密钥和密钥标识符。§7.3.4 + +#### external_account.key_id + +密钥标识符。 + +#### external_account.mac_key + +MAC 密钥。 + +#### dns01_challenge + +ACME DNS01 质询字段。如果配置,将禁用其他质询方法。 + +参阅 [DNS01 质询字段](/zh/configuration/shared/dns01_challenge/)。 + +#### key_type + +!!! question "自 sing-box 1.14.0 起" + +为新证书生成的私钥类型。 + +| 值 | 类型 | +|-----------|----------| +| `ed25519` | Ed25519 | +| `p256` | P-256 | +| `p384` | P-384 | +| `rsa2048` | RSA | +| `rsa4096` | RSA | + +#### detour + +!!! question "自 sing-box 1.14.0 起" + +上游出站的标签。 + +所有提供者 HTTP 请求将使用此出站。 diff --git a/docs/configuration/shared/certificate-provider/cloudflare-origin-ca.md b/docs/configuration/shared/certificate-provider/cloudflare-origin-ca.md new file mode 100644 index 000000000..cfd2da4fe --- /dev/null +++ b/docs/configuration/shared/certificate-provider/cloudflare-origin-ca.md @@ -0,0 +1,82 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# Cloudflare Origin CA + +### Structure + +```json +{ + "type": "cloudflare-origin-ca", + "tag": "", + + "domain": [], + "data_directory": "", + "api_token": "", + "origin_ca_key": "", + "request_type": "", + "requested_validity": 0, + "detour": "" +} +``` + +### Fields + +#### domain + +==Required== + +List of domain names or wildcard domain names to include in the certificate. + +#### data_directory + +Root directory used to store the issued certificate, private key, and metadata. + +If empty, sing-box uses the same default data directory as the ACME certificate provider: +`$XDG_DATA_HOME/certmagic` or `$HOME/.local/share/certmagic`. + +#### api_token + +Cloudflare API token used to create the certificate. + +Get or create one in [Cloudflare Dashboard > My Profile > API Tokens](https://dash.cloudflare.com/profile/api-tokens). + +Requires the `Zone / SSL and Certificates / Edit` permission. + +Conflict with `origin_ca_key`. + +#### origin_ca_key + +Cloudflare Origin CA Key. + +Get it in [Cloudflare Dashboard > My Profile > API Tokens > API Keys > Origin CA Key](https://dash.cloudflare.com/profile/api-tokens). + +Conflict with `api_token`. + +#### request_type + +The signature type to request from Cloudflare. + +| Value | Type | +|----------------------|-------------| +| `origin-rsa` | RSA | +| `origin-ecc` | ECDSA P-256 | + +`origin-rsa` is used if empty. + +#### requested_validity + +The requested certificate validity in days. + +Available values: `7`, `30`, `90`, `365`, `730`, `1095`, `5475`. + +`5475` days (15 years) is used if empty. + +#### detour + +The tag of the upstream outbound. + +All provider HTTP requests will use this outbound. diff --git a/docs/configuration/shared/certificate-provider/cloudflare-origin-ca.zh.md b/docs/configuration/shared/certificate-provider/cloudflare-origin-ca.zh.md new file mode 100644 index 000000000..85036268d --- /dev/null +++ b/docs/configuration/shared/certificate-provider/cloudflare-origin-ca.zh.md @@ -0,0 +1,82 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# Cloudflare Origin CA + +### 结构 + +```json +{ + "type": "cloudflare-origin-ca", + "tag": "", + + "domain": [], + "data_directory": "", + "api_token": "", + "origin_ca_key": "", + "request_type": "", + "requested_validity": 0, + "detour": "" +} +``` + +### 字段 + +#### domain + +==必填== + +要写入证书的域名或通配符域名列表。 + +#### data_directory + +保存签发证书、私钥和元数据的根目录。 + +如果为空,sing-box 会使用与 ACME 证书提供者相同的默认数据目录: +`$XDG_DATA_HOME/certmagic` 或 `$HOME/.local/share/certmagic`。 + +#### api_token + +用于创建证书的 Cloudflare API Token。 + +可在 [Cloudflare Dashboard > My Profile > API Tokens](https://dash.cloudflare.com/profile/api-tokens) 获取或创建。 + +需要 `Zone / SSL and Certificates / Edit` 权限。 + +与 `origin_ca_key` 冲突。 + +#### origin_ca_key + +Cloudflare Origin CA Key。 + +可在 [Cloudflare Dashboard > My Profile > API Tokens > API Keys > Origin CA Key](https://dash.cloudflare.com/profile/api-tokens) 获取。 + +与 `api_token` 冲突。 + +#### request_type + +向 Cloudflare 请求的签名类型。 + +| 值 | 类型 | +|----------------------|-------------| +| `origin-rsa` | RSA | +| `origin-ecc` | ECDSA P-256 | + +如果为空,使用 `origin-rsa`。 + +#### requested_validity + +请求的证书有效期,单位为天。 + +可用值:`7`、`30`、`90`、`365`、`730`、`1095`、`5475`。 + +如果为空,使用 `5475` 天(15 年)。 + +#### detour + +上游出站的标签。 + +所有提供者 HTTP 请求将使用此出站。 diff --git a/docs/configuration/shared/certificate-provider/index.md b/docs/configuration/shared/certificate-provider/index.md new file mode 100644 index 000000000..c493550aa --- /dev/null +++ b/docs/configuration/shared/certificate-provider/index.md @@ -0,0 +1,32 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# Certificate Provider + +### Structure + +```json +{ + "certificate_providers": [ + { + "type": "", + "tag": "" + } + ] +} +``` + +### Fields + +| Type | Format | +|--------|------------------| +| `acme` | [ACME](/configuration/shared/certificate-provider/acme) | +| `tailscale` | [Tailscale](/configuration/shared/certificate-provider/tailscale) | +| `cloudflare-origin-ca` | [Cloudflare Origin CA](/configuration/shared/certificate-provider/cloudflare-origin-ca) | + +#### tag + +The tag of the certificate provider. diff --git a/docs/configuration/shared/certificate-provider/index.zh.md b/docs/configuration/shared/certificate-provider/index.zh.md new file mode 100644 index 000000000..2df4b3638 --- /dev/null +++ b/docs/configuration/shared/certificate-provider/index.zh.md @@ -0,0 +1,32 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# 证书提供者 + +### 结构 + +```json +{ + "certificate_providers": [ + { + "type": "", + "tag": "" + } + ] +} +``` + +### 字段 + +| 类型 | 格式 | +|--------|------------------| +| `acme` | [ACME](/zh/configuration/shared/certificate-provider/acme) | +| `tailscale` | [Tailscale](/zh/configuration/shared/certificate-provider/tailscale) | +| `cloudflare-origin-ca` | [Cloudflare Origin CA](/zh/configuration/shared/certificate-provider/cloudflare-origin-ca) | + +#### tag + +证书提供者的标签。 diff --git a/docs/configuration/shared/certificate-provider/tailscale.md b/docs/configuration/shared/certificate-provider/tailscale.md new file mode 100644 index 000000000..045f2c5ec --- /dev/null +++ b/docs/configuration/shared/certificate-provider/tailscale.md @@ -0,0 +1,27 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# Tailscale + +### Structure + +```json +{ + "type": "tailscale", + "tag": "ts-cert", + "endpoint": "ts-ep" +} +``` + +### Fields + +#### endpoint + +==Required== + +The tag of the [Tailscale endpoint](/configuration/endpoint/tailscale/) to reuse. + +[MagicDNS and HTTPS](https://tailscale.com/kb/1153/enabling-https) must be enabled in the Tailscale admin console. diff --git a/docs/configuration/shared/certificate-provider/tailscale.zh.md b/docs/configuration/shared/certificate-provider/tailscale.zh.md new file mode 100644 index 000000000..1987da508 --- /dev/null +++ b/docs/configuration/shared/certificate-provider/tailscale.zh.md @@ -0,0 +1,27 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# Tailscale + +### 结构 + +```json +{ + "type": "tailscale", + "tag": "ts-cert", + "endpoint": "ts-ep" +} +``` + +### 字段 + +#### endpoint + +==必填== + +要复用的 [Tailscale 端点](/zh/configuration/endpoint/tailscale/) 的标签。 + +必须在 Tailscale 管理控制台中启用 [MagicDNS 和 HTTPS](https://tailscale.com/kb/1153/enabling-https)。 diff --git a/docs/configuration/shared/dns01_challenge.md b/docs/configuration/shared/dns01_challenge.md index 8bdbfc97a..0157cb459 100644 --- a/docs/configuration/shared/dns01_challenge.md +++ b/docs/configuration/shared/dns01_challenge.md @@ -2,6 +2,14 @@ icon: material/new-box --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [ttl](#ttl) + :material-plus: [propagation_delay](#propagation_delay) + :material-plus: [propagation_timeout](#propagation_timeout) + :material-plus: [resolvers](#resolvers) + :material-plus: [override_domain](#override_domain) + !!! quote "Changes in sing-box 1.13.0" :material-plus: [alidns.security_token](#security_token) @@ -12,12 +20,57 @@ icon: material/new-box ```json { + "ttl": "", + "propagation_delay": "", + "propagation_timeout": "", + "resolvers": [], + "override_domain": "", "provider": "", ... // Provider Fields } ``` +### Fields + +#### ttl + +!!! question "Since sing-box 1.14.0" + +The TTL of the temporary TXT record used for the DNS challenge. + +#### propagation_delay + +!!! question "Since sing-box 1.14.0" + +How long to wait after creating the challenge record before starting propagation checks. + +#### propagation_timeout + +!!! question "Since sing-box 1.14.0" + +The maximum time to wait for the challenge record to propagate. + +Set to `-1` to disable propagation checks. + +#### resolvers + +!!! question "Since sing-box 1.14.0" + +Preferred DNS resolvers to use for DNS propagation checks. + +#### override_domain + +!!! question "Since sing-box 1.14.0" + +Override the domain name used for the DNS challenge record. + +Useful when `_acme-challenge` is delegated to a different zone. + +#### provider + +The DNS provider. See below for provider-specific fields. + ### Provider Fields #### Alibaba Cloud DNS diff --git a/docs/configuration/shared/dns01_challenge.zh.md b/docs/configuration/shared/dns01_challenge.zh.md index e6919338c..8c582bb54 100644 --- a/docs/configuration/shared/dns01_challenge.zh.md +++ b/docs/configuration/shared/dns01_challenge.zh.md @@ -2,6 +2,14 @@ icon: material/new-box --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [ttl](#ttl) + :material-plus: [propagation_delay](#propagation_delay) + :material-plus: [propagation_timeout](#propagation_timeout) + :material-plus: [resolvers](#resolvers) + :material-plus: [override_domain](#override_domain) + !!! quote "sing-box 1.13.0 中的更改" :material-plus: [alidns.security_token](#security_token) @@ -12,12 +20,57 @@ icon: material/new-box ```json { + "ttl": "", + "propagation_delay": "", + "propagation_timeout": "", + "resolvers": [], + "override_domain": "", "provider": "", ... // 提供商字段 } ``` +### 字段 + +#### ttl + +!!! question "自 sing-box 1.14.0 起" + +DNS 质询临时 TXT 记录的 TTL。 + +#### propagation_delay + +!!! question "自 sing-box 1.14.0 起" + +创建质询记录后,在开始传播检查前要等待的时间。 + +#### propagation_timeout + +!!! question "自 sing-box 1.14.0 起" + +等待质询记录传播完成的最长时间。 + +设为 `-1` 可禁用传播检查。 + +#### resolvers + +!!! question "自 sing-box 1.14.0 起" + +进行 DNS 传播检查时优先使用的 DNS 解析器。 + +#### override_domain + +!!! question "自 sing-box 1.14.0 起" + +覆盖 DNS 质询记录使用的域名。 + +适用于将 `_acme-challenge` 委托到其他 zone 的场景。 + +#### provider + +DNS 提供商。提供商专有字段见下文。 + ### 提供商字段 #### Alibaba Cloud DNS diff --git a/docs/configuration/shared/tls.md b/docs/configuration/shared/tls.md index 73ceffcce..518b2f917 100644 --- a/docs/configuration/shared/tls.md +++ b/docs/configuration/shared/tls.md @@ -2,6 +2,11 @@ icon: material/new-box --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [certificate_provider](#certificate_provider) + :material-delete-clock: [acme](#acme-fields) + !!! quote "Changes in sing-box 1.13.0" :material-plus: [kernel_tx](#kernel_tx) @@ -49,6 +54,10 @@ icon: material/new-box "key_path": "", "kernel_tx": false, "kernel_rx": false, + "certificate_provider": "", + + // Deprecated + "acme": { "domain": [], "data_directory": "", @@ -408,6 +417,18 @@ Enable kernel TLS transmit support. Enable kernel TLS receive support. +#### certificate_provider + +!!! question "Since sing-box 1.14.0" + +==Server only== + +A string or an object. + +When string, the tag of a shared [Certificate Provider](/configuration/shared/certificate-provider/). + +When object, an inline certificate provider. See [Certificate Provider](/configuration/shared/certificate-provider/) for available types and fields. + ## Custom TLS support !!! info "QUIC support" @@ -469,7 +490,7 @@ The ECH key and configuration can be generated by `sing-box generate ech-keypair !!! failure "Deprecated in sing-box 1.12.0" - ECH support has been migrated to use stdlib in sing-box 1.12.0, which does not come with support for PQ signature schemes, so `pq_signature_schemes_enabled` has been deprecated and no longer works. + `pq_signature_schemes_enabled` is deprecated in sing-box 1.12.0 and removed in sing-box 1.13.0. Enable support for post-quantum peer certificate signature schemes. @@ -477,7 +498,7 @@ Enable support for post-quantum peer certificate signature schemes. !!! failure "Deprecated in sing-box 1.12.0" - `dynamic_record_sizing_disabled` has nothing to do with ECH, was added by mistake, has been deprecated and no longer works. + `dynamic_record_sizing_disabled` is deprecated in sing-box 1.12.0 and removed in sing-box 1.13.0. Disables adaptive sizing of TLS records. @@ -566,6 +587,10 @@ Fragment TLS handshake into multiple TLS records to bypass firewalls. ### ACME Fields +!!! failure "Deprecated in sing-box 1.14.0" + + Inline ACME options are deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0, check [Migration](/migration/#migrate-inline-acme-to-certificate-provider). + #### domain List of domain. @@ -677,4 +702,4 @@ A hexadecimal string with zero to eight digits. The maximum time difference between the server and the client. -Check disabled if empty. \ No newline at end of file +Check disabled if empty. diff --git a/docs/configuration/shared/tls.zh.md b/docs/configuration/shared/tls.zh.md index 0b47189bc..56b90d33f 100644 --- a/docs/configuration/shared/tls.zh.md +++ b/docs/configuration/shared/tls.zh.md @@ -2,6 +2,11 @@ icon: material/new-box --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [certificate_provider](#certificate_provider) + :material-delete-clock: [acme](#acme-字段) + !!! quote "sing-box 1.13.0 中的更改" :material-plus: [kernel_tx](#kernel_tx) @@ -49,6 +54,10 @@ icon: material/new-box "key_path": "", "kernel_tx": false, "kernel_rx": false, + "certificate_provider": "", + + // 废弃的 + "acme": { "domain": [], "data_directory": "", @@ -407,6 +416,18 @@ echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/ 启用内核 TLS 接收支持。 +#### certificate_provider + +!!! question "自 sing-box 1.14.0 起" + +==仅服务器== + +字符串或对象。 + +为字符串时,共享[证书提供者](/zh/configuration/shared/certificate-provider/)的标签。 + +为对象时,内联的证书提供者。可用类型和字段参阅[证书提供者](/zh/configuration/shared/certificate-provider/)。 + ## 自定义 TLS 支持 !!! info "QUIC 支持" @@ -465,7 +486,7 @@ ECH 密钥和配置可以通过 `sing-box generate ech-keypair` 生成。 !!! failure "已在 sing-box 1.12.0 废弃" - ECH 支持已在 sing-box 1.12.0 迁移至使用标准库,但标准库不支持后量子对等证书签名方案,因此 `pq_signature_schemes_enabled` 已被弃用且不再工作。 + `pq_signature_schemes_enabled` 已在 sing-box 1.12.0 废弃且已在 sing-box 1.13.0 中被移除。 启用对后量子对等证书签名方案的支持。 @@ -473,7 +494,7 @@ ECH 密钥和配置可以通过 `sing-box generate ech-keypair` 生成。 !!! failure "已在 sing-box 1.12.0 废弃" - `dynamic_record_sizing_disabled` 与 ECH 无关,是错误添加的,现已弃用且不再工作。 + `dynamic_record_sizing_disabled` 已在 sing-box 1.12.0 废弃且已在 sing-box 1.13.0 中被移除。 禁用 TLS 记录的自适应大小调整。 @@ -561,6 +582,10 @@ ECH 配置路径,PEM 格式。 ### ACME 字段 +!!! failure "已在 sing-box 1.14.0 废弃" + + 内联 ACME 选项已在 sing-box 1.14.0 废弃且将在 sing-box 1.16.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移内联-acme-到证书提供者)。 + #### domain 域名列表。 diff --git a/docs/deprecated.md b/docs/deprecated.md index 8e53bda6d..3faf986e0 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -4,6 +4,16 @@ icon: material/delete-alert # Deprecated Feature List +## 1.14.0 + +#### Inline ACME options in TLS + +Inline ACME options (`tls.acme`) are deprecated +and can be replaced by the ACME certificate provider, +check [Migration](../migration/#migrate-inline-acme-to-certificate-provider). + +Old fields will be removed in sing-box 1.16.0. + ## 1.12.0 #### Legacy DNS server formats @@ -28,7 +38,7 @@ so `pq_signature_schemes_enabled` has been deprecated and no longer works. Also, `dynamic_record_sizing_disabled` has nothing to do with ECH, was added by mistake, has been deprecated and no longer works. -These fields will be removed in sing-box 1.13.0. +These fields were removed in sing-box 1.13.0. ## 1.11.0 @@ -38,7 +48,7 @@ Legacy special outbounds (`block` / `dns`) are deprecated and can be replaced by rule actions, check [Migration](../migration/#migrate-legacy-special-outbounds-to-rule-actions). -Old fields will be removed in sing-box 1.13.0. +Old fields were removed in sing-box 1.13.0. #### Legacy inbound fields @@ -46,7 +56,7 @@ Legacy inbound fields (`inbound.` are deprecated and can be replaced by rule actions, check [Migration](../migration/#migrate-legacy-inbound-fields-to-rule-actions). -Old fields will be removed in sing-box 1.13.0. +Old fields were removed in sing-box 1.13.0. #### Destination override fields in direct outbound @@ -54,18 +64,20 @@ Destination override fields (`override_address` / `override_port`) in direct out and can be replaced by rule actions, check [Migration](../migration/#migrate-destination-override-fields-to-route-options). +Old fields were removed in sing-box 1.13.0. + #### WireGuard outbound WireGuard outbound is deprecated and can be replaced by endpoint, check [Migration](../migration/#migrate-wireguard-outbound-to-endpoint). -Old outbound will be removed in sing-box 1.13.0. +Old outbound was removed in sing-box 1.13.0. #### GSO option in TUN GSO has no advantages for transparent proxy scenarios, is deprecated and no longer works in TUN. -Old fields will be removed in sing-box 1.13.0. +Old fields were removed in sing-box 1.13.0. ## 1.10.0 @@ -75,12 +87,12 @@ Old fields will be removed in sing-box 1.13.0. `inet4_route_address` and `inet6_route_address` are merged into `route_address`, `inet4_route_exclude_address` and `inet6_route_exclude_address` are merged into `route_exclude_address`. -Old fields will be removed in sing-box 1.12.0. +Old fields were removed in sing-box 1.12.0. #### Match source rule items are renamed `rule_set_ipcidr_match_source` route and DNS rule items are renamed to -`rule_set_ip_cidr_match_source` and will be remove in sing-box 1.11.0. +`rule_set_ip_cidr_match_source` and were removed in sing-box 1.11.0. #### Drop support for go1.18 and go1.19 @@ -95,7 +107,7 @@ check [Migration](/migration/#migrate-cache-file-from-clash-api-to-independent-o #### GeoIP -GeoIP is deprecated and will be removed in sing-box 1.12.0. +GeoIP is deprecated and was removed in sing-box 1.12.0. The maxmind GeoIP National Database, as an IP classification database, is not entirely suitable for traffic bypassing, @@ -106,7 +118,7 @@ check [Migration](/migration/#migrate-geoip-to-rule-sets). #### Geosite -Geosite is deprecated and will be removed in sing-box 1.12.0. +Geosite is deprecated and was removed in sing-box 1.12.0. Geosite, the `domain-list-community` project maintained by V2Ray as an early traffic bypassing solution, suffers from a number of problems, including lack of maintenance, inaccurate rules, and difficult management. diff --git a/docs/deprecated.zh.md b/docs/deprecated.zh.md index 82b6db042..e710e78ce 100644 --- a/docs/deprecated.zh.md +++ b/docs/deprecated.zh.md @@ -4,6 +4,18 @@ icon: material/delete-alert # 废弃功能列表 +## 1.14.0 + +#### TLS 中的内联 ACME 选项 + +TLS 中的内联 ACME 选项(`tls.acme`)已废弃, +且可以通过 ACME 证书提供者替代, +参阅 [迁移指南](/zh/migration/#迁移内联-acme-到证书提供者)。 + +旧字段将在 sing-box 1.16.0 中被移除。 + +## 1.12.0 + #### 旧的 DNS 服务器格式 DNS 服务器已重构, @@ -24,7 +36,7 @@ ECH 支持已在 sing-box 1.12.0 迁移至使用标准库,但标准库不支 另外,`dynamic_record_sizing_disabled` 与 ECH 无关,是错误添加的,现已弃用且不再工作。 -相关字段将在 sing-box 1.13.0 中被移除。 +相关字段已在 sing-box 1.13.0 中被移除。 ## 1.11.0 @@ -33,41 +45,41 @@ ECH 支持已在 sing-box 1.12.0 迁移至使用标准库,但标准库不支 旧的特殊出站(`block` / `dns`)已废弃且可以通过规则动作替代, 参阅 [迁移指南](/zh/migration/#迁移旧的特殊出站到规则动作)。 -旧字段将在 sing-box 1.13.0 中被移除。 +旧字段已在 sing-box 1.13.0 中被移除。 #### 旧的入站字段 旧的入站字段(`inbound.`)已废弃且可以通过规则动作替代, 参阅 [迁移指南](/zh/migration/#迁移旧的入站字段到规则动作)。 -旧字段将在 sing-box 1.13.0 中被移除。 +旧字段已在 sing-box 1.13.0 中被移除。 #### direct 出站中的目标地址覆盖字段 direct 出站中的目标地址覆盖字段(`override_address` / `override_port`)已废弃且可以通过规则动作替代, 参阅 [迁移指南](/zh/migration/#迁移-direct-出站中的目标地址覆盖字段到路由字段)。 -旧字段将在 sing-box 1.13.0 中被移除。 +旧字段已在 sing-box 1.13.0 中被移除。 #### WireGuard 出站 WireGuard 出站已废弃且可以通过端点替代, 参阅 [迁移指南](/zh/migration/#迁移-wireguard-出站到端点)。 -旧出站将在 sing-box 1.13.0 中被移除。 +旧出站已在 sing-box 1.13.0 中被移除。 #### TUN 的 GSO 字段 GSO 对透明代理场景没有优势,已废弃且在 TUN 中不再起作用。 -旧字段将在 sing-box 1.13.0 中被移除。 +旧字段已在 sing-box 1.13.0 中被移除。 ## 1.10.0 #### Match source 规则项已重命名 `rule_set_ipcidr_match_source` 路由和 DNS 规则项已被重命名为 -`rule_set_ip_cidr_match_source` 且将在 sing-box 1.11.0 中被移除。 +`rule_set_ip_cidr_match_source` 且已在 sing-box 1.11.0 中被移除。 #### TUN 地址字段已合并 @@ -75,7 +87,7 @@ GSO 对透明代理场景没有优势,已废弃且在 TUN 中不再起作用 `inet4_route_address` 和 `inet6_route_address` 已合并为 `route_address`, `inet4_route_exclude_address` 和 `inet6_route_exclude_address` 已合并为 `route_exclude_address`。 -旧字段将在 sing-box 1.11.0 中被移除。 +旧字段已在 sing-box 1.12.0 中被移除。 #### 移除对 go1.18 和 go1.19 的支持 @@ -90,7 +102,7 @@ Clash API 中的 `cache_file` 及相关功能已废弃且已迁移到独立的 ` #### GeoIP -GeoIP 已废弃且将在 sing-box 1.12.0 中被移除。 +GeoIP 已废弃且已在 sing-box 1.12.0 中被移除。 maxmind GeoIP 国家数据库作为 IP 分类数据库,不完全适合流量绕过, 且现有的实现均存在内存使用大与管理困难的问题。 @@ -100,7 +112,7 @@ sing-box 1.8.0 引入了[规则集](/zh/configuration/rule-set/), #### Geosite -Geosite 已废弃且将在 sing-box 1.12.0 中被移除。 +Geosite 已废弃且已在 sing-box 1.12.0 中被移除。 Geosite,即由 V2Ray 维护的 domain-list-community 项目,作为早期流量绕过解决方案, 存在着包括缺少维护、规则不准确和管理困难内的大量问题。 diff --git a/docs/migration.md b/docs/migration.md index 86074ac71..810bae190 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -2,6 +2,83 @@ icon: material/arrange-bring-forward --- +## 1.14.0 + +### Migrate inline ACME to certificate provider + +Inline ACME options in TLS are deprecated and can be replaced by certificate providers. + +Most `tls.acme` fields can be moved into the ACME certificate provider unchanged. +See [ACME](/configuration/shared/certificate-provider/acme/) for fields newly added in sing-box 1.14.0. + +!!! info "References" + + [TLS](/configuration/shared/tls/#certificate_provider) / + [Certificate Provider](/configuration/shared/certificate-provider/) + +=== ":material-card-remove: Deprecated" + + ```json + { + "inbounds": [ + { + "type": "trojan", + "tls": { + "enabled": true, + "acme": { + "domain": ["example.com"], + "email": "admin@example.com" + } + } + } + ] + } + ``` + +=== ":material-card-multiple: Inline" + + ```json + { + "inbounds": [ + { + "type": "trojan", + "tls": { + "enabled": true, + "certificate_provider": { + "type": "acme", + "domain": ["example.com"], + "email": "admin@example.com" + } + } + } + ] + } + ``` + +=== ":material-card-multiple: Shared" + + ```json + { + "certificate_providers": [ + { + "type": "acme", + "tag": "my-cert", + "domain": ["example.com"], + "email": "admin@example.com" + } + ], + "inbounds": [ + { + "type": "trojan", + "tls": { + "enabled": true, + "certificate_provider": "my-cert" + } + } + ] + } + ``` + ## 1.12.0 ### Migrate to new DNS server formats diff --git a/docs/migration.zh.md b/docs/migration.zh.md index c08be78f5..18e287261 100644 --- a/docs/migration.zh.md +++ b/docs/migration.zh.md @@ -2,6 +2,83 @@ icon: material/arrange-bring-forward --- +## 1.14.0 + +### 迁移内联 ACME 到证书提供者 + +TLS 中的内联 ACME 选项已废弃,且可以被证书提供者替代。 + +`tls.acme` 的大多数字段都可以原样迁移到 ACME 证书提供者中。 +sing-box 1.14.0 新增字段参阅 [ACME](/zh/configuration/shared/certificate-provider/acme/) 页面。 + +!!! info "参考" + + [TLS](/zh/configuration/shared/tls/#certificate_provider) / + [证书提供者](/zh/configuration/shared/certificate-provider/) + +=== ":material-card-remove: 弃用的" + + ```json + { + "inbounds": [ + { + "type": "trojan", + "tls": { + "enabled": true, + "acme": { + "domain": ["example.com"], + "email": "admin@example.com" + } + } + } + ] + } + ``` + +=== ":material-card-multiple: 内联" + + ```json + { + "inbounds": [ + { + "type": "trojan", + "tls": { + "enabled": true, + "certificate_provider": { + "type": "acme", + "domain": ["example.com"], + "email": "admin@example.com" + } + } + } + ] + } + ``` + +=== ":material-card-multiple: 共享" + + ```json + { + "certificate_providers": [ + { + "type": "acme", + "tag": "my-cert", + "domain": ["example.com"], + "email": "admin@example.com" + } + ], + "inbounds": [ + { + "type": "trojan", + "tls": { + "enabled": true, + "certificate_provider": "my-cert" + } + } + ] + } + ``` + ## 1.12.0 ### 迁移到新的 DNS 服务器格式 diff --git a/experimental/deprecated/constants.go b/experimental/deprecated/constants.go index 385105d38..3526cda83 100644 --- a/experimental/deprecated/constants.go +++ b/experimental/deprecated/constants.go @@ -102,10 +102,20 @@ var OptionLegacyDomainStrategyOptions = Note{ MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-domain-strategy-options", } +var OptionInlineACME = Note{ + Name: "inline-acme-options", + Description: "inline ACME options in TLS", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + EnvName: "INLINE_ACME_OPTIONS", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-inline-acme-to-certificate-provider", +} + var Options = []Note{ OptionLegacyDNSTransport, OptionLegacyDNSFakeIPOptions, OptionOutboundDNSRuleItem, OptionMissingDomainResolver, OptionLegacyDomainStrategyOptions, + OptionInlineACME, } diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go index 54369bf77..eca3fdf94 100644 --- a/experimental/libbox/config.go +++ b/experimental/libbox/config.go @@ -33,7 +33,7 @@ func baseContext(platformInterface PlatformInterface) context.Context { } ctx := context.Background() ctx = filemanager.WithDefault(ctx, sWorkingPath, sTempPath, sUserID, sGroupID) - return box.Context(ctx, include.InboundRegistry(), include.OutboundRegistry(), include.EndpointRegistry(), dnsRegistry, include.ServiceRegistry()) + return box.Context(ctx, include.InboundRegistry(), include.OutboundRegistry(), include.EndpointRegistry(), dnsRegistry, include.ServiceRegistry(), include.CertificateProviderRegistry()) } func parseConfig(ctx context.Context, configContent string) (option.Options, error) { diff --git a/include/acme.go b/include/acme.go new file mode 100644 index 000000000..093fd5082 --- /dev/null +++ b/include/acme.go @@ -0,0 +1,12 @@ +//go:build with_acme + +package include + +import ( + "github.com/sagernet/sing-box/adapter/certificate" + "github.com/sagernet/sing-box/service/acme" +) + +func registerACMECertificateProvider(registry *certificate.Registry) { + acme.RegisterCertificateProvider(registry) +} diff --git a/include/acme_stub.go b/include/acme_stub.go new file mode 100644 index 000000000..bceab3d73 --- /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/certificate" + 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 registerACMECertificateProvider(registry *certificate.Registry) { + certificate.Register[option.ACMECertificateProviderOptions](registry, C.TypeACME, func(ctx context.Context, logger log.ContextLogger, tag string, options option.ACMECertificateProviderOptions) (adapter.CertificateProviderService, 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..eb22cce1f 100644 --- a/include/registry.go +++ b/include/registry.go @@ -5,6 +5,7 @@ import ( "github.com/sagernet/sing-box" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/certificate" "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" @@ -34,13 +35,14 @@ import ( "github.com/sagernet/sing-box/protocol/tun" "github.com/sagernet/sing-box/protocol/vless" "github.com/sagernet/sing-box/protocol/vmess" + originca "github.com/sagernet/sing-box/service/origin_ca" "github.com/sagernet/sing-box/service/resolved" "github.com/sagernet/sing-box/service/ssmapi" E "github.com/sagernet/sing/common/exceptions" ) func Context(ctx context.Context) context.Context { - return box.Context(ctx, InboundRegistry(), OutboundRegistry(), EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry()) + return box.Context(ctx, InboundRegistry(), OutboundRegistry(), EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry(), CertificateProviderRegistry()) } func InboundRegistry() *inbound.Registry { @@ -139,6 +141,16 @@ func ServiceRegistry() *service.Registry { return registry } +func CertificateProviderRegistry() *certificate.Registry { + registry := certificate.NewRegistry() + + registerACMECertificateProvider(registry) + registerTailscaleCertificateProvider(registry) + originca.RegisterCertificateProvider(registry) + + return registry +} + func registerStubForRemovedInbounds(registry *inbound.Registry) { inbound.Register[option.ShadowsocksInboundOptions](registry, C.TypeShadowsocksR, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (adapter.Inbound, error) { return nil, E.New("ShadowsocksR is deprecated and removed in sing-box 1.6.0") diff --git a/include/tailscale.go b/include/tailscale.go index 1757283b0..6f85aaac1 100644 --- a/include/tailscale.go +++ b/include/tailscale.go @@ -3,6 +3,7 @@ package include import ( + "github.com/sagernet/sing-box/adapter/certificate" "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/service" "github.com/sagernet/sing-box/dns" @@ -18,6 +19,10 @@ func registerTailscaleTransport(registry *dns.TransportRegistry) { tailscale.RegistryTransport(registry) } +func registerTailscaleCertificateProvider(registry *certificate.Registry) { + tailscale.RegisterCertificateProvider(registry) +} + func registerDERPService(registry *service.Registry) { derp.Register(registry) } diff --git a/include/tailscale_stub.go b/include/tailscale_stub.go index 78398875f..e6f97f1ea 100644 --- a/include/tailscale_stub.go +++ b/include/tailscale_stub.go @@ -6,6 +6,7 @@ import ( "context" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/certificate" "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/service" C "github.com/sagernet/sing-box/constant" @@ -27,6 +28,12 @@ func registerTailscaleTransport(registry *dns.TransportRegistry) { }) } +func registerTailscaleCertificateProvider(registry *certificate.Registry) { + certificate.Register[option.TailscaleCertificateProviderOptions](registry, C.TypeTailscale, func(ctx context.Context, logger log.ContextLogger, tag string, options option.TailscaleCertificateProviderOptions) (adapter.CertificateProviderService, error) { + return nil, E.New(`Tailscale is not included in this build, rebuild with -tags with_tailscale`) + }) +} + func registerDERPService(registry *service.Registry) { service.Register[option.DERPServiceOptions](registry, C.TypeDERP, func(ctx context.Context, logger log.ContextLogger, tag string, options option.DERPServiceOptions) (adapter.Service, error) { return nil, E.New(`DERP is not included in this build, rebuild with -tags with_tailscale`) diff --git a/mkdocs.yml b/mkdocs.yml index 5f95842a5..65c9db71f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -122,6 +122,11 @@ nav: - Listen Fields: configuration/shared/listen.md - Dial Fields: configuration/shared/dial.md - TLS: configuration/shared/tls.md + - Certificate Provider: + - configuration/shared/certificate-provider/index.md + - ACME: configuration/shared/certificate-provider/acme.md + - Tailscale: configuration/shared/certificate-provider/tailscale.md + - Cloudflare Origin CA: configuration/shared/certificate-provider/cloudflare-origin-ca.md - DNS01 Challenge Fields: configuration/shared/dns01_challenge.md - Pre-match: configuration/shared/pre-match.md - Multiplex: configuration/shared/multiplex.md @@ -273,6 +278,7 @@ plugins: Shared: 通用 Listen Fields: 监听字段 Dial Fields: 拨号字段 + Certificate Provider Fields: 证书提供者字段 DNS01 Challenge Fields: DNS01 验证字段 Multiplex: 多路复用 V2Ray Transport: V2Ray 传输层 @@ -281,6 +287,7 @@ plugins: Endpoint: 端点 Inbound: 入站 Outbound: 出站 + Certificate Provider: 证书提供者 Manual: 手册 reconfigure_material: true diff --git a/option/acme.go b/option/acme.go new file mode 100644 index 000000000..ea9349b72 --- /dev/null +++ b/option/acme.go @@ -0,0 +1,106 @@ +package option + +import ( + "strings" + + 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" + "github.com/sagernet/sing/common/json/badoption" +) + +type ACMECertificateProviderOptions 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"` + AccountKey string `json:"account_key,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 *ACMEProviderDNS01ChallengeOptions `json:"dns01_challenge,omitempty"` + KeyType ACMEKeyType `json:"key_type,omitempty"` + Detour string `json:"detour,omitempty"` +} + +type _ACMEProviderDNS01ChallengeOptions struct { + TTL badoption.Duration `json:"ttl,omitempty"` + PropagationDelay badoption.Duration `json:"propagation_delay,omitempty"` + PropagationTimeout badoption.Duration `json:"propagation_timeout,omitempty"` + Resolvers badoption.Listable[string] `json:"resolvers,omitempty"` + OverrideDomain string `json:"override_domain,omitempty"` + Provider string `json:"provider,omitempty"` + AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` + CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` + ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` +} + +type ACMEProviderDNS01ChallengeOptions _ACMEProviderDNS01ChallengeOptions + +func (o ACMEProviderDNS01ChallengeOptions) MarshalJSON() ([]byte, error) { + var v any + switch o.Provider { + case C.DNSProviderAliDNS: + v = o.AliDNSOptions + case C.DNSProviderCloudflare: + v = o.CloudflareOptions + case C.DNSProviderACMEDNS: + v = o.ACMEDNSOptions + case "": + return nil, E.New("missing provider type") + default: + return nil, E.New("unknown provider type: ", o.Provider) + } + return badjson.MarshallObjects((_ACMEProviderDNS01ChallengeOptions)(o), v) +} + +func (o *ACMEProviderDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_ACMEProviderDNS01ChallengeOptions)(o)) + if err != nil { + return err + } + var v any + switch o.Provider { + case C.DNSProviderAliDNS: + v = &o.AliDNSOptions + case C.DNSProviderCloudflare: + v = &o.CloudflareOptions + case C.DNSProviderACMEDNS: + v = &o.ACMEDNSOptions + case "": + return E.New("missing provider type") + default: + return E.New("unknown provider type: ", o.Provider) + } + return badjson.UnmarshallExcluded(bytes, (*_ACMEProviderDNS01ChallengeOptions)(o), v) +} + +type ACMEKeyType string + +const ( + ACMEKeyTypeED25519 = ACMEKeyType("ed25519") + ACMEKeyTypeP256 = ACMEKeyType("p256") + ACMEKeyTypeP384 = ACMEKeyType("p384") + ACMEKeyTypeRSA2048 = ACMEKeyType("rsa2048") + ACMEKeyTypeRSA4096 = ACMEKeyType("rsa4096") +) + +func (t *ACMEKeyType) UnmarshalJSON(data []byte) error { + var value string + err := json.Unmarshal(data, &value) + if err != nil { + return err + } + value = strings.ToLower(value) + switch ACMEKeyType(value) { + case "", ACMEKeyTypeED25519, ACMEKeyTypeP256, ACMEKeyTypeP384, ACMEKeyTypeRSA2048, ACMEKeyTypeRSA4096: + *t = ACMEKeyType(value) + default: + return E.New("unknown ACME key type: ", value) + } + return nil +} diff --git a/option/certificate_provider.go b/option/certificate_provider.go new file mode 100644 index 000000000..a24abdc57 --- /dev/null +++ b/option/certificate_provider.go @@ -0,0 +1,100 @@ +package option + +import ( + "context" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/service" +) + +type CertificateProviderOptionsRegistry interface { + CreateOptions(providerType string) (any, bool) +} + +type _CertificateProvider struct { + Type string `json:"type"` + Tag string `json:"tag,omitempty"` + Options any `json:"-"` +} + +type CertificateProvider _CertificateProvider + +func (h *CertificateProvider) MarshalJSONContext(ctx context.Context) ([]byte, error) { + return badjson.MarshallObjectsContext(ctx, (*_CertificateProvider)(h), h.Options) +} + +func (h *CertificateProvider) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_CertificateProvider)(h)) + if err != nil { + return err + } + registry := service.FromContext[CertificateProviderOptionsRegistry](ctx) + if registry == nil { + return E.New("missing certificate provider options registry in context") + } + options, loaded := registry.CreateOptions(h.Type) + if !loaded { + return E.New("unknown certificate provider type: ", h.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, content, (*_CertificateProvider)(h), options) + if err != nil { + return err + } + h.Options = options + return nil +} + +type CertificateProviderOptions struct { + Tag string `json:"-"` + Type string `json:"-"` + Options any `json:"-"` +} + +type _CertificateProviderInline struct { + Type string `json:"type"` +} + +func (o *CertificateProviderOptions) MarshalJSONContext(ctx context.Context) ([]byte, error) { + if o.Tag != "" { + return json.Marshal(o.Tag) + } + return badjson.MarshallObjectsContext(ctx, _CertificateProviderInline{Type: o.Type}, o.Options) +} + +func (o *CertificateProviderOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { + if len(content) == 0 { + return E.New("empty certificate_provider value") + } + if content[0] == '"' { + return json.UnmarshalContext(ctx, content, &o.Tag) + } + var inline _CertificateProviderInline + err := json.UnmarshalContext(ctx, content, &inline) + if err != nil { + return err + } + o.Type = inline.Type + if o.Type == "" { + return E.New("missing certificate provider type") + } + registry := service.FromContext[CertificateProviderOptionsRegistry](ctx) + if registry == nil { + return E.New("missing certificate provider options registry in context") + } + options, loaded := registry.CreateOptions(o.Type) + if !loaded { + return E.New("unknown certificate provider type: ", o.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, content, &inline, options) + if err != nil { + return err + } + o.Options = options + return nil +} + +func (o *CertificateProviderOptions) IsShared() bool { + return o.Tag != "" +} diff --git a/option/options.go b/option/options.go index 8bebd48fc..a08dcbc0f 100644 --- a/option/options.go +++ b/option/options.go @@ -10,18 +10,19 @@ import ( ) type _Options struct { - RawMessage json.RawMessage `json:"-"` - Schema string `json:"$schema,omitempty"` - Log *LogOptions `json:"log,omitempty"` - DNS *DNSOptions `json:"dns,omitempty"` - NTP *NTPOptions `json:"ntp,omitempty"` - Certificate *CertificateOptions `json:"certificate,omitempty"` - Endpoints []Endpoint `json:"endpoints,omitempty"` - Inbounds []Inbound `json:"inbounds,omitempty"` - Outbounds []Outbound `json:"outbounds,omitempty"` - Route *RouteOptions `json:"route,omitempty"` - Services []Service `json:"services,omitempty"` - Experimental *ExperimentalOptions `json:"experimental,omitempty"` + RawMessage json.RawMessage `json:"-"` + Schema string `json:"$schema,omitempty"` + Log *LogOptions `json:"log,omitempty"` + DNS *DNSOptions `json:"dns,omitempty"` + NTP *NTPOptions `json:"ntp,omitempty"` + Certificate *CertificateOptions `json:"certificate,omitempty"` + CertificateProviders []CertificateProvider `json:"certificate_providers,omitempty"` + Endpoints []Endpoint `json:"endpoints,omitempty"` + Inbounds []Inbound `json:"inbounds,omitempty"` + Outbounds []Outbound `json:"outbounds,omitempty"` + Route *RouteOptions `json:"route,omitempty"` + Services []Service `json:"services,omitempty"` + Experimental *ExperimentalOptions `json:"experimental,omitempty"` } type Options _Options @@ -56,6 +57,25 @@ func checkOptions(options *Options) error { if err != nil { return err } + err = checkCertificateProviders(options.CertificateProviders) + if err != nil { + return err + } + return nil +} + +func checkCertificateProviders(providers []CertificateProvider) error { + seen := make(map[string]bool) + for i, provider := range providers { + tag := provider.Tag + if tag == "" { + tag = F.ToString(i) + } + if seen[tag] { + return E.New("duplicate certificate provider tag: ", tag) + } + seen[tag] = true + } return nil } diff --git a/option/origin_ca.go b/option/origin_ca.go new file mode 100644 index 000000000..ee8b37041 --- /dev/null +++ b/option/origin_ca.go @@ -0,0 +1,76 @@ +package option + +import ( + "strings" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badoption" +) + +type CloudflareOriginCACertificateProviderOptions struct { + Domain badoption.Listable[string] `json:"domain,omitempty"` + DataDirectory string `json:"data_directory,omitempty"` + APIToken string `json:"api_token,omitempty"` + OriginCAKey string `json:"origin_ca_key,omitempty"` + RequestType CloudflareOriginCARequestType `json:"request_type,omitempty"` + RequestedValidity CloudflareOriginCARequestValidity `json:"requested_validity,omitempty"` + Detour string `json:"detour,omitempty"` +} + +type CloudflareOriginCARequestType string + +const ( + CloudflareOriginCARequestTypeOriginRSA = CloudflareOriginCARequestType("origin-rsa") + CloudflareOriginCARequestTypeOriginECC = CloudflareOriginCARequestType("origin-ecc") +) + +func (t *CloudflareOriginCARequestType) UnmarshalJSON(data []byte) error { + var value string + err := json.Unmarshal(data, &value) + if err != nil { + return err + } + value = strings.ToLower(value) + switch CloudflareOriginCARequestType(value) { + case "", CloudflareOriginCARequestTypeOriginRSA, CloudflareOriginCARequestTypeOriginECC: + *t = CloudflareOriginCARequestType(value) + default: + return E.New("unsupported Cloudflare Origin CA request type: ", value) + } + return nil +} + +type CloudflareOriginCARequestValidity uint16 + +const ( + CloudflareOriginCARequestValidity7 = CloudflareOriginCARequestValidity(7) + CloudflareOriginCARequestValidity30 = CloudflareOriginCARequestValidity(30) + CloudflareOriginCARequestValidity90 = CloudflareOriginCARequestValidity(90) + CloudflareOriginCARequestValidity365 = CloudflareOriginCARequestValidity(365) + CloudflareOriginCARequestValidity730 = CloudflareOriginCARequestValidity(730) + CloudflareOriginCARequestValidity1095 = CloudflareOriginCARequestValidity(1095) + CloudflareOriginCARequestValidity5475 = CloudflareOriginCARequestValidity(5475) +) + +func (v *CloudflareOriginCARequestValidity) UnmarshalJSON(data []byte) error { + var value uint16 + err := json.Unmarshal(data, &value) + if err != nil { + return err + } + switch CloudflareOriginCARequestValidity(value) { + case 0, + CloudflareOriginCARequestValidity7, + CloudflareOriginCARequestValidity30, + CloudflareOriginCARequestValidity90, + CloudflareOriginCARequestValidity365, + CloudflareOriginCARequestValidity730, + CloudflareOriginCARequestValidity1095, + CloudflareOriginCARequestValidity5475: + *v = CloudflareOriginCARequestValidity(value) + default: + return E.New("unsupported Cloudflare Origin CA requested validity: ", value) + } + return nil +} diff --git a/option/tailscale.go b/option/tailscale.go index 68a143693..a4f82ce0d 100644 --- a/option/tailscale.go +++ b/option/tailscale.go @@ -36,6 +36,10 @@ type TailscaleDNSServerOptions struct { AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"` } +type TailscaleCertificateProviderOptions struct { + Endpoint string `json:"endpoint,omitempty"` +} + type DERPServiceOptions struct { ListenOptions InboundTLSOptionsContainer diff --git a/option/tls.go b/option/tls.go index 60343a15f..dbbb7620e 100644 --- a/option/tls.go +++ b/option/tls.go @@ -28,9 +28,13 @@ 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/protocol/tailscale/certificate_provider.go b/protocol/tailscale/certificate_provider.go new file mode 100644 index 000000000..5ac18a307 --- /dev/null +++ b/protocol/tailscale/certificate_provider.go @@ -0,0 +1,98 @@ +//go:build with_gvisor + +package tailscale + +import ( + "context" + "crypto/tls" + "net" + + "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" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + "github.com/sagernet/tailscale/client/local" +) + +func RegisterCertificateProvider(registry *certificate.Registry) { + certificate.Register[option.TailscaleCertificateProviderOptions](registry, C.TypeTailscale, NewCertificateProvider) +} + +var _ adapter.CertificateProviderService = (*CertificateProvider)(nil) + +type CertificateProvider struct { + certificate.Adapter + endpointTag string + endpoint *Endpoint + dialer N.Dialer + localClient *local.Client +} + +func NewCertificateProvider(ctx context.Context, _ log.ContextLogger, tag string, options option.TailscaleCertificateProviderOptions) (adapter.CertificateProviderService, error) { + if options.Endpoint == "" { + return nil, E.New("missing tailscale endpoint tag") + } + endpointManager := service.FromContext[adapter.EndpointManager](ctx) + if endpointManager == nil { + return nil, E.New("missing endpoint manager in context") + } + rawEndpoint, loaded := endpointManager.Get(options.Endpoint) + if !loaded { + return nil, E.New("endpoint not found: ", options.Endpoint) + } + endpoint, isTailscale := rawEndpoint.(*Endpoint) + if !isTailscale { + return nil, E.New("endpoint is not Tailscale: ", options.Endpoint) + } + providerDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: option.DialerOptions{}, + RemoteIsDomain: true, + }) + if err != nil { + return nil, E.Cause(err, "create tailscale certificate provider dialer") + } + return &CertificateProvider{ + Adapter: certificate.NewAdapter(C.TypeTailscale, tag), + endpointTag: options.Endpoint, + endpoint: endpoint, + dialer: providerDialer, + }, nil +} + +func (p *CertificateProvider) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + localClient, err := p.endpoint.Server().LocalClient() + if err != nil { + return E.Cause(err, "initialize tailscale local client for endpoint ", p.endpointTag) + } + originalDial := localClient.Dial + localClient.Dial = func(ctx context.Context, network, addr string) (net.Conn, error) { + if originalDial != nil && addr == "local-tailscaled.sock:80" { + return originalDial(ctx, network, addr) + } + return p.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + } + p.localClient = localClient + return nil +} + +func (p *CertificateProvider) Close() error { + return nil +} + +func (p *CertificateProvider) GetCertificate(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) { + localClient := p.localClient + if localClient == nil { + return nil, E.New("Tailscale is not ready yet") + } + return localClient.GetCertificate(clientHello) +} diff --git a/service/acme/service.go b/service/acme/service.go new file mode 100644 index 000000000..8286a1971 --- /dev/null +++ b/service/acme/service.go @@ -0,0 +1,411 @@ +//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 +} 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 diff --git a/service/origin_ca/service.go b/service/origin_ca/service.go new file mode 100644 index 000000000..85588c37d --- /dev/null +++ b/service/origin_ca/service.go @@ -0,0 +1,618 @@ +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"` +}