diff --git a/.github/CRONET_GO_VERSION b/.github/CRONET_GO_VERSION index 2838ee072b..cc20e651e7 100644 --- a/.github/CRONET_GO_VERSION +++ b/.github/CRONET_GO_VERSION @@ -1 +1 @@ -cba7b9ac0399055aa49fbdc57c03c374f58e1597 +d181863d6a4aa2e7bb7eaf67c1d512c5e4827fde diff --git a/.gitignore b/.gitignore index d2b74d08cd..ccf3310e64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.idea/ +/.vscode/ /vendor/ /*.json /*.srs diff --git a/README.md b/README.md index 90be2a83a7..ed1ad1e8bb 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,141 @@ The universal proxy platform. https://sing-box.sagernet.org +## Inbound TLS + +```json +{ + "inbounds": [ + { + "type": "trojan", + "tag": "trojan-in", + "tls": { + "enabled": true, + "server_name": "sekai.love", + "certificate_path": "cert.pem", + "key_path": "key.key", + "reject_unknown_sni": true + } + }, + { + "type": "anytls", + "tag": "anytls-in", + "tls": { + "enabled": true, + "server_names": [ + "sagernet.sekai.love", + "sekai.love" + ], + "certificate_path": "cert.pem", + "key_path": "key.key", + "reject_unknown_sni": true + } + } + ] +} +``` + +Reject unknown SNI: If the server name of connection does not match `server_name` or any domain in `server_names`, +and is not included in the certificate, it will be rejected. + +拒绝未知 SNI:如果连接的 server name 与 `server_name` 或者 `server_names` 中包含的域名 不符 且 证书中不包含它,则拒绝连接。 + +## Dialer + +```json +{ + "outbounds": [ + { + "type": "direct", + "tag": "direct", + "tcp_keep_alive": "5m", + "tcp_keep_alive_interval": "75s", + "tcp_keep_alive_count": 0, + "disable_tcp_keep_alive": false + } + ] +} +``` + +TCP Keep alive options. + +## DNS + +### TCP + +```json +{ + "dns": { + "servers": [ + { + "type": "tcp", + "tag": "cloudlfare-tcp", + "server": "1.1.1.1", + "server_port": 53, + "reuse": true, + "pipeline": true + } + ] + } +} +``` + +- `reuse`: Reuse TCP connection. Always enabled when `pipeline` is true. +- `pipeline`: Enable DNS pipelining (RFC 9210). Multiple queries can be sent without waiting for responses, improving performance. + +### DoT + +```json +{ + "dns": { + "servers": [ + { + "type": "tls", + "tag": "cloudflare-dot", + "server": "1.1.1.1", + "server_port": 853, + "pipeline": true + } + ] + } +} +``` + +- `pipeline`: Enable DNS pipelining (RFC 9210). Multiple queries can be sent over the same TLS connection without waiting for responses, +significantly improving performance in high-concurrency scenarios. + +## URLTest Fallback 支持 + +按照**可用性**和**顺序**选择出站 + +可用:指 URL 测试存在有效结果 + +配置示例: +``` +{ + "tag": "fallback", + "type": "urltest", + "outbounds": [ + "A", + "B", + "C" + ], + "fallback": { + "enabled": true, // 开启 fallback + "max_delay": "200ms" // 可选配置 + // 若某节点可用,但是延迟超过 max_delay,则认为该节点不可用,淘汰忽略该节点,继续匹配选择下一个节点 + // 但若所有节点均不可用,但是存在被 max_delay 规则淘汰的节点,则选择延迟最低的被淘汰节点 + } +} +``` +以上配置为例子: +1. 当 A, B, C 都可用时,优选选择 A。当 A 不可用时,优选选择 B。当 A, B 都不可用时,选择 C,若 C 也不可用,则返回第一个出站:A +2. (配置了 max_delay) 当 A, C 都不可用,B 延迟超过 200ms 时(在第一轮选择时淘汰,被认为是不可用节点),则选择 B + +For extended features + +- Providers: [中文](./docs/configuration/provider/index.zh.md), [English](./docs/configuration/provider/index.md) + ## License ``` diff --git a/adapter/dns.go b/adapter/dns.go index 8f065e2e82..32a905bb46 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -20,14 +20,18 @@ type DNSRouter interface { Lookup(ctx context.Context, domain string, options DNSQueryOptions) ([]netip.Addr, error) ClearCache() LookupReverseMapping(ip netip.Addr) (string, bool) + Rules() []DNSRule + Rule(uuid string) (DNSRule, bool) ResetNetwork() } type DNSClient interface { Start() - Exchange(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error) - Lookup(ctx context.Context, transport DNSTransport, domain string, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error) + Exchange(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error, bool) + Lookup(ctx context.Context, transport DNSTransport, domain string, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error, bool) ClearCache() + UpdateDnsCacheFromContext(ctx context.Context) bool + UpdateDnsCacheToContext(ctx context.Context) context.Context } type DNSQueryOptions struct { @@ -37,6 +41,7 @@ type DNSQueryOptions struct { DisableCache bool RewriteTTL *uint32 ClientSubnet netip.Prefix + LazyCacheTTL *uint32 } func DNSQueryOptionsFrom(ctx context.Context, options *option.DomainResolveOptions) (*DNSQueryOptions, error) { diff --git a/adapter/experimental.go b/adapter/experimental.go index 1bd8d2d928..e214cc53c4 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -7,6 +7,7 @@ import ( "io" "time" + "github.com/sagernet/sing-box/common/hash" "github.com/sagernet/sing/common/observable" "github.com/sagernet/sing/common/varbin" ) @@ -55,9 +56,14 @@ type CacheFile interface { StoreGroupExpand(group string, expand bool) error LoadRuleSet(tag string) *SavedBinary SaveRuleSet(tag string, set *SavedBinary) error + LoadExternalUI(tag string) *SavedBinary + SaveExternalUI(tag string, info *SavedBinary) error + LoadSubscription(tag string) *SavedBinary + SaveSubscription(tag string, sub *SavedBinary) error } type SavedBinary struct { + Hash hash.HashType Content []byte LastUpdated time.Time LastEtag string @@ -69,6 +75,18 @@ func (s *SavedBinary) MarshalBinary() ([]byte, error) { if err != nil { return nil, err } + hash, err := s.Hash.MarshalBinary() + if err != nil { + return nil, err + } + _, err = varbin.WriteUvarint(&buffer, uint64(len(hash))) + if err != nil { + return nil, err + } + _, err = buffer.Write(hash) + if err != nil { + return nil, err + } _, err = varbin.WriteUvarint(&buffer, uint64(len(s.Content))) if err != nil { return nil, err @@ -99,6 +117,19 @@ func (s *SavedBinary) UnmarshalBinary(data []byte) error { if err != nil { return err } + hashLength, err := binary.ReadUvarint(reader) + if err != nil { + return err + } + hash := make([]byte, hashLength) + _, err = io.ReadFull(reader, hash) + if err != nil { + return err + } + err = s.Hash.UnmarshalBinary(hash) + if err != nil { + return err + } contentLength, err := binary.ReadUvarint(reader) if err != nil { return err @@ -138,9 +169,20 @@ type URLTestGroup interface { URLTest(ctx context.Context) (map[string]uint16, error) } +type LoadBalanceGroup interface { + OutboundGroup + URLTest(ctx context.Context) (map[string]uint16, error) +} + +type SelectorGroup interface { + Selected() Outbound +} + func OutboundTag(detour Outbound) string { if group, isGroup := detour.(OutboundGroup); isGroup { - return group.Now() + if now := group.Now(); now != "" { + return now + } } return detour.Tag() } diff --git a/adapter/inbound.go b/adapter/inbound.go index b32e9f8278..f591145666 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -2,6 +2,7 @@ package adapter import ( "context" + "net" "net/netip" "time" @@ -53,7 +54,7 @@ type InboundContext struct { // sniffer Protocol string - Domain string + SniffHost string Client string SniffContext any SnifferNames []string @@ -61,6 +62,9 @@ type InboundContext struct { // cache + CacheIPs []netip.Addr + Domain string + // Deprecated: implement in rule action InboundDetour string LastInbound string @@ -82,8 +86,11 @@ type InboundContext struct { SourceGeoIPCode string GeoIPCode string ProcessInfo *ConnectionOwner + SourceMACAddress net.HardwareAddr + SourceHostname string QueryType uint16 FakeIP bool + DestOverride bool // rule cache @@ -96,6 +103,32 @@ type InboundContext struct { DestinationPortMatch bool DidMatch bool IgnoreDestinationIPCIDRMatch bool + + // extended metadata + Extended *InboundContextExtended +} + +type InboundContextExtended struct { + RealOutboundChain []string +} + +func (c *InboundContext) InitExtended() { + if c.Extended == nil { + c.Extended = new(InboundContextExtended) + } +} + +func (c *InboundContext) AppendRealOutbound(tag string) { + if c.Extended != nil { + c.Extended.RealOutboundChain = append(c.Extended.RealOutboundChain, tag) + } +} + +func (c *InboundContext) GetRealOutboundChain() []string { + if c.Extended != nil { + return c.Extended.RealOutboundChain + } + return nil } func (c *InboundContext) ResetRuleCache() { @@ -111,6 +144,7 @@ func (c *InboundContext) ResetRuleCache() { type inboundContextKey struct{} func WithContext(ctx context.Context, inboundContext *InboundContext) context.Context { + inboundContext.InitExtended() return context.WithValue(ctx, (*inboundContextKey)(nil), inboundContext) } diff --git a/adapter/neighbor.go b/adapter/neighbor.go new file mode 100644 index 0000000000..d917db5b7a --- /dev/null +++ b/adapter/neighbor.go @@ -0,0 +1,23 @@ +package adapter + +import ( + "net" + "net/netip" +) + +type NeighborEntry struct { + Address netip.Addr + MACAddress net.HardwareAddr + Hostname string +} + +type NeighborResolver interface { + LookupMAC(address netip.Addr) (net.HardwareAddr, bool) + LookupHostname(address netip.Addr) (string, bool) + Start() error + Close() error +} + +type NeighborUpdateListener interface { + UpdateNeighborTable(entries []NeighborEntry) +} diff --git a/adapter/platform.go b/adapter/platform.go index 95db93c646..12ab82a219 100644 --- a/adapter/platform.go +++ b/adapter/platform.go @@ -36,6 +36,10 @@ type PlatformInterface interface { UsePlatformNotification() bool SendNotification(notification *Notification) error + + UsePlatformNeighborResolver() bool + StartNeighborMonitor(listener NeighborUpdateListener) error + CloseNeighborMonitor(listener NeighborUpdateListener) error } type FindConnectionOwnerRequest struct { diff --git a/adapter/provider.go b/adapter/provider.go new file mode 100644 index 0000000000..0bb8886067 --- /dev/null +++ b/adapter/provider.go @@ -0,0 +1,51 @@ +package adapter + +import ( + "context" + "time" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/x/list" +) + +type Provider interface { + Type() string + Tag() string + Outbounds() []Outbound + Outbound(tag string) (Outbound, bool) + UpdatedAt() time.Time + HealthCheck(ctx context.Context) (map[string]uint16, error) + RegisterCallback(callback ProviderUpdateCallback) *list.Element[ProviderUpdateCallback] + UnregisterCallback(element *list.Element[ProviderUpdateCallback]) +} + +type ProviderUpdater interface { + Update() error +} + +type ProviderSubscriptionInfo interface { + SubscriptionInfo() SubscriptionInfo +} + +type ProviderRegistry interface { + option.ProviderOptionsRegistry + CreateProvider(ctx context.Context, router Router, logFactory log.Factory, tag string, providerType string, options any) (Provider, error) +} + +type ProviderManager interface { + Lifecycle + Providers() []Provider + Get(tag string) (Provider, bool) + Remove(tag string) error + Create(ctx context.Context, router Router, logFactory log.Factory, tag string, providerType string, options any) error +} + +type SubscriptionInfo struct { + Upload int64 + Download int64 + Total int64 + Expire int64 +} + +type ProviderUpdateCallback = func(tag string) error diff --git a/adapter/provider/adapter.go b/adapter/provider/adapter.go new file mode 100644 index 0000000000..a3a96e7847 --- /dev/null +++ b/adapter/provider/adapter.go @@ -0,0 +1,285 @@ +package provider + +import ( + "context" + "reflect" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/urltest" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/batch" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" +) + +type Adapter struct { + ctx context.Context + outbound adapter.OutboundManager + router adapter.Router + logFactory log.Factory + logger log.ContextLogger + providerType string + providerTag string + outbounds []adapter.Outbound + outboundsByTag map[string]adapter.Outbound + ticker *time.Ticker + checking atomic.Bool + history adapter.URLTestHistoryStorage + callbackAccess sync.Mutex + callbacks list.List[adapter.ProviderUpdateCallback] + + link string + enabled bool + timeout time.Duration + interval time.Duration +} + +func NewAdapter(ctx context.Context, router adapter.Router, outbound adapter.OutboundManager, logFactory log.Factory, logger log.ContextLogger, providerTag string, providerType string, options option.ProviderHealthCheckOptions) Adapter { + timeout := time.Duration(options.Timeout) + if timeout == 0 { + timeout = 3 * time.Second + } + interval := time.Duration(options.Interval) + if interval == 0 { + interval = 10 * time.Minute + } + if interval < time.Minute { + interval = time.Minute + } + return Adapter{ + ctx: ctx, + outbound: outbound, + router: router, + logFactory: logFactory, + logger: logger, + providerType: providerType, + providerTag: providerTag, + + enabled: options.Enabled, + link: options.URL, + timeout: timeout, + interval: interval, + } +} + +func (a *Adapter) Start() error { + a.history = service.FromContext[adapter.URLTestHistoryStorage](a.ctx) + if a.history == nil { + if clashServer := service.FromContext[adapter.ClashServer](a.ctx); clashServer != nil { + a.history = clashServer.HistoryStorage() + } else { + a.history = urltest.NewHistoryStorage() + } + } + go a.loopCheck() + return nil +} + +func (a *Adapter) Type() string { + return a.providerType +} + +func (a *Adapter) Tag() string { + return a.providerTag +} + +func (a *Adapter) Outbounds() []adapter.Outbound { + return a.outbounds +} + +func (a *Adapter) Outbound(tag string) (adapter.Outbound, bool) { + if a.outboundsByTag == nil { + return nil, false + } + detour, ok := a.outboundsByTag[tag] + return detour, ok +} + +func (a *Adapter) UpdateOutbounds(oldOpts []option.Outbound, newOpts []option.Outbound) { + a.removeUseless(newOpts) + var ( + oldOptByTag = make(map[string]option.Outbound) + outbounds = make([]adapter.Outbound, 0, len(newOpts)) + outboundsByTag = make(map[string]adapter.Outbound) + ) + for _, opt := range oldOpts { + oldOptByTag[opt.Tag] = opt + } + for i, opt := range newOpts { + var tag string + if opt.Tag != "" { + tag = F.ToString(a.providerTag, "/", opt.Tag) + } else { + tag = F.ToString(a.providerTag, "/", i) + } + outbound, exist := a.outbound.Outbound(tag) + if !exist || !reflect.DeepEqual(opt, oldOptByTag[opt.Tag]) { + err := a.outbound.Create( + adapter.WithContext(a.ctx, &adapter.InboundContext{ + Outbound: tag, + }), + a.router, + a.logFactory.NewLogger(F.ToString("outbound/", opt.Type, "[", tag, "]")), + tag, + opt.Type, + opt.Options, + ) + if err != nil { + a.logger.Warn(err, " in ", tag, ", skip create this outbound") + continue + } + outbound, _ = a.outbound.Outbound(tag) + } + outbounds = append(outbounds, outbound) + outboundsByTag[tag] = outbound + } + if a.enabled && a.history != nil { + go a.HealthCheck(a.ctx) + } + a.outbounds = outbounds + a.outboundsByTag = outboundsByTag +} + +func (a *Adapter) HealthCheck(ctx context.Context) (map[string]uint16, error) { + if a.ticker != nil { + a.ticker.Reset(a.interval) + } + return a.healthcheck(ctx) +} + +func (a *Adapter) RegisterCallback(callback adapter.ProviderUpdateCallback) *list.Element[adapter.ProviderUpdateCallback] { + a.callbackAccess.Lock() + defer a.callbackAccess.Unlock() + return a.callbacks.PushBack(callback) +} + +func (a *Adapter) UnregisterCallback(element *list.Element[adapter.ProviderUpdateCallback]) { + a.callbackAccess.Lock() + defer a.callbackAccess.Unlock() + a.callbacks.Remove(element) +} + +func (a *Adapter) UpdateGroups() { + for element := a.callbacks.Front(); element != nil; element = element.Next() { + element.Value(a.providerTag) + } +} + +func (a *Adapter) Close() error { + if a.ticker != nil { + a.ticker.Stop() + } + outbounds := a.outbounds + a.outbounds = nil + var err error + for _, ob := range outbounds { + if err2 := a.outbound.Remove(ob.Tag()); err2 != nil { + err = E.Append(err, err2, func(err error) error { + return E.Cause(err, "close outbound [", ob.Tag(), "]") + }) + } + } + return err +} + +func (a *Adapter) loopCheck() { + if !a.enabled { + return + } + a.ticker = time.NewTicker(a.interval) + a.healthcheck(a.ctx) + for { + select { + case <-a.ctx.Done(): + return + case <-a.ticker.C: + a.healthcheck(a.ctx) + } + } +} + +func (a *Adapter) healthcheck(ctx context.Context) (map[string]uint16, error) { + result := make(map[string]uint16) + if a.checking.Swap(true) { + return result, nil + } + defer a.checking.Store(false) + b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10)) + var resultAccess sync.Mutex + checked := make(map[string]bool) + for _, detour := range a.outbounds { + tag := detour.Tag() + if checked[tag] { + continue + } + checked[tag] = true + b.Go(tag, func() (any, error) { + ctx, cancel := context.WithTimeout(a.ctx, a.timeout) + defer cancel() + t, err := urltest.URLTest(ctx, a.link, detour) + if err != nil { + a.logger.Debug("outbound ", tag, " unavailable: ", err) + a.history.DeleteURLTestHistory(tag) + } else { + a.logger.Debug("outbound ", tag, " available: ", t, "ms") + a.history.StoreURLTestHistory(tag, &adapter.URLTestHistory{ + Time: time.Now(), + Delay: t, + }) + resultAccess.Lock() + result[tag] = t + resultAccess.Unlock() + } + return nil, nil + }) + } + b.Wait() + return result, nil +} + +func (a *Adapter) RewriteDetourForProvider(opts []option.Outbound) { + tagMapping := make(map[string]string) + for _, opt := range opts { + if opt.Tag != "" { + tagMapping[opt.Tag] = F.ToString(a.providerTag, "/", opt.Tag) + } + } + for _, opt := range opts { + if dialerWrapper, ok := opt.Options.(option.DialerOptionsWrapper); ok { + dialerOptions := dialerWrapper.TakeDialerOptions() + if newDetour, found := tagMapping[dialerOptions.Detour]; found { + dialerOptions.Detour = newDetour + dialerWrapper.ReplaceDialerOptions(dialerOptions) + } + } + } +} + +func (a *Adapter) removeUseless(newOpts []option.Outbound) { + if len(a.outbounds) == 0 { + return + } + exists := make(map[string]bool) + for i, opt := range newOpts { + var tag string + if opt.Tag != "" { + tag = F.ToString(a.providerTag, "/", opt.Tag) + } else { + tag = F.ToString(a.providerTag, "/", i) + } + exists[tag] = true + } + for _, opt := range a.outbounds { + if !exists[opt.Tag()] { + if err := a.outbound.Remove(opt.Tag()); err != nil { + a.logger.Error(err, "close outbound [", opt.Tag(), "]") + } + } + } +} diff --git a/adapter/provider/manager.go b/adapter/provider/manager.go new file mode 100644 index 0000000000..549f771040 --- /dev/null +++ b/adapter/provider/manager.go @@ -0,0 +1,170 @@ +package provider + +import ( + "context" + "io" + "os" + "sync" + + "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" + "github.com/sagernet/sing/common/logger" +) + +var _ adapter.ProviderManager = (*Manager)(nil) + +type Manager struct { + logger log.ContextLogger + registry adapter.ProviderRegistry + access sync.Mutex + started bool + stage adapter.StartStage + providers []adapter.Provider + providerByTag map[string]adapter.Provider + wg sync.WaitGroup +} + +func NewManager(logger logger.ContextLogger, registry adapter.ProviderRegistry) *Manager { + return &Manager{ + logger: logger, + registry: registry, + providerByTag: make(map[string]adapter.Provider), + } +} + +func (m *Manager) Initialize() { +} + +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() + if stage == adapter.StartStateStart && len(providers) > 0 { + startContext := adapter.NewHTTPStartContext(context.Background()) + defer startContext.Close() + for _, provider := range providers { + if contextStarter, ok := provider.(interface { + StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error + }); ok { + err := contextStarter.StartContext(context.Background(), startContext) + if err != nil { + return E.Cause(err, stage, " provider/", provider.Type(), "[", provider.Tag(), "]") + } + } + } + return nil + } + return nil +} + +func (m *Manager) Close() error { + monitor := taskmonitor.New(m.logger, C.StopTimeout) + m.access.Lock() + if !m.started { + m.access.Unlock() + return nil + } + m.started = false + providers := m.providers + m.providers = nil + m.access.Unlock() + var err error + for _, provider := range providers { + if closer, isCloser := provider.(io.Closer); isCloser { + monitor.Start("close provider/", provider.Type(), "[", provider.Tag(), "]") + err = E.Append(err, closer.Close(), func(err error) error { + return E.Cause(err, "close provider/", provider.Type(), "[", provider.Tag(), "]") + }) + monitor.Finish() + } + } + return nil +} + +func (m *Manager) Providers() []adapter.Provider { + m.access.Lock() + defer m.access.Unlock() + return m.providers +} + +func (m *Manager) Get(tag string) (adapter.Provider, 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.Provider) bool { + return it == provider + }) + if index == -1 { + panic("invalid provider index") + } + m.providers = append(m.providers[:index], m.providers[index+1:]...) + started := m.started + m.access.Unlock() + if started { + return common.Close(provider) + } + return nil +} + +func (m *Manager) Create(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, providerType string, options any) error { + if tag == "" { + return os.ErrInvalid + } + + provider, err := m.registry.CreateProvider(ctx, router, logFactory, tag, providerType, options) + if err != nil { + return err + } + m.access.Lock() + defer m.access.Unlock() + if m.started { + if m.stage >= adapter.StartStateStart { + if contextStarter, ok := provider.(interface { + StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error + }); ok { + err = contextStarter.StartContext(context.Background(), nil) + if err != nil { + return E.Cause(err, "start provider/", provider.Type(), "[", provider.Tag(), "]") + } + } + } + } + if existsProvider, loaded := m.providerByTag[tag]; loaded { + if m.started { + err = common.Close(existsProvider) + if err != nil { + return E.Cause(err, "close provider", provider.Type(), "[", existsProvider.Tag(), "]") + } + } + existsIndex := common.Index(m.providers, func(it adapter.Provider) bool { + return it == existsProvider + }) + if existsIndex == -1 { + panic("invalid 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/provider/registry.go b/adapter/provider/registry.go new file mode 100644 index 0000000000..5a48475452 --- /dev/null +++ b/adapter/provider/registry.go @@ -0,0 +1,72 @@ +package provider + +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, router adapter.Router, logFactory log.Factory, tag string, options T) (adapter.Provider, error) + +func Register[Options any](registry *Registry, providerType string, constructor ConstructorFunc[Options]) { + registry.register(providerType, func() any { + return new(Options) + }, func(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, rawOptions any) (adapter.Provider, error) { + var options *Options + if rawOptions != nil { + options = rawOptions.(*Options) + } + return constructor(ctx, router, logFactory, tag, common.PtrValueOrDefault(options)) + }) +} + +var _ adapter.ProviderRegistry = (*Registry)(nil) + +type ( + optionsConstructorFunc func() any + constructorFunc func(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options any) (adapter.Provider, error) +) + +type Registry struct { + access sync.Mutex + optionsType map[string]optionsConstructorFunc + constructors map[string]constructorFunc +} + +func NewRegistry() *Registry { + return &Registry{ + optionsType: make(map[string]optionsConstructorFunc), + constructors: make(map[string]constructorFunc), + } +} + +func (r *Registry) CreateOptions(providerType string) (any, bool) { + r.access.Lock() + defer r.access.Unlock() + optionsConstructor, loaded := r.optionsType[providerType] + if !loaded { + return nil, false + } + return optionsConstructor(), true +} + +func (r *Registry) CreateProvider(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, providerType string, options any) (adapter.Provider, error) { + r.access.Lock() + defer r.access.Unlock() + constructor, loaded := r.constructors[providerType] + if !loaded { + return nil, E.New("provider type not found: '" + providerType + "'") + } + return constructor(ctx, router, logFactory, tag, options) +} + +func (r *Registry) register(providerType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) { + r.access.Lock() + defer r.access.Unlock() + r.optionsType[providerType] = optionsConstructor + r.constructors[providerType] = constructor +} diff --git a/adapter/router.go b/adapter/router.go index 3d5310c4ee..1c3e52db65 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -9,7 +9,7 @@ import ( "time" C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ntp" @@ -23,11 +23,18 @@ type Router interface { ConnectionRouter PreMatch(metadata InboundContext, context tun.DirectRouteContext, timeout time.Duration, supportBypass bool) (tun.DirectRouteDestination, error) ConnectionRouterEx + RuleSets() []RuleSet RuleSet(tag string) (RuleSet, bool) Rules() []Rule NeedFindProcess() bool + Rule(uuid string) (Rule, bool) + NeedFindNeighbor() bool + NeighborResolver() NeighborResolver AppendTracker(tracker ConnectionTracker) ResetNetwork() + DefaultDomainMatchStrategy() C.DomainMatchStrategy + + Reload() } type ConnectionTracker interface { @@ -49,6 +56,10 @@ type ConnectionRouterEx interface { type RuleSet interface { Name() string + Type() string + Format() string + UpdatedTime() time.Time + Update(ctx context.Context) error StartContext(ctx context.Context, startContext *HTTPStartContext) error PostStart() error Metadata() RuleSetMetadata diff --git a/adapter/rule.go b/adapter/rule.go index f8ee797d44..963e91eed8 100644 --- a/adapter/rule.go +++ b/adapter/rule.go @@ -6,12 +6,16 @@ import ( type HeadlessRule interface { Match(metadata *InboundContext) bool + RuleCount() uint64 String() string } type Rule interface { HeadlessRule SimpleLifecycle + Disabled() bool + UUID() string + ChangeStatus() Type() string Action() RuleAction } diff --git a/box.go b/box.go index fe116b3175..e291bcb94b 100644 --- a/box.go +++ b/box.go @@ -12,6 +12,7 @@ import ( "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/provider" boxService "github.com/sagernet/sing-box/adapter/service" "github.com/sagernet/sing-box/common/certificate" "github.com/sagernet/sing-box/common/dialer" @@ -44,12 +45,14 @@ type Box struct { endpoint *endpoint.Manager inbound *inbound.Manager outbound *outbound.Manager + provider *provider.Manager service *boxService.Manager dnsTransport *dns.TransportManager dnsRouter *dns.Router connection *route.ConnectionManager router *route.Router internalService []adapter.LifecycleService + reloadChan chan struct{} done chan struct{} } @@ -62,6 +65,7 @@ type Options struct { func Context( ctx context.Context, inboundRegistry adapter.InboundRegistry, + providerRegistry adapter.ProviderRegistry, outboundRegistry adapter.OutboundRegistry, endpointRegistry adapter.EndpointRegistry, dnsTransportRegistry adapter.DNSTransportRegistry, @@ -72,6 +76,11 @@ func Context( ctx = service.ContextWith[option.InboundOptionsRegistry](ctx, inboundRegistry) ctx = service.ContextWith[adapter.InboundRegistry](ctx, inboundRegistry) } + if service.FromContext[option.ProviderOptionsRegistry](ctx) == nil || + service.FromContext[adapter.ProviderRegistry](ctx) == nil { + ctx = service.ContextWith[option.ProviderOptionsRegistry](ctx, providerRegistry) + ctx = service.ContextWith[adapter.ProviderRegistry](ctx, providerRegistry) + } if service.FromContext[option.OutboundOptionsRegistry](ctx) == nil || service.FromContext[adapter.OutboundRegistry](ctx) == nil { ctx = service.ContextWith[option.OutboundOptionsRegistry](ctx, outboundRegistry) @@ -95,6 +104,7 @@ func Context( func New(options Options) (*Box, error) { createdAt := time.Now() + reloadChan := make(chan struct{}, 1) ctx := options.Context if ctx == nil { ctx = context.Background() @@ -104,6 +114,7 @@ func New(options Options) (*Box, error) { endpointRegistry := service.FromContext[adapter.EndpointRegistry](ctx) inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) + providerRegistry := service.FromContext[adapter.ProviderRegistry](ctx) dnsTransportRegistry := service.FromContext[adapter.DNSTransportRegistry](ctx) serviceRegistry := service.FromContext[adapter.ServiceRegistry](ctx) @@ -116,6 +127,9 @@ func New(options Options) (*Box, error) { if outboundRegistry == nil { return nil, E.New("missing outbound registry in context") } + if providerRegistry == nil { + return nil, E.New("missing provider registry in context") + } if dnsTransportRegistry == nil { return nil, E.New("missing DNS transport registry in context") } @@ -158,6 +172,8 @@ func New(options Options) (*Box, error) { return nil, E.Cause(err, "create log factory") } + C.URLTestUnifiedDelay = experimentalOptions.URLTestUnifiedDelay + var internalServices []adapter.LifecycleService certificateOptions := common.PtrValueOrDefault(options.Certificate) if C.IsAndroid || certificateOptions.Store != "" && certificateOptions.Store != C.CertificateStoreSystem || @@ -177,11 +193,13 @@ func New(options Options) (*Box, error) { endpointManager := endpoint.NewManager(logFactory.NewLogger("endpoint"), endpointRegistry) inboundManager := inbound.NewManager(logFactory.NewLogger("inbound"), inboundRegistry, endpointManager) outboundManager := outbound.NewManager(logFactory.NewLogger("outbound"), outboundRegistry, endpointManager, routeOptions.Final) + providerManager := provider.NewManager(logFactory.NewLogger("provider"), providerRegistry) dnsTransportManager := dns.NewTransportManager(logFactory.NewLogger("dns/transport"), dnsTransportRegistry, outboundManager, dnsOptions.Final) serviceManager := boxService.NewManager(logFactory.NewLogger("service"), serviceRegistry) service.MustRegister[adapter.EndpointManager](ctx, endpointManager) service.MustRegister[adapter.InboundManager](ctx, inboundManager) service.MustRegister[adapter.OutboundManager](ctx, outboundManager) + service.MustRegister[adapter.ProviderManager](ctx, providerManager) service.MustRegister[adapter.DNSTransportManager](ctx, dnsTransportManager) service.MustRegister[adapter.ServiceManager](ctx, serviceManager) dnsRouter := dns.NewRouter(ctx, logFactory, dnsOptions) @@ -193,7 +211,7 @@ func New(options Options) (*Box, error) { service.MustRegister[adapter.NetworkManager](ctx, networkManager) connectionManager := route.NewConnectionManager(logFactory.NewLogger("connection")) service.MustRegister[adapter.ConnectionManager](ctx, connectionManager) - router := route.NewRouter(ctx, logFactory, routeOptions, dnsOptions) + router := route.NewRouter(ctx, logFactory, routeOptions, dnsOptions, reloadChan) service.MustRegister[adapter.Router](ctx, router) err = router.Initialize(routeOptions.Rules, routeOptions.RuleSet) if err != nil { @@ -272,6 +290,10 @@ func New(options Options) (*Box, error) { return nil, E.Cause(err, "initialize inbound[", i, "]") } } + options.Outbounds = append(options.Outbounds, option.Outbound{ + Tag: "Compatible", + Type: C.TypeDirect, + }) for i, outboundOptions := range options.Outbounds { var tag string if outboundOptions.Tag != "" { @@ -298,6 +320,25 @@ func New(options Options) (*Box, error) { return nil, E.Cause(err, "initialize outbound[", i, "]") } } + for i, providerOptions := range options.Providers { + var tag string + if providerOptions.Tag != "" { + tag = providerOptions.Tag + } else { + tag = F.ToString(i) + } + err = providerManager.Create( + ctx, + router, + logFactory, + tag, + providerOptions.Type, + providerOptions.Options, + ) + if err != nil { + return nil, E.Cause(err, "initialize provider[", i, "]") + } + } for i, serviceOptions := range options.Services { var tag string if serviceOptions.Tag != "" { @@ -387,6 +428,7 @@ func New(options Options) (*Box, error) { endpoint: endpointManager, inbound: inboundManager, outbound: outboundManager, + provider: providerManager, dnsTransport: dnsTransportManager, service: serviceManager, dnsRouter: dnsRouter, @@ -396,6 +438,7 @@ func New(options Options) (*Box, error) { logFactory: logFactory, logger: logFactory.Logger(), internalService: internalServices, + reloadChan: reloadChan, done: make(chan struct{}), }, nil } @@ -450,11 +493,11 @@ 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.provider, s.inbound, s.endpoint, s.service) if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router) + err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.dnsRouter, s.provider, s.network, s.connection, s.router) if err != nil { return err } @@ -474,7 +517,7 @@ func (s *Box) start() error { 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.StartStatePostStart, s.outbound, s.provider, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.inbound, s.endpoint, s.service) if err != nil { return err } @@ -482,7 +525,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.provider, s.inbound, s.endpoint, s.service) if err != nil { return err } @@ -508,6 +551,7 @@ func (s *Box) Close() error { {"service", s.service}, {"endpoint", s.endpoint}, {"inbound", s.inbound}, + {"provider", s.provider}, {"outbound", s.outbound}, {"router", s.router}, {"connection", s.connection}, @@ -558,3 +602,7 @@ func (s *Box) Outbound() adapter.OutboundManager { func (s *Box) LogFactory() log.Factory { return s.logFactory } + +func (s *Box) ReloadChan() <-chan struct{} { + return s.reloadChan +} diff --git a/cmd/sing-box/cmd_generate_pinsha256.go b/cmd/sing-box/cmd_generate_pinsha256.go new file mode 100644 index 0000000000..64b557260a --- /dev/null +++ b/cmd/sing-box/cmd_generate_pinsha256.go @@ -0,0 +1,49 @@ +package main + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "os" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandGeneratePinSHA256 = &cobra.Command{ + // openssl x509 -noout -fingerprint -sha256 -in certificate.crt + Use: "pinsha256 certificate.crt", + Short: "Generate SHA256 fingerprint for a certificate file", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + err := generatePinSHA256(args) + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandGenerate.AddCommand(commandGeneratePinSHA256) +} + +func generatePinSHA256(args []string) error { + file, err := os.ReadFile(args[0]) + if err != nil { + return err + } + block, _ := pem.Decode(file) + if block == nil { + return E.New("pem decode error") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return err + } + hash := sha256.Sum256(cert.Raw) + os.Stdout.WriteString("SHA256 fingerprint: " + hex.EncodeToString(hash[:]) + "\n") + return nil +} diff --git a/cmd/sing-box/cmd_run.go b/cmd/sing-box/cmd_run.go index f31db9dc82..1288e051e4 100644 --- a/cmd/sing-box/cmd_run.go +++ b/cmd/sing-box/cmd_run.go @@ -177,20 +177,31 @@ func run() error { } runtimeDebug.FreeOSMemory() for { - osSignal := <-osSignals - if osSignal == syscall.SIGHUP { + reloadTag := false + select { + case osSignal := <-osSignals: + if osSignal == syscall.SIGHUP { + err = check() + if err != nil { + log.Error(E.Cause(err, "reload service")) + continue + } + reloadTag = true + } + case <-instance.ReloadChan(): err = check() if err != nil { log.Error(E.Cause(err, "reload service")) continue } + reloadTag = true } cancel() closeCtx, closed := context.WithCancel(context.Background()) go closeMonitor(closeCtx) err = instance.Close() closed() - if osSignal != syscall.SIGHUP { + if !reloadTag { if err != nil { log.Error(E.Cause(err, "sing-box did not closed properly")) } diff --git a/common/dialer/default.go b/common/dialer/default.go index 6b2379f4d4..6c6ead3544 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -158,10 +158,15 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial if keepInterval == 0 { keepInterval = C.TCPKeepAliveInterval } + keepCount := options.TCPKeepAliveCount + if keepCount < 0 { + keepCount = 0 + } dialer.KeepAliveConfig = net.KeepAliveConfig{ Enable: true, Idle: keepIdle, Interval: keepInterval, + Count: keepCount, } } var udpFragment bool diff --git a/common/expiringpool/pool.go b/common/expiringpool/pool.go new file mode 100644 index 0000000000..5598ed14e6 --- /dev/null +++ b/common/expiringpool/pool.go @@ -0,0 +1,149 @@ +package expiringpool + +import ( + "container/heap" + "context" + "sync" + "time" + + "github.com/sagernet/sing/common" +) + +type item[T any] struct { + value T + expiresAt time.Time + index int // heap index +} + +type minHeap[T any] []*item[T] + +func (h minHeap[T]) Len() int { + return len(h) +} + +func (h minHeap[T]) Less(i, j int) bool { + return h[i].expiresAt.Before(h[j].expiresAt) +} + +func (h minHeap[T]) Swap(i, j int) { + h[i], h[j] = h[j], h[i] + h[i].index, h[j].index = i, j +} + +func (h *minHeap[T]) Push(x any) { + it := x.(*item[T]) + it.index = len(*h) + *h = append(*h, it) +} + +func (h *minHeap[T]) Pop() any { + old := *h + n := len(old) + it := old[n-1] + it.index = -1 + *h = old[0 : n-1] + return it +} + +type ExpiringPool[T comparable] struct { + ctx context.Context + onClean func(T) + expire time.Duration + + access sync.Mutex + heap minHeap[T] + items map[T]*item[T] + + cancel context.CancelFunc +} + +func New[T comparable](ctx context.Context, expire time.Duration, onClean func(T)) *ExpiringPool[T] { + ctx, cancel := context.WithCancel(ctx) + return &ExpiringPool[T]{ + ctx: ctx, + onClean: onClean, + expire: expire, + items: make(map[T]*item[T]), + cancel: cancel, + } +} + +func (e *ExpiringPool[T]) Start() { + go e.cleanLoop() +} + +func (e *ExpiringPool[T]) cleanLoop() { + for { + e.access.Lock() + if len(e.heap) == 0 { + e.access.Unlock() + select { + case <-e.ctx.Done(): + return + case <-time.After(e.expire): + continue + } + } + + next := e.heap[0] + now := time.Now() + wait := next.expiresAt.Sub(now) + e.access.Unlock() + + if wait > 0 { + select { + case <-e.ctx.Done(): + return + case <-time.After(wait): + continue + } + } + + e.access.Lock() + // re-check + if len(e.heap) == 0 || !e.heap[0].expiresAt.Before(time.Now()) { + e.access.Unlock() + continue + } + it := heap.Pop(&e.heap).(*item[T]) + delete(e.items, it.value) + e.access.Unlock() + + e.onClean(it.value) + } +} + +func (e *ExpiringPool[T]) Get() T { + e.access.Lock() + defer e.access.Unlock() + if len(e.heap) <= 0 { + return common.DefaultValue[T]() + } + // take oldest + it := heap.Pop(&e.heap).(*item[T]) + delete(e.items, it.value) + return it.value +} + +func (e *ExpiringPool[T]) Put(value T) { + e.access.Lock() + defer e.access.Unlock() + expiresAt := time.Now().Add(e.expire) + it := &item[T]{value: value, expiresAt: expiresAt} + heap.Push(&e.heap, it) + e.items[value] = it +} + +func (e *ExpiringPool[T]) Close() { + e.access.Lock() + defer e.access.Unlock() + if e.cancel != nil { + e.cancel() + e.cancel = nil + } + // clean remaining + for len(e.heap) > 0 { + it := heap.Pop(&e.heap).(*item[T]) + e.onClean(it.value) + } +} diff --git a/common/hash/hash.go b/common/hash/hash.go new file mode 100644 index 0000000000..b5f7744e9a --- /dev/null +++ b/common/hash/hash.go @@ -0,0 +1,62 @@ +package hash + +import ( + "crypto/md5" + "encoding/hex" + "errors" +) + +// HashType warps hash array inside struct +// someday can change to other hash algorithm simply +type HashType struct { + md5 [md5.Size]byte // MD5 +} + +func MakeHash(data []byte) HashType { + return HashType{md5.Sum(data)} +} + +func (h HashType) Equal(hash HashType) bool { + return h.md5 == hash.md5 +} + +func (h HashType) Bytes() []byte { + return h.md5[:] +} + +func (h HashType) String() string { + return hex.EncodeToString(h.Bytes()) +} + +func (h HashType) MarshalText() ([]byte, error) { + return []byte(h.String()), nil +} + +func (h *HashType) UnmarshalText(data []byte) error { + if hex.DecodedLen(len(data)) != md5.Size { + return errors.New("invalid hash length") + } + _, err := hex.Decode(h.md5[:], data) + return err +} + +func (h HashType) MarshalBinary() ([]byte, error) { + return h.md5[:], nil +} + +func (h *HashType) UnmarshalBinary(data []byte) error { + if len(data) != md5.Size { + return errors.New("invalid hash length") + } + copy(h.md5[:], data) + return nil +} + +func (h HashType) Len() int { + return len(h.md5) +} + +func (h HashType) IsValid() bool { + var zero HashType + return h != zero +} diff --git a/common/interrupt/context.go b/common/interrupt/context.go index 44726b2d2b..ba91601aea 100644 --- a/common/interrupt/context.go +++ b/common/interrupt/context.go @@ -11,3 +11,13 @@ func ContextWithIsExternalConnection(ctx context.Context) context.Context { func IsExternalConnectionFromContext(ctx context.Context) bool { return ctx.Value(contextKeyIsExternalConnection{}) != nil } + +type contextKeyIsProviderConnection struct{} + +func ContextWithIsProviderConnection(ctx context.Context) context.Context { + return context.WithValue(ctx, contextKeyIsProviderConnection{}, true) +} + +func IsProviderConnectionFromContext(ctx context.Context) bool { + return ctx.Value(contextKeyIsProviderConnection{}) != nil +} diff --git a/common/interrupt/group.go b/common/interrupt/group.go index ba2e7f739b..af95ed0239 100644 --- a/common/interrupt/group.go +++ b/common/interrupt/group.go @@ -16,23 +16,24 @@ type Group struct { type groupConnItem struct { conn io.Closer isExternal bool + isProvider bool } func NewGroup() *Group { return &Group{} } -func (g *Group) NewConn(conn net.Conn, isExternal bool) net.Conn { +func (g *Group) NewConn(conn net.Conn, isExternal, isProvider bool) net.Conn { g.access.Lock() defer g.access.Unlock() - item := g.connections.PushBack(&groupConnItem{conn, isExternal}) + item := g.connections.PushBack(&groupConnItem{conn, isExternal, isProvider}) return &Conn{Conn: conn, group: g, element: item} } -func (g *Group) NewPacketConn(conn net.PacketConn, isExternal bool) net.PacketConn { +func (g *Group) NewPacketConn(conn net.PacketConn, isExternal, isProvider bool) net.PacketConn { g.access.Lock() defer g.access.Unlock() - item := g.connections.PushBack(&groupConnItem{conn, isExternal}) + item := g.connections.PushBack(&groupConnItem{conn, isExternal, isProvider}) return &PacketConn{PacketConn: conn, group: g, element: item} } @@ -41,7 +42,7 @@ func (g *Group) Interrupt(interruptExternalConnections bool) { defer g.access.Unlock() var toDelete []*list.Element[*groupConnItem] for element := g.connections.Front(); element != nil; element = element.Next() { - if !element.Value.isExternal || interruptExternalConnections { + if !element.Value.isProvider && !element.Value.isExternal || interruptExternalConnections { element.Value.conn.Close() toDelete = append(toDelete, element) } diff --git a/common/listener/listener_tcp.go b/common/listener/listener_tcp.go index 899d444fea..b245438543 100644 --- a/common/listener/listener_tcp.go +++ b/common/listener/listener_tcp.go @@ -8,6 +8,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/proxyproto" "github.com/sagernet/sing-box/common/redir" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" @@ -21,10 +22,6 @@ import ( ) func (l *Listener) ListenTCP() (net.Listener, error) { - //nolint:staticcheck - if l.listenOptions.ProxyProtocol || l.listenOptions.ProxyProtocolAcceptNoHeader { - return nil, E.New("Proxy Protocol is deprecated and removed in sing-box 1.6.0") - } var err error bindAddr := M.SocksaddrFrom(l.listenOptions.Listen.Build(netip.AddrFrom4([4]byte{127, 0, 0, 1})), l.listenOptions.ListenPort) var listenConfig net.ListenConfig @@ -46,10 +43,15 @@ func (l *Listener) ListenTCP() (net.Listener, error) { if keepInterval == 0 { keepInterval = C.TCPKeepAliveInterval } + keepCount := l.listenOptions.TCPKeepAliveCount + if keepCount < 0 { + keepCount = 0 + } listenConfig.KeepAliveConfig = net.KeepAliveConfig{ Enable: true, Idle: keepIdle, Interval: keepInterval, + Count: keepCount, } } if l.listenOptions.TCPMultiPath { @@ -74,6 +76,9 @@ func (l *Listener) ListenTCP() (net.Listener, error) { if err != nil { return nil, err } + if l.listenOptions.ProxyProtocol { + tcpListener = &proxyproto.Listener{Listener: tcpListener, AcceptNoHeader: l.listenOptions.ProxyProtocolAcceptNoHeader} + } l.logger.Info("tcp server started at ", tcpListener.Addr()) l.tcpListener = tcpListener return tcpListener, err diff --git a/common/proxyproto/dialer.go b/common/proxyproto/dialer.go new file mode 100644 index 0000000000..f3fba6f4da --- /dev/null +++ b/common/proxyproto/dialer.go @@ -0,0 +1,50 @@ +package proxyproto + +import ( + "context" + "net" + "net/netip" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/pires/go-proxyproto" +) + +var _ N.Dialer = (*Dialer)(nil) + +type Dialer struct { + N.Dialer +} + +func (d *Dialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch N.NetworkName(network) { + case N.NetworkTCP: + conn, err := d.Dialer.DialContext(ctx, network, destination) + if err != nil { + return nil, err + } + var source M.Socksaddr + metadata := adapter.ContextFrom(ctx) + if metadata != nil { + source = metadata.Source + } + if !source.IsValid() { + source = M.SocksaddrFromNet(conn.LocalAddr()) + } + if destination.Addr.Is6() { + source = M.SocksaddrFrom(netip.AddrFrom16(source.Addr.As16()), source.Port) + } + h := proxyproto.HeaderProxyFromAddrs(1, source.TCPAddr(), destination.TCPAddr()) + _, err = h.WriteTo(conn) + if err != nil { + conn.Close() + return nil, E.Cause(err, "write proxy protocol header") + } + return conn, nil + default: + return d.Dialer.DialContext(ctx, network, destination) + } +} diff --git a/common/proxyproto/listener.go b/common/proxyproto/listener.go new file mode 100644 index 0000000000..c70d500121 --- /dev/null +++ b/common/proxyproto/listener.go @@ -0,0 +1,63 @@ +package proxyproto + +import ( + std_bufio "bufio" + "net" + + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + M "github.com/sagernet/sing/common/metadata" + + "github.com/pires/go-proxyproto" +) + +type Listener struct { + net.Listener + AcceptNoHeader bool +} + +func (l *Listener) Accept() (net.Conn, error) { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + bufReader := std_bufio.NewReader(conn) + header, err := proxyproto.Read(bufReader) + if err != nil && !(l.AcceptNoHeader && err == proxyproto.ErrNoProxyProtocol) { + return nil, &Error{err} + } + if bufReader.Buffered() > 0 { + cache := buf.NewSize(bufReader.Buffered()) + _, err = cache.ReadFullFrom(bufReader, cache.FreeLen()) + if err != nil { + return nil, &Error{err} + } + conn = bufio.NewCachedConn(conn, cache) + } + if header != nil { + return &bufio.AddrConn{ + Conn: conn, + Source: M.SocksaddrFromNet(header.SourceAddr).Unwrap(), + Destination: M.SocksaddrFromNet(header.DestinationAddr).Unwrap(), + }, nil + } + return conn, nil +} + +var _ net.Error = (*Error)(nil) + +type Error struct { + error +} + +func (e *Error) Unwrap() error { + return e.error +} + +func (e *Error) Timeout() bool { + return false +} + +func (e *Error) Temporary() bool { + return true +} diff --git a/common/sniff/http.go b/common/sniff/http.go index 012f2c99df..9d3127c22d 100644 --- a/common/sniff/http.go +++ b/common/sniff/http.go @@ -23,6 +23,6 @@ func HTTPHost(_ context.Context, metadata *adapter.InboundContext, reader io.Rea } } metadata.Protocol = C.ProtocolHTTP - metadata.Domain = M.ParseSocksaddr(request.Host).AddrString() + metadata.SniffHost = M.ParseSocksaddr(request.Host).AddrString() return nil } diff --git a/common/sniff/http_test.go b/common/sniff/http_test.go index 9f64efa85e..bfdb821612 100644 --- a/common/sniff/http_test.go +++ b/common/sniff/http_test.go @@ -17,7 +17,7 @@ func TestSniffHTTP1(t *testing.T) { var metadata adapter.InboundContext err := sniff.HTTPHost(context.Background(), &metadata, strings.NewReader(pkt)) require.NoError(t, err) - require.Equal(t, metadata.Domain, "www.google.com") + require.Equal(t, metadata.SniffHost, "www.google.com") } func TestSniffHTTP1WithPort(t *testing.T) { @@ -26,5 +26,5 @@ func TestSniffHTTP1WithPort(t *testing.T) { var metadata adapter.InboundContext err := sniff.HTTPHost(context.Background(), &metadata, strings.NewReader(pkt)) require.NoError(t, err) - require.Equal(t, metadata.Domain, "www.gov.cn") + require.Equal(t, metadata.SniffHost, "www.gov.cn") } diff --git a/common/sniff/quic.go b/common/sniff/quic.go index 049bd2c14e..da466364c9 100644 --- a/common/sniff/quic.go +++ b/common/sniff/quic.go @@ -306,7 +306,7 @@ find: metadata.SniffContext = fragments return E.Cause1(ErrNeedMoreData, err) } - metadata.Domain = fingerprint.ServerName + metadata.SniffHost = fingerprint.ServerName for metadata.Client == "" { if len(frameTypeList) == 1 { metadata.Client = C.ClientFirefox diff --git a/common/sniff/quic_test.go b/common/sniff/quic_test.go index e2f5372472..3e70a4c71d 100644 --- a/common/sniff/quic_test.go +++ b/common/sniff/quic_test.go @@ -29,7 +29,7 @@ func TestSniffQUICChromeNew(t *testing.T) { require.NoError(t, err) err = sniff.QUICClientHello(context.Background(), &metadata, pkt) require.NoError(t, err) - require.Equal(t, "www.google.com", metadata.Domain) + require.Equal(t, "www.google.com", metadata.SniffHost) } func TestSniffQUICChromium(t *testing.T) { @@ -45,7 +45,7 @@ func TestSniffQUICChromium(t *testing.T) { require.NoError(t, err) err = sniff.QUICClientHello(context.Background(), &metadata, pkt) require.NoError(t, err) - require.Equal(t, metadata.Domain, "google.com") + require.Equal(t, metadata.SniffHost, "google.com") } func TestSniffUQUICChrome115(t *testing.T) { @@ -57,7 +57,7 @@ func TestSniffUQUICChrome115(t *testing.T) { require.NoError(t, err) require.Equal(t, metadata.Protocol, C.ProtocolQUIC) require.Equal(t, metadata.Client, C.ClientChromium) - require.Equal(t, metadata.Domain, "www.google.com") + require.Equal(t, metadata.SniffHost, "www.google.com") } func TestSniffQUICFirefox(t *testing.T) { @@ -69,7 +69,7 @@ func TestSniffQUICFirefox(t *testing.T) { require.NoError(t, err) require.Equal(t, metadata.Protocol, C.ProtocolQUIC) require.Equal(t, metadata.Client, C.ClientFirefox) - require.Equal(t, metadata.Domain, "www.google.com") + require.Equal(t, metadata.SniffHost, "www.google.com") } func TestSniffQUICSafari(t *testing.T) { @@ -81,7 +81,7 @@ func TestSniffQUICSafari(t *testing.T) { require.NoError(t, err) require.Equal(t, metadata.Protocol, C.ProtocolQUIC) require.Equal(t, metadata.Client, C.ClientSafari) - require.Equal(t, metadata.Domain, "www.google.com") + require.Equal(t, metadata.SniffHost, "www.google.com") } func FuzzSniffQUIC(f *testing.F) { diff --git a/common/sniff/tls.go b/common/sniff/tls.go index 613086e810..4e1db94b4c 100644 --- a/common/sniff/tls.go +++ b/common/sniff/tls.go @@ -22,7 +22,7 @@ func TLSClientHello(ctx context.Context, metadata *adapter.InboundContext, reade }).HandshakeContext(ctx) if clientHello != nil { metadata.Protocol = C.ProtocolTLS - metadata.Domain = clientHello.ServerName + metadata.SniffHost = clientHello.ServerName return nil } if errors.Is(err, io.ErrUnexpectedEOF) { diff --git a/common/tls/reality_server.go b/common/tls/reality_server.go index 5fc684756b..95a3b497ef 100644 --- a/common/tls/reality_server.go +++ b/common/tls/reality_server.go @@ -26,12 +26,16 @@ import ( var _ ServerConfigCompat = (*RealityServerConfig)(nil) type RealityServerConfig struct { - config *utls.RealityConfig + config *utls.RealityConfig + rejectUnknownSNI bool } func NewRealityServer(ctx context.Context, logger log.ContextLogger, options option.InboundTLSOptions) (ServerConfig, error) { var tlsConfig utls.RealityConfig + if options.ServerName != "" && len(options.ServerNames) > 0 { + return nil, E.New("server_name and server_names cannot be configured at the same time") + } if options.ACME != nil && len(options.ACME.Domain) > 0 { return nil, E.New("acme is unavailable in reality") } @@ -87,7 +91,15 @@ func NewRealityServer(ctx context.Context, logger log.ContextLogger, options opt tlsConfig.Type = N.NetworkTCP tlsConfig.Dest = options.Reality.Handshake.ServerOptions.Build().String() - tlsConfig.ServerNames = map[string]bool{options.ServerName: true} + tlsConfig.ServerNames = make(map[string]bool) + if options.ServerName != "" { + tlsConfig.ServerNames[options.ServerName] = true + } + for _, name := range options.ServerNames { + if name != "" { + tlsConfig.ServerNames[name] = true + } + } privateKey, err := base64.RawURLEncoding.DecodeString(options.Reality.PrivateKey) if err != nil { return nil, E.Cause(err, "decode private key") @@ -126,7 +138,7 @@ func NewRealityServer(ctx context.Context, logger log.ContextLogger, options opt if options.ECH != nil && options.ECH.Enabled { return nil, E.New("Reality is conflict with ECH") } - var config ServerConfig = &RealityServerConfig{&tlsConfig} + var config ServerConfig = &RealityServerConfig{&tlsConfig, options.RejectUnknownSNI} if options.KernelTx || options.KernelRx { if !C.IsLinux { return nil, E.New("kTLS is only supported on Linux") @@ -182,6 +194,18 @@ func (c *RealityServerConfig) ServerHandshake(ctx context.Context, conn net.Conn if err != nil { return nil, err } + if c.rejectUnknownSNI { + sni := tlsConn.ConnectionState().ServerName + if len(c.config.ServerNames) > 0 { + if sni == "" || !c.config.ServerNames[sni] { + _ = tlsConn.Close() + return nil, E.New("unknown or missing server name") + } + } else if sni != "" { + _ = tlsConn.Close() + return nil, E.New("unknown server name: no server names configured") + } + } return &realityConnWrapper{Conn: tlsConn}, nil } diff --git a/common/tls/std_client.go b/common/tls/std_client.go index 1611c83e7c..2b258e9aee 100644 --- a/common/tls/std_client.go +++ b/common/tls/std_client.go @@ -7,13 +7,14 @@ import ( "crypto/tls" "crypto/x509" "encoding/base64" + "encoding/hex" "net" "os" "strings" "time" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/tlsfragment" + tf "github.com/sagernet/sing-box/common/tlsfragment" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" @@ -93,6 +94,53 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres } if options.Insecure { tlsConfig.InsecureSkipVerify = options.Insecure + } else if len(options.CertificatePinSHA256) > 0 { + if len(options.CertificatePublicKeySHA256) > 0 || len(options.Certificate) > 0 || options.CertificatePath != "" { + return nil, E.New("certificate_pin_sha256 is conflict with certificate_public_key_sha256 or certificate or certificate_path") + } + fingerprint := strings.TrimSpace(strings.ReplaceAll(options.CertificatePinSHA256, ":", "")) + fpByte, err := hex.DecodeString(fingerprint) + if err != nil { + return nil, E.Cause(err, "decode fingerprint string") + } + if len(fpByte) != 32 { + return nil, E.New("fingerprint string length error, need sha256 fingerprint") + } + tlsConfig.InsecureSkipVerify = true + tlsConfig.VerifyConnection = func(state tls.ConnectionState) error { + certs := state.PeerCertificates + for i, cert := range certs { + hash := sha256.Sum256(cert.Raw) + if bytes.Equal(fpByte, hash[:]) { + if i > 0 { + opts := x509.VerifyOptions{ + Roots: x509.NewCertPool(), + Intermediates: x509.NewCertPool(), + DNSName: serverName, + } + if tlsConfig.Time != nil { + opts.CurrentTime = tlsConfig.Time() + } + opts.Roots.AddCert(certs[i]) + for _, cert := range certs[1 : i+1] { + opts.Intermediates.AddCert(cert) + } + _, err := certs[0].Verify(opts) + return err + } + return nil + } + } + return E.New("certificate fingerprint mismatch") + } + } else if len(options.CertificatePublicKeySHA256) > 0 { + if len(options.Certificate) > 0 || options.CertificatePath != "" { + return nil, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path") + } + tlsConfig.InsecureSkipVerify = true + tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + return verifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts, tlsConfig.Time) + } } else if options.DisableSNI { tlsConfig.InsecureSkipVerify = true tlsConfig.VerifyConnection = func(state tls.ConnectionState) error { @@ -111,15 +159,6 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres return err } } - if len(options.CertificatePublicKeySHA256) > 0 { - if len(options.Certificate) > 0 || options.CertificatePath != "" { - return nil, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path") - } - tlsConfig.InsecureSkipVerify = true - tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { - return verifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts, tlsConfig.Time) - } - } if len(options.ALPN) > 0 { tlsConfig.NextProtos = options.ALPN } diff --git a/common/tls/std_server.go b/common/tls/std_server.go index 760c4b3a7f..b509c72eac 100644 --- a/common/tls/std_server.go +++ b/common/tls/std_server.go @@ -34,6 +34,8 @@ type STDServerConfig struct { clientCertificatePath []string echKeyPath string watcher *fswatch.Watcher + rejectUnknownSNI bool + serverNames map[string]bool } func (c *STDServerConfig) ServerName() string { @@ -216,6 +218,9 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. if !options.Enabled { return nil, nil } + if options.ServerName != "" && len(options.ServerNames) > 0 { + return nil, E.New("server_name and server_names cannot be configured at the same time") + } var tlsConfig *tls.Config var acmeService adapter.SimpleLifecycle var err error @@ -357,6 +362,15 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. return nil, err } } + serverNames := make(map[string]bool) + if options.ServerName != "" { + serverNames[options.ServerName] = true + } + for _, name := range options.ServerNames { + if name != "" { + serverNames[name] = true + } + } serverConfig := &STDServerConfig{ config: tlsConfig, logger: logger, @@ -367,10 +381,22 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. clientCertificatePath: options.ClientCertificatePath, keyPath: options.KeyPath, echKeyPath: echKeyPath, + rejectUnknownSNI: options.RejectUnknownSNI, + serverNames: serverNames, } serverConfig.config.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) { serverConfig.access.Lock() defer serverConfig.access.Unlock() + if serverConfig.rejectUnknownSNI { + sni := info.ServerName + if len(serverConfig.serverNames) > 0 { + if sni == "" || !serverConfig.serverNames[sni] { + return nil, E.New("unknown or missing server name") + } + } else if sni != "" { + return nil, E.New("unknown server name: no server names configured") + } + } return serverConfig.config, nil } var config ServerConfig = serverConfig diff --git a/common/tls/utls_client.go b/common/tls/utls_client.go index 941192ba16..e4e4578a7d 100644 --- a/common/tls/utls_client.go +++ b/common/tls/utls_client.go @@ -3,9 +3,12 @@ package tls import ( + "bytes" "context" + "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/hex" "math/rand" "net" "os" @@ -13,7 +16,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/tlsfragment" + tf "github.com/sagernet/sing-box/common/tlsfragment" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" @@ -161,13 +164,46 @@ func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre } if options.Insecure { tlsConfig.InsecureSkipVerify = options.Insecure - } else if options.DisableSNI { - if options.Reality != nil && options.Reality.Enabled { - return nil, E.New("disable_sni is unsupported in reality") + } else if len(options.CertificatePinSHA256) > 0 { + if len(options.CertificatePublicKeySHA256) > 0 || len(options.Certificate) > 0 || options.CertificatePath != "" { + return nil, E.New("certificate_pin_sha256 is conflict with certificate_public_key_sha256 or certificate or certificate_path") } - tlsConfig.InsecureServerNameToVerify = serverName - } - if len(options.CertificatePublicKeySHA256) > 0 { + fingerprint := strings.TrimSpace(strings.ReplaceAll(options.CertificatePinSHA256, ":", "")) + fpByte, err := hex.DecodeString(fingerprint) + if err != nil { + return nil, E.Cause(err, "decode fingerprint string") + } + if len(fpByte) != 32 { + return nil, E.New("fingerprint string length error, need sha256 fingerprint") + } + tlsConfig.InsecureSkipVerify = true + tlsConfig.VerifyConnection = func(state utls.ConnectionState) error { + certs := state.PeerCertificates + for i, cert := range certs { + hash := sha256.Sum256(cert.Raw) + if bytes.Equal(fpByte, hash[:]) { + if i > 0 { + opts := x509.VerifyOptions{ + Roots: x509.NewCertPool(), + Intermediates: x509.NewCertPool(), + DNSName: serverName, + } + if tlsConfig.Time != nil { + opts.CurrentTime = tlsConfig.Time() + } + opts.Roots.AddCert(certs[i]) + for _, cert := range certs[1 : i+1] { + opts.Intermediates.AddCert(cert) + } + _, err := certs[0].Verify(opts) + return err + } + return nil + } + } + return E.New("certificate fingerprint mismatch") + } + } else if len(options.CertificatePublicKeySHA256) > 0 { if len(options.Certificate) > 0 || options.CertificatePath != "" { return nil, E.New("certificate_public_key_sha256 is conflict with certificate or certificate_path") } @@ -175,6 +211,11 @@ func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { return verifyPublicKeySHA256(options.CertificatePublicKeySHA256, rawCerts, tlsConfig.Time) } + } else if options.DisableSNI { + if options.Reality != nil && options.Reality.Enabled { + return nil, E.New("disable_sni is unsupported in reality") + } + tlsConfig.InsecureServerNameToVerify = serverName } if len(options.ALPN) > 0 { tlsConfig.NextProtos = options.ALPN diff --git a/common/urltest/urltest.go b/common/urltest/urltest.go index 29d790e4d0..856449a245 100644 --- a/common/urltest/urltest.go +++ b/common/urltest/urltest.go @@ -125,6 +125,17 @@ func URLTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err e return } resp.Body.Close() + if C.URLTestUnifiedDelay { + second := time.Now() + var ignoredErr error + var secondResp *http.Response + secondResp, ignoredErr = client.Do(req.WithContext(ctx)) + if ignoredErr == nil { + resp = secondResp + resp.Body.Close() + start = second + } + } t = uint16(time.Since(start) / time.Millisecond) return } diff --git a/constant/dns.go b/constant/dns.go index 15d6096c78..3d15c6304d 100644 --- a/constant/dns.go +++ b/constant/dns.go @@ -28,6 +28,7 @@ const ( DNSTypeFakeIP = "fakeip" DNSTypeDHCP = "dhcp" DNSTypeTailscale = "tailscale" + DNSTypeGroup = "group" ) const ( diff --git a/constant/network.go b/constant/network.go index 88a1dd815f..ce0b7f6917 100644 --- a/constant/network.go +++ b/constant/network.go @@ -5,6 +5,8 @@ import ( F "github.com/sagernet/sing/common/format" ) +var URLTestUnifiedDelay = false + type InterfaceType uint8 const ( diff --git a/constant/provider.go b/constant/provider.go new file mode 100644 index 0000000000..252b1af5c4 --- /dev/null +++ b/constant/provider.go @@ -0,0 +1,20 @@ +package constant + +const ( + ProviderTypeInline = "inline" + ProviderTypeLocal = "local" + ProviderTypeRemote = "remote" +) + +func ProviderDisplayName(providerType string) string { + switch providerType { + case ProviderTypeInline: + return "Inline" + case ProviderTypeLocal: + return "Local" + case ProviderTypeRemote: + return "Remote" + default: + return "Unknown" + } +} diff --git a/constant/proxy.go b/constant/proxy.go index 278a46c2f6..23d7668d79 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -6,6 +6,7 @@ const ( TypeTProxy = "tproxy" TypeDirect = "direct" TypeBlock = "block" + TypePass = "pass" TypeDNS = "dns" TypeSOCKS = "socks" TypeHTTP = "http" @@ -34,8 +35,9 @@ const ( ) const ( - TypeSelector = "selector" - TypeURLTest = "urltest" + TypeSelector = "selector" + TypeURLTest = "urltest" + TypeLoadBalance = "loadbalance" ) func ProxyDisplayName(proxyType string) string { @@ -50,6 +52,8 @@ func ProxyDisplayName(proxyType string) string { return "Direct" case TypeBlock: return "Block" + case TypePass: + return "Pass" case TypeDNS: return "DNS" case TypeSOCKS: @@ -92,6 +96,8 @@ func ProxyDisplayName(proxyType string) string { return "Selector" case TypeURLTest: return "URLTest" + case TypeLoadBalance: + return "LoadBalance" default: return "Unknown" } diff --git a/constant/rule.go b/constant/rule.go index 55cad2e137..bbe201ab5a 100644 --- a/constant/rule.go +++ b/constant/rule.go @@ -27,15 +27,16 @@ const ( ) const ( - RuleActionTypeRoute = "route" - RuleActionTypeRouteOptions = "route-options" - RuleActionTypeDirect = "direct" - RuleActionTypeBypass = "bypass" - RuleActionTypeReject = "reject" - RuleActionTypeHijackDNS = "hijack-dns" - RuleActionTypeSniff = "sniff" - RuleActionTypeResolve = "resolve" - RuleActionTypePredefined = "predefined" + RuleActionTypeRoute = "route" + RuleActionTypeRouteOptions = "route-options" + RuleActionTypeDirect = "direct" + RuleActionTypeBypass = "bypass" + RuleActionTypeReject = "reject" + RuleActionTypeHijackDNS = "hijack-dns" + RuleActionTypeSniff = "sniff" + RuleActionTypeSniffOverrideDestination = "sniff-override-destination" + RuleActionTypeResolve = "resolve" + RuleActionTypePredefined = "predefined" ) const ( @@ -43,3 +44,13 @@ const ( RuleActionRejectMethodDrop = "drop" RuleActionRejectMethodReply = "reply" ) + +type DomainMatchStrategy = uint8 + +const ( + DomainMatchStrategyAsIS DomainMatchStrategy = iota + DomainMatchStrategyPreferFQDN + DomainMatchStrategyPreferSniffHost + DomainMatchStrategyFQDNOnly + DomainMatchStrategySniffHostOnly +) diff --git a/dns/client.go b/dns/client.go index ed4e8207b3..e10fd7f177 100644 --- a/dns/client.go +++ b/dns/client.go @@ -6,6 +6,8 @@ import ( "net" "net/netip" "strings" + "sync" + "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" @@ -31,19 +33,95 @@ var ( var _ adapter.DNSClient = (*Client)(nil) +func rotateSlice[T any](slice []T, steps int32) []T { + if len(slice) <= 1 { + return slice + } + steps = steps % int32(len(slice)) + return append(slice[steps:], slice[:steps]...) +} + +func reverseRotateSlice[T any](slice []T, steps int32) []T { + if len(slice) <= 1 { + return slice + } + steps = steps % int32(len(slice)) + return append(slice[len(slice)-int(steps):], slice[:len(slice)-int(steps)]...) +} + +func removeAnswersOfType(answers []dns.RR, rrType uint16) []dns.RR { + var filteredAnswers []dns.RR + for _, ans := range answers { + if ans.Header().Rrtype != rrType { + filteredAnswers = append(filteredAnswers, ans) + } + } + return filteredAnswers +} + +type dnsMsg struct { + ipv4Index int32 + ipv6Index int32 + msg *dns.Msg + expireTime time.Time +} + +func (dm *dnsMsg) RoundRobin() *dns.Msg { + rotatedMsg := dm.msg.Copy() + var ( + ipv4Answers []*dns.A + ipv6Answers []*dns.AAAA + ) + for _, ans := range rotatedMsg.Answer { + switch a := ans.(type) { + case *dns.A: + ipv4Answers = append(ipv4Answers, a) + case *dns.AAAA: + ipv6Answers = append(ipv6Answers, a) + } + } + if len(ipv4Answers) > 1 { + newIndex := (atomic.AddInt32(&dm.ipv4Index, 1) % int32(len(ipv4Answers))) + atomic.StoreInt32(&dm.ipv4Index, newIndex) + rotatedIPv4 := reverseRotateSlice(ipv4Answers, newIndex) + rotatedMsg.Answer = removeAnswersOfType(rotatedMsg.Answer, dns.TypeA) + for _, ipv4 := range rotatedIPv4 { + rotatedMsg.Answer = append(rotatedMsg.Answer, ipv4) + } + } + if len(ipv6Answers) > 1 { + newIndex := (atomic.AddInt32(&dm.ipv6Index, 1) % int32(len(ipv6Answers))) + atomic.StoreInt32(&dm.ipv6Index, newIndex) + rotatedIPv6 := reverseRotateSlice(ipv6Answers, newIndex) + rotatedMsg.Answer = removeAnswersOfType(rotatedMsg.Answer, dns.TypeAAAA) + for _, ipv6 := range rotatedIPv6 { + rotatedMsg.Answer = append(rotatedMsg.Answer, ipv6) + } + } + return rotatedMsg +} + type Client struct { - timeout time.Duration - disableCache bool - disableExpire bool - independentCache bool - clientSubnet netip.Prefix - rdrc adapter.RDRCStore - initRDRCFunc func() adapter.RDRCStore - logger logger.ContextLogger - cache freelru.Cache[dns.Question, *dns.Msg] - cacheLock compatible.Map[dns.Question, chan struct{}] - transportCache freelru.Cache[transportCacheKey, *dns.Msg] - transportCacheLock compatible.Map[dns.Question, chan struct{}] + timeout time.Duration + disableCache bool + disableExpire bool + independentCache bool + roundRobinCache bool + useLazyCache bool + lazyCacheTTL uint32 + minCacheTTL uint32 + maxCacheTTL uint32 + clientSubnet netip.Prefix + rdrc adapter.RDRCStore + initRDRCFunc func() adapter.RDRCStore + logger logger.ContextLogger + cache freelru.Cache[dns.Question, *dnsMsg] + cacheLock compatible.Map[dns.Question, chan struct{}] + transportCache freelru.Cache[transportCacheKey, *dnsMsg] + transportCacheLock compatible.Map[dns.Question, chan struct{}] + cacheUpdating map[dns.Question]struct{} + transportCacheUpdating map[transportCacheKey]struct{} + updateAccess sync.Mutex } type ClientOptions struct { @@ -51,8 +129,12 @@ type ClientOptions struct { DisableCache bool DisableExpire bool IndependentCache bool + RoundRobinCache bool + LazyCacheTTL uint32 CacheCapacity uint32 ClientSubnet netip.Prefix + MinCacheTTL uint32 + MaxCacheTTL uint32 RDRC func() adapter.RDRCStore Logger logger.ContextLogger } @@ -63,10 +145,21 @@ func NewClient(options ClientOptions) *Client { disableCache: options.DisableCache, disableExpire: options.DisableExpire, independentCache: options.IndependentCache, + roundRobinCache: options.RoundRobinCache, + useLazyCache: options.LazyCacheTTL > 0, + lazyCacheTTL: options.LazyCacheTTL, clientSubnet: options.ClientSubnet, + minCacheTTL: options.MinCacheTTL, + maxCacheTTL: options.MaxCacheTTL, initRDRCFunc: options.RDRC, logger: options.Logger, } + if client.maxCacheTTL == 0 { + client.maxCacheTTL = 86400 + } + if client.minCacheTTL > client.maxCacheTTL { + client.maxCacheTTL = client.minCacheTTL + } if client.timeout == 0 { client.timeout = C.DNSTimeout } @@ -76,9 +169,11 @@ func NewClient(options ClientOptions) *Client { } if !client.disableCache { if !client.independentCache { - client.cache = common.Must1(freelru.NewSharded[dns.Question, *dns.Msg](cacheCapacity, maphash.NewHasher[dns.Question]().Hash32)) + client.cache = common.Must1(freelru.NewSharded[dns.Question, *dnsMsg](cacheCapacity, maphash.NewHasher[dns.Question]().Hash32)) + client.cacheUpdating = make(map[dns.Question]struct{}) } else { - client.transportCache = common.Must1(freelru.NewSharded[transportCacheKey, *dns.Msg](cacheCapacity, maphash.NewHasher[transportCacheKey]().Hash32)) + client.transportCache = common.Must1(freelru.NewSharded[transportCacheKey, *dnsMsg](cacheCapacity, maphash.NewHasher[transportCacheKey]().Hash32)) + client.transportCacheUpdating = make(map[transportCacheKey]struct{}) } } return client @@ -109,19 +204,71 @@ func extractNegativeTTL(response *dns.Msg) (uint32, bool) { return 0, false } -func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error) { +type updateDnsCacheContext struct{} + +func (c *Client) UpdateDnsCacheFromContext(ctx context.Context) bool { + _, ok := ctx.Value((*updateDnsCacheContext)(nil)).(struct{}) + return ok +} + +func (c *Client) UpdateDnsCacheToContext(ctx context.Context) context.Context { + return context.WithValue(ctx, (*updateDnsCacheContext)(nil), struct{}{}) +} + +func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error, bool) { if len(message.Question) == 0 { if c.logger != nil { c.logger.WarnContext(ctx, "bad question size: ", len(message.Question)) } - return FixedResponseStatus(message, dns.RcodeFormatError), nil + return FixedResponseStatus(message, dns.RcodeFormatError), nil, false } question := message.Question[0] if question.Qtype == dns.TypeA && options.Strategy == C.DomainStrategyIPv6Only || question.Qtype == dns.TypeAAAA && options.Strategy == C.DomainStrategyIPv4Only { if c.logger != nil { c.logger.DebugContext(ctx, "strategy rejected") } - return FixedResponseStatus(message, dns.RcodeSuccess), nil + return FixedResponseStatus(message, dns.RcodeSuccess), nil, false + } + isUpdatingCache := c.UpdateDnsCacheFromContext(ctx) + if isUpdatingCache { + var key interface{} + isUpdating := func() bool { + c.updateAccess.Lock() + defer c.updateAccess.Unlock() + var exist bool + if !c.independentCache { + _, exist = c.cacheUpdating[question] + if !exist { + c.cacheUpdating[question] = struct{}{} + key = question + } + } else { + withTransportKey := transportCacheKey{ + Question: question, + transportTag: transport.Tag(), + } + _, exist = c.transportCacheUpdating[withTransportKey] + if !exist { + c.transportCacheUpdating[withTransportKey] = struct{}{} + key = withTransportKey + } + } + return exist + }() + if !isUpdating && key != nil { + defer func() { + c.updateAccess.Lock() + defer c.updateAccess.Unlock() + if !c.independentCache { + delete(c.cacheUpdating, key.(dns.Question)) + } else { + delete(c.transportCacheUpdating, key.(transportCacheKey)) + } + }() + } + if isUpdating { + return nil, nil, false + } } clientSubnet := options.ClientSubnet if !clientSubnet.IsValid() { @@ -140,14 +287,14 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m len(message.Extra[0].(*dns.OPT).Option) == 0) && !options.ClientSubnet.IsValid() disableCache := !isSimpleRequest || c.disableCache || options.DisableCache - if !disableCache { + if !disableCache && !isUpdatingCache { if c.cache != nil { cond, loaded := c.cacheLock.LoadOrStore(question, make(chan struct{})) if loaded { select { case <-cond: case <-ctx.Done(): - return nil, ctx.Err() + return nil, ctx.Err(), false } } else { defer func() { @@ -161,7 +308,7 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m select { case <-cond: case <-ctx.Done(): - return nil, ctx.Err() + return nil, ctx.Err(), false } } else { defer func() { @@ -170,24 +317,24 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m }() } } - response, ttl := c.loadResponse(question, transport) + response, ttl, stale := c.loadResponse(question, transport) if response != nil { logCachedResponse(c.logger, ctx, response, ttl) response.Id = message.Id - return response, nil + return response, nil, stale } } messageId := message.Id contextTransport, clientSubnetLoaded := transportTagFromContext(ctx) if clientSubnetLoaded && transport.Tag() == contextTransport { - return nil, E.New("DNS query loopback in transport[", contextTransport, "]") + return nil, E.New("DNS query loopback in transport[", contextTransport, "]"), false } ctx = contextWithTransportTag(ctx, transport.Tag()) if !disableCache && responseChecker != nil && c.rdrc != nil { rejected := c.rdrc.LoadRDRC(transport.Tag(), question.Name, question.Qtype) if rejected { - return nil, ErrResponseRejectedCached + return nil, ErrResponseRejectedCached, false } } ctx, cancel := context.WithTimeout(ctx, c.timeout) @@ -198,7 +345,7 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m if errors.As(err, &rcodeError) { response = FixedResponseStatus(message, int(rcodeError)) } else { - return nil, err + return nil, err, false } } /*if question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA { @@ -252,7 +399,7 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m c.rdrc.SaveRDRCAsync(transport.Tag(), question.Name, question.Qtype, c.logger) } logRejectedResponse(c.logger, ctx, response) - return response, ErrResponseRejected + return response, ErrResponseRejected, false } } if question.Qtype == dns.TypeHTTPS { @@ -289,6 +436,12 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m } } } + if timeToLive < c.minCacheTTL { + timeToLive = c.minCacheTTL + } + if timeToLive > c.maxCacheTTL { + timeToLive = c.maxCacheTTL + } if options.RewriteTTL != nil { timeToLive = *options.RewriteTTL } @@ -298,7 +451,7 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m } } if !disableCache { - c.storeCache(transport, question, response, timeToLive) + c.storeCache(transport, question, response, timeToLive, options.LazyCacheTTL) } response.Id = messageId requestEDNSOpt := message.IsEdns0() @@ -312,10 +465,10 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m } } logExchangedResponse(c.logger, ctx, response, timeToLive) - return response, nil + return response, nil, false } -func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error) { +func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error, bool) { domain = FqdnToDomain(domain) dnsName := dns.Fqdn(domain) var strategy C.DomainStrategy @@ -331,28 +484,31 @@ func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, dom } var response4 []netip.Addr var response6 []netip.Addr + var stale4, stale6 bool var group task.Group group.Append("exchange4", func(ctx context.Context) error { - response, err := c.lookupToExchange(ctx, transport, dnsName, dns.TypeA, options, responseChecker) + response, err, stale := c.lookupToExchange(ctx, transport, dnsName, dns.TypeA, options, responseChecker) if err != nil { return err } response4 = response + stale4 = stale return nil }) group.Append("exchange6", func(ctx context.Context) error { - response, err := c.lookupToExchange(ctx, transport, dnsName, dns.TypeAAAA, options, responseChecker) + response, err, stale := c.lookupToExchange(ctx, transport, dnsName, dns.TypeAAAA, options, responseChecker) if err != nil { return err } response6 = response + stale6 = stale return nil }) err := group.Run(ctx) if len(response4) == 0 && len(response6) == 0 { - return nil, err + return nil, err, false } - return sortAddresses(response4, response6, strategy), nil + return sortAddresses(response4, response6, strategy), nil, stale4 || stale6 } func (c *Client) ClearCache() { @@ -371,42 +527,53 @@ func sortAddresses(response4 []netip.Addr, response6 []netip.Addr, strategy C.Do } } -func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Question, message *dns.Msg, timeToLive uint32) { +func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Question, message *dns.Msg, timeToLive uint32, lazyCacheTTL *uint32) { if timeToLive == 0 { return } + pdnsMsg := &dnsMsg{msg: message} if c.disableExpire { if !c.independentCache { - c.cache.Add(question, message) + c.cache.Add(question, pdnsMsg) } else { c.transportCache.Add(transportCacheKey{ Question: question, transportTag: transport.Tag(), - }, message) + }, pdnsMsg) } } else { + lifetime := time.Second * time.Duration(timeToLive) + pdnsMsg.expireTime = time.Now().Add(lifetime) + if lazyCacheTTL != nil { + if *lazyCacheTTL > 0 { + lifetime = lifetime + (time.Second * time.Duration(*lazyCacheTTL)) + } + } else if c.useLazyCache { + lifetime = lifetime + time.Second*time.Duration(c.lazyCacheTTL) + } if !c.independentCache { - c.cache.AddWithLifetime(question, message, time.Second*time.Duration(timeToLive)) + c.cache.AddWithLifetime(question, pdnsMsg, lifetime) } else { c.transportCache.AddWithLifetime(transportCacheKey{ Question: question, transportTag: transport.Tag(), - }, message, time.Second*time.Duration(timeToLive)) + }, pdnsMsg, lifetime) } } } -func (c *Client) lookupToExchange(ctx context.Context, transport adapter.DNSTransport, name string, qType uint16, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error) { +func (c *Client) lookupToExchange(ctx context.Context, transport adapter.DNSTransport, name string, qType uint16, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error, bool) { question := dns.Question{ Name: name, Qtype: qType, Qclass: dns.ClassINET, } + isUpdatingCache := c.UpdateDnsCacheFromContext(ctx) disableCache := c.disableCache || options.DisableCache - if !disableCache { - cachedAddresses, err := c.questionCache(question, transport) + if !disableCache && !isUpdatingCache { + cachedAddresses, err, stale := c.questionCache(question, transport) if err != ErrNotCached { - return cachedAddresses, err + return cachedAddresses, err, stale } } message := dns.Msg{ @@ -415,57 +582,69 @@ func (c *Client) lookupToExchange(ctx context.Context, transport adapter.DNSTran }, Question: []dns.Question{question}, } - response, err := c.Exchange(ctx, transport, &message, options, responseChecker) + response, err, _ := c.Exchange(ctx, transport, &message, options, responseChecker) if err != nil { - return nil, err + return nil, err, false + } + if response == nil { + return nil, nil, false } if response.Rcode != dns.RcodeSuccess { - return nil, RcodeError(response.Rcode) + return nil, RcodeError(response.Rcode), false } - return MessageToAddresses(response), nil + return MessageToAddresses(response), nil, false } -func (c *Client) questionCache(question dns.Question, transport adapter.DNSTransport) ([]netip.Addr, error) { - response, _ := c.loadResponse(question, transport) +func (c *Client) questionCache(question dns.Question, transport adapter.DNSTransport) ([]netip.Addr, error, bool) { + response, _, stale := c.loadResponse(question, transport) if response == nil { - return nil, ErrNotCached + return nil, ErrNotCached, false } if response.Rcode != dns.RcodeSuccess { - return nil, RcodeError(response.Rcode) + return nil, RcodeError(response.Rcode), false + } + return MessageToAddresses(response), nil, stale +} + +func (c *Client) getRoundRobin(response *dnsMsg) *dns.Msg { + if c.roundRobinCache { + return response.RoundRobin() + } else { + return response.msg.Copy() } - return MessageToAddresses(response), nil } -func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int) { +func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int, bool) { var ( + resp *dnsMsg response *dns.Msg loaded bool ) if c.disableExpire { if !c.independentCache { - response, loaded = c.cache.Get(question) + resp, loaded = c.cache.Get(question) } else { - response, loaded = c.transportCache.Get(transportCacheKey{ + resp, loaded = c.transportCache.Get(transportCacheKey{ Question: question, transportTag: transport.Tag(), }) } if !loaded { - return nil, 0 + return nil, 0, false } - return response.Copy(), 0 + return c.getRoundRobin(resp), 0, false } else { var expireAt time.Time if !c.independentCache { - response, expireAt, loaded = c.cache.GetWithLifetime(question) + resp, expireAt, loaded = c.cache.GetWithLifetime(question) } else { - response, expireAt, loaded = c.transportCache.GetWithLifetime(transportCacheKey{ + resp, expireAt, loaded = c.transportCache.GetWithLifetime(transportCacheKey{ Question: question, transportTag: transport.Tag(), }) } if !loaded { - return nil, 0 + return nil, 0, false } timeNow := time.Now() if timeNow.After(expireAt) { @@ -477,8 +656,10 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp transportTag: transport.Tag(), }) } - return nil, 0 + return nil, 0, false } + stale := c.useLazyCache && !resp.expireTime.IsZero() && timeNow.After(resp.expireTime) + response = c.getRoundRobin(resp) var originTTL int for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { for _, record := range recordList { @@ -491,7 +672,28 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp if nowTTL < 0 { nowTTL = 0 } - response = response.Copy() + if stale { + for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { + for _, record := range recordList { + record.Header().Ttl = 5 + } + } + opt := response.IsEdns0() + if opt == nil { + opt = &dns.OPT{ + Hdr: dns.RR_Header{ + Name: ".", + Rrtype: dns.TypeOPT, + }, + } + opt.SetUDPSize(4096) + response.Extra = append(response.Extra, opt) + } + opt.Option = append(opt.Option, &dns.EDNS0_EDE{ + InfoCode: dns.ExtendedErrorCodeStaleAnswer, + }) + return response, 0, true + } if originTTL > 0 { duration := uint32(originTTL - nowTTL) for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { @@ -506,7 +708,7 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp } } } - return response, nowTTL + return response, nowTTL, false } } diff --git a/dns/router.go b/dns/router.go index 567f3225f4..19230a3dd7 100644 --- a/dns/router.go +++ b/dns/router.go @@ -13,7 +13,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" R "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" @@ -35,9 +35,11 @@ type Router struct { outbound adapter.OutboundManager client adapter.DNSClient rules []adapter.DNSRule + ruleByUUID map[string]adapter.DNSRule defaultDomainStrategy C.DomainStrategy dnsReverseMapping freelru.Cache[netip.Addr, string] platformInterface adapter.PlatformInterface + defaultRejectRcode int } func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOptions) *Router { @@ -47,14 +49,20 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp transport: service.FromContext[adapter.DNSTransportManager](ctx), outbound: service.FromContext[adapter.OutboundManager](ctx), rules: make([]adapter.DNSRule, 0, len(options.Rules)), + ruleByUUID: make(map[string]adapter.DNSRule), defaultDomainStrategy: C.DomainStrategy(options.Strategy), + defaultRejectRcode: options.DefaultRejectRcode.Build(), } router.client = NewClient(ClientOptions{ DisableCache: options.DNSClientOptions.DisableCache, DisableExpire: options.DNSClientOptions.DisableExpire, IndependentCache: options.DNSClientOptions.IndependentCache, + RoundRobinCache: options.DNSClientOptions.RoundRobinCache, CacheCapacity: options.DNSClientOptions.CacheCapacity, ClientSubnet: options.DNSClientOptions.ClientSubnet.Build(netip.Prefix{}), + MinCacheTTL: options.DNSClientOptions.MinCacheTTL, + MaxCacheTTL: options.DNSClientOptions.MaxCacheTTL, + LazyCacheTTL: options.DNSClientOptions.LazyCacheTTL, RDRC: func() adapter.RDRCStore { cacheFile := service.FromContext[adapter.CacheFile](ctx) if cacheFile == nil { @@ -80,6 +88,7 @@ func (r *Router) Initialize(rules []option.DNSRule) error { return E.Cause(err, "parse dns rule[", i, "]") } r.rules = append(r.rules, dnsRule) + r.ruleByUUID[dnsRule.UUID()] = dnsRule } return nil } @@ -128,6 +137,9 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, } for ; currentRuleIndex < len(r.rules); currentRuleIndex++ { currentRule := r.rules[currentRuleIndex] + if currentRule.Disabled() { + continue + } if currentRule.WithAddressLimit() && !isAddressQuery { continue } @@ -166,6 +178,9 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, if action.ClientSubnet.IsValid() { options.ClientSubnet = action.ClientSubnet } + if action.LazyCacheTTL != nil { + options.LazyCacheTTL = action.LazyCacheTTL + } if legacyTransport, isLegacy := transport.(adapter.LegacyDNSTransport); isLegacy { if options.Strategy == C.DomainStrategyAsIS { options.Strategy = legacyTransport.LegacyStrategy() @@ -188,6 +203,9 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, if action.ClientSubnet.IsValid() { options.ClientSubnet = action.ClientSubnet } + if action.LazyCacheTTL != nil { + options.LazyCacheTTL = action.LazyCacheTTL + } case *R.RuleActionReject: return nil, currentRule, currentRuleIndex case *R.RuleActionPredefined: @@ -195,7 +213,16 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, } } } - return r.transport.Default(), nil, -1 + defaultTransport := r.transport.Default() + if legacyTransport, isLegacy := defaultTransport.(adapter.LegacyDNSTransport); isLegacy { + if options.Strategy == C.DomainStrategyAsIS { + options.Strategy = legacyTransport.LegacyStrategy() + } + if !options.ClientSubnet.IsValid() { + options.ClientSubnet = legacyTransport.LegacyClientSubnet() + } + } + return defaultTransport, nil, -1 } func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { @@ -216,6 +243,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte response *mDNS.Msg transport adapter.DNSTransport err error + stale bool ) var metadata *adapter.InboundContext ctx, metadata = adapter.ExtendContext(ctx) @@ -241,7 +269,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte if options.Strategy == C.DomainStrategyAsIS { options.Strategy = r.defaultDomainStrategy } - response, err = r.client.Exchange(ctx, transport, message, options, nil) + response, err, stale = r.client.Exchange(ctx, transport, message, options, nil) } else { var ( rule adapter.DNSRule @@ -257,10 +285,20 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte case *R.RuleActionReject: switch action.Method { case C.RuleActionRejectMethodDefault: + var rcode int + if action.Rcode == -1 { + if r.defaultRejectRcode == -1 { + rcode = mDNS.RcodeRefused + } else { + rcode = r.defaultRejectRcode + } + } else { + rcode = action.Rcode + } return &mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ Id: message.Id, - Rcode: mDNS.RcodeRefused, + Rcode: rcode, Response: true, }, Question: []mDNS.Question{message.Question[0]}, @@ -276,7 +314,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte if dnsOptions.Strategy == C.DomainStrategyAsIS { dnsOptions.Strategy = r.defaultDomainStrategy } - response, err = r.client.Exchange(dnsCtx, transport, message, dnsOptions, responseCheck) + response, err, stale = r.client.Exchange(dnsCtx, transport, message, dnsOptions, responseCheck) var rejected bool if err != nil { if errors.Is(err, ErrResponseRejectedCached) { @@ -300,6 +338,10 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte if err != nil { return nil, err } + if stale { + r.logger.DebugContext(ctx, "updating stale cache ", FormatQuestion(message.Question[0].String())) + go r.Exchange(r.client.UpdateDnsCacheToContext(context.WithoutCancel(ctx)), message, options) + } if r.dnsReverseMapping != nil && len(message.Question) > 0 && response != nil && len(response.Answer) > 0 { if transport == nil || transport.Type() != C.DNSTypeFakeIP { for _, answer := range response.Answer { @@ -319,6 +361,7 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ var ( responseAddrs []netip.Addr err error + stale bool ) printResult := func() { if err == nil && len(responseAddrs) == 0 { @@ -354,7 +397,7 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ if options.Strategy == C.DomainStrategyAsIS { options.Strategy = r.defaultDomainStrategy } - responseAddrs, err = r.client.Lookup(ctx, transport, domain, options, nil) + responseAddrs, err, stale = r.client.Lookup(ctx, transport, domain, options, nil) } else { var ( transport adapter.DNSTransport @@ -392,13 +435,17 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ if dnsOptions.Strategy == C.DomainStrategyAsIS { dnsOptions.Strategy = r.defaultDomainStrategy } - responseAddrs, err = r.client.Lookup(dnsCtx, transport, domain, dnsOptions, responseCheck) + responseAddrs, err, stale = r.client.Lookup(dnsCtx, transport, domain, dnsOptions, responseCheck) if responseCheck == nil || err == nil { break } printResult() } } + if stale { + r.logger.DebugContext(ctx, "updating stale cache for lookup ", domain) + go r.Lookup(r.client.UpdateDnsCacheToContext(context.WithoutCancel(ctx)), domain, options) + } response: printResult() if len(responseAddrs) > 0 { @@ -428,6 +475,15 @@ func addressLimitResponseCheck(rule adapter.DNSRule, metadata *adapter.InboundCo } } +func (r *Router) Rules() []adapter.DNSRule { + return r.rules +} + +func (r *Router) Rule(uuid string) (adapter.DNSRule, bool) { + rule, exists := r.ruleByUUID[uuid] + return rule, exists +} + func (r *Router) ClearCache() { r.client.ClearCache() if r.platformInterface != nil { diff --git a/dns/transport/group.go b/dns/transport/group.go new file mode 100644 index 0000000000..53d9c3a2b3 --- /dev/null +++ b/dns/transport/group.go @@ -0,0 +1,117 @@ +package transport + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" +) + +var _ adapter.DNSTransport = (*GroupTransport)(nil) + +func RegisterGroup(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.GroupDNSServerOptions](registry, C.DNSTypeGroup, NewGroup) +} + +type GroupTransport struct { + dns.TransportAdapter + + ctx context.Context + logger log.ContextLogger + serverTags []string +} + +func NewGroup(ctx context.Context, logger log.ContextLogger, tag string, options option.GroupDNSServerOptions) (adapter.DNSTransport, error) { + if len(options.Servers) == 0 { + return nil, E.New("missing servers") + } + return &GroupTransport{ + TransportAdapter: dns.NewTransportAdapter(C.DNSTypeGroup, tag, options.Servers), + ctx: ctx, + logger: logger, + serverTags: options.Servers, + }, nil +} + +func (t *GroupTransport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + transportManager := service.FromContext[adapter.DNSTransportManager](t.ctx) + if transportManager == nil { + return E.New("missing DNS transport manager") + } + for _, tag := range t.serverTags { + transport, loaded := transportManager.Transport(tag) + if !loaded { + return E.New("DNS server not found: ", tag) + } + if transport.Type() == C.DNSTypeGroup { + return E.New("group cannot contain another group: ", tag) + } + if transport.Type() == C.DNSTypeFakeIP { + return E.New("group cannot contain fakeip server: ", tag) + } + } + return nil +} + +func (t *GroupTransport) Close() error { + return nil +} + +func (t *GroupTransport) Reset() { +} + +func (t *GroupTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + transportManager := service.FromContext[adapter.DNSTransportManager](t.ctx) + if transportManager == nil { + return nil, E.New("missing DNS transport manager") + } + + type result struct { + response *mDNS.Msg + tag string + err error + } + + resultCh := make(chan result, len(t.serverTags)) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + for _, tag := range t.serverTags { + transport, loaded := transportManager.Transport(tag) + if !loaded { + resultCh <- result{nil, tag, E.New("DNS server not found: ", tag)} + continue + } + go func(transport adapter.DNSTransport, tag string) { + resp, err := transport.Exchange(ctx, message.Copy()) + resultCh <- result{resp, tag, err} + }(transport, tag) + } + + var firstErr error + for range t.serverTags { + r := <-resultCh + if r.err == nil && r.response != nil { + t.logger.DebugContext(ctx, "fastest response from ", r.tag) + return r.response, nil + } + if firstErr == nil && r.err != nil { + firstErr = r.err + } + } + + if firstErr != nil { + return nil, firstErr + } + return nil, E.New("all DNS servers failed") +} diff --git a/dns/transport/https.go b/dns/transport/https.go index b508e6eae5..b44c4b3ade 100644 --- a/dns/transport/https.go +++ b/dns/transport/https.go @@ -3,6 +3,7 @@ package transport import ( "bytes" "context" + "encoding/base64" "errors" "io" "net" @@ -44,6 +45,7 @@ type HTTPSTransport struct { logger logger.ContextLogger dialer N.Dialer destination *url.URL + method string headers http.Header transportAccess sync.Mutex transport *HTTPSTransportWrapper @@ -105,6 +107,7 @@ func NewHTTPS(ctx context.Context, logger log.ContextLogger, tag string, options logger, transportDialer, &destinationURL, + options.Method, headers, serverAddr, tlsConfig, @@ -116,6 +119,7 @@ func NewHTTPSRaw( logger log.ContextLogger, dialer N.Dialer, destination *url.URL, + method string, headers http.Header, serverAddr M.Socksaddr, tlsConfig tls.Config, @@ -124,6 +128,7 @@ func NewHTTPSRaw( TransportAdapter: adapter, logger: logger, dialer: dialer, + method: method, destination: destination, headers: headers, transport: NewHTTPSTransportWrapper(tls.NewDialer(dialer, tlsConfig), serverAddr), @@ -181,13 +186,26 @@ func (t *HTTPSTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS requestBuffer.Release() return nil, err } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), bytes.NewReader(rawMessage)) + destination := *t.destination + var request *http.Request + var body io.Reader + switch t.method { + case http.MethodGet: + query := url.Values{} + query.Set("dns", base64.RawURLEncoding.EncodeToString(rawMessage)) + destination.RawQuery = query.Encode() + case http.MethodPost: + body = bytes.NewReader(rawMessage) + } + request, err = http.NewRequestWithContext(ctx, t.method, destination.String(), body) if err != nil { requestBuffer.Release() return nil, err } request.Header = t.headers.Clone() - request.Header.Set("Content-Type", MimeType) + if t.method == http.MethodPost { + request.Header.Set("Content-Type", MimeType) + } request.Header.Set("Accept", MimeType) t.transportAccess.Lock() currentTransport := t.transport diff --git a/dns/transport/quic/http3.go b/dns/transport/quic/http3.go index c3a5ca81cb..7e5b7da922 100644 --- a/dns/transport/quic/http3.go +++ b/dns/transport/quic/http3.go @@ -3,6 +3,7 @@ package quic import ( "bytes" "context" + "encoding/base64" "io" "net" "net/http" @@ -43,6 +44,7 @@ type HTTP3Transport struct { logger logger.ContextLogger dialer N.Dialer destination *url.URL + method string headers http.Header serverAddr M.Socksaddr tlsConfig *tls.STDConfig @@ -106,6 +108,7 @@ func NewHTTP3(ctx context.Context, logger log.ContextLogger, tag string, options logger: logger, dialer: transportDialer, destination: &destinationURL, + method: options.Method, headers: headers, serverAddr: serverAddr, tlsConfig: stdConfig, @@ -162,13 +165,26 @@ func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS requestBuffer.Release() return nil, err } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.destination.String(), bytes.NewReader(rawMessage)) + destination := *t.destination + var request *http.Request + var body io.Reader + switch t.method { + case http.MethodGet: + query := url.Values{} + query.Set("dns", base64.RawURLEncoding.EncodeToString(rawMessage)) + destination.RawQuery = query.Encode() + case http.MethodPost: + body = bytes.NewReader(rawMessage) + } + request, err = http.NewRequestWithContext(ctx, t.method, destination.String(), body) if err != nil { requestBuffer.Release() return nil, err } request.Header = t.headers.Clone() - request.Header.Set("Content-Type", transport.MimeType) + if t.method == http.MethodPost { + request.Header.Set("Content-Type", transport.MimeType) + } request.Header.Set("Accept", transport.MimeType) t.transportAccess.Lock() currentTransport := t.transport diff --git a/dns/transport/tcp.go b/dns/transport/tcp.go index 59333de8df..6ec832ebec 100644 --- a/dns/transport/tcp.go +++ b/dns/transport/tcp.go @@ -4,9 +4,14 @@ import ( "context" "encoding/binary" "io" + "net" + "sync" + "sync/atomic" + "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/expiringpool" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/log" @@ -14,6 +19,7 @@ import ( "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -22,18 +28,38 @@ import ( var _ adapter.DNSTransport = (*TCPTransport)(nil) +type dnsTransportManager interface { + removeActiveConn(conn *reuseableDNSConn) + markPipelineDetected() bool + isPipelineDetected() bool + getDetectionCounters() (consecutiveOutOfOrder, outOfOrderCount, totalResponses *int32) +} + func RegisterTCP(registry *dns.TransportRegistry) { - dns.RegisterTransport[option.RemoteDNSServerOptions](registry, C.DNSTypeTCP, NewTCP) + dns.RegisterTransport[option.RemoteTCPDNSServerOptions](registry, C.DNSTypeTCP, NewTCP) } type TCPTransport struct { dns.TransportAdapter + logger logger.ContextLogger dialer N.Dialer serverAddr M.Socksaddr + + connections *expiringpool.ExpiringPool[*reuseableDNSConn] + enablePipeline bool + idleTimeout time.Duration + disableKeepAlive bool + maxQueries int + activeConns []*reuseableDNSConn + activeAccess sync.Mutex + pipelineDetected int32 + consecutiveOutOfOrder int32 + outOfOrderCount int32 + totalResponses int32 } -func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteDNSServerOptions) (adapter.DNSTransport, error) { - transportDialer, err := dns.NewRemoteDialer(ctx, options) +func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteTCPDNSServerOptions) (adapter.DNSTransport, error) { + transportDialer, err := dns.NewRemoteDialer(ctx, options.RemoteDNSServerOptions) if err != nil { return nil, err } @@ -44,21 +70,66 @@ func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options o if !serverAddr.IsValid() { return nil, E.New("invalid server address: ", serverAddr) } - return &TCPTransport{ - TransportAdapter: dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTCP, tag, options), + enableConnReuse := options.Reuse + if options.Pipeline { + enableConnReuse = true + } + var poolIdleTimeout time.Duration + if options.DisableTCPKeepAlive { + poolIdleTimeout = 2 * time.Minute + } else { + var keepAliveIdle, keepAliveInterval time.Duration + if options.TCPKeepAlive != 0 { + keepAliveIdle = time.Duration(options.TCPKeepAlive) + } else { + keepAliveIdle = C.TCPKeepAliveInitial + } + if options.TCPKeepAliveInterval != 0 { + keepAliveInterval = time.Duration(options.TCPKeepAliveInterval) + } else { + keepAliveInterval = C.TCPKeepAliveInterval + } + poolIdleTimeout = keepAliveIdle + keepAliveInterval + } + maxQueries := options.MaxQueries + if maxQueries <= 0 { + maxQueries = 0 + } + if !options.Pipeline && maxQueries > 0 { + maxQueries = 0 + } + transport := &TCPTransport{ + TransportAdapter: dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTCP, tag, options.RemoteDNSServerOptions), + logger: logger, dialer: transportDialer, serverAddr: serverAddr, - }, nil + enablePipeline: options.Pipeline, + idleTimeout: poolIdleTimeout, + disableKeepAlive: options.DisableTCPKeepAlive, + maxQueries: maxQueries, + } + if enableConnReuse { + transport.connections = expiringpool.New(ctx, poolIdleTimeout, func(conn *reuseableDNSConn) { + conn.Close() + }) + } + return transport, nil } func (t *TCPTransport) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } + if t.connections != nil { + t.connections.Start() + } return dialer.InitializeDetour(t.dialer) } func (t *TCPTransport) Close() error { + if t.connections != nil { + t.connections.Close() + } return nil } @@ -66,20 +137,161 @@ func (t *TCPTransport) Reset() { } func (t *TCPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) + if t.connections == nil { + return t.createNewConnection(ctx, message) + } + + if t.enablePipeline { + if t.maxQueries == 0 { + conn := t.getValidConnFromPool() + if conn != nil { + return conn.Exchange(ctx, message) + } + return t.createNewConnection(ctx, message) + } else { + conn := t.findAndReserveActiveConn() + if conn != nil { + return conn.exchangeWithoutIncrement(ctx, message) + } + + conn = t.getValidConnFromPool() + if conn != nil { + t.addActiveConn(conn) + return conn.Exchange(ctx, message) + } + + return t.createNewConnection(ctx, message) + } + } else { + conn := t.getValidConnFromPool() + if conn != nil { + response, err := conn.Exchange(ctx, message) + if err == nil { + return response, nil + } + } + return t.createNewConnection(ctx, message) + } +} + +func (t *TCPTransport) getValidConnFromPool() *reuseableDNSConn { + conn := t.connections.Get() + if conn == nil { + return nil + } + + select { + case <-conn.done: + return nil + default: + return conn + } +} + +func (t *TCPTransport) findAndReserveActiveConn() *reuseableDNSConn { + t.activeAccess.Lock() + defer t.activeAccess.Unlock() + + var bestConn *reuseableDNSConn + var minQueries int32 = -1 + var closedCount int + + for _, conn := range t.activeConns { + select { + case <-conn.done: + closedCount++ + default: + if conn.maxQueries <= 0 || atomic.LoadInt32(&conn.activeQueries) < int32(conn.maxQueries) { + current := atomic.LoadInt32(&conn.activeQueries) + if minQueries == -1 || current < minQueries { + minQueries = current + bestConn = conn + } + } + } + } + + if bestConn != nil && minQueries == 0 && closedCount == 0 { + atomic.AddInt32(&bestConn.activeQueries, 1) + return bestConn + } + + if closedCount > 0 { + validConns := make([]*reuseableDNSConn, 0, len(t.activeConns)-closedCount) + for _, conn := range t.activeConns { + select { + case <-conn.done: + default: + validConns = append(validConns, conn) + } + } + t.activeConns = validConns + } + + if bestConn != nil { + atomic.AddInt32(&bestConn.activeQueries, 1) + } + + return bestConn +} + +func (t *TCPTransport) addActiveConn(conn *reuseableDNSConn) { + t.activeAccess.Lock() + defer t.activeAccess.Unlock() + + for _, c := range t.activeConns { + if c == conn { + return + } + } + + t.activeConns = append(t.activeConns, conn) +} + +func (t *TCPTransport) removeActiveConn(conn *reuseableDNSConn) { + t.activeAccess.Lock() + defer t.activeAccess.Unlock() + + for i, c := range t.activeConns { + if c == conn { + last := len(t.activeConns) - 1 + t.activeConns[i] = t.activeConns[last] + t.activeConns = t.activeConns[:last] + return + } + } +} + +func (t *TCPTransport) markPipelineDetected() bool { + return atomic.CompareAndSwapInt32(&t.pipelineDetected, 0, 1) +} + +func (t *TCPTransport) isPipelineDetected() bool { + return atomic.LoadInt32(&t.pipelineDetected) != 0 +} + +func (t *TCPTransport) getDetectionCounters() (*int32, *int32, *int32) { + return &t.consecutiveOutOfOrder, &t.outOfOrderCount, &t.totalResponses +} + +func (t *TCPTransport) createNewConnection(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + rawConn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) if err != nil { return nil, E.Cause(err, "dial TCP connection") } - defer conn.Close() - err = WriteMessage(conn, 0, message) - if err != nil { - return nil, E.Cause(err, "write request") + var connIdleTimeout time.Duration + if t.connections != nil && t.disableKeepAlive { + connIdleTimeout = t.idleTimeout } - response, err := ReadMessage(conn) - if err != nil { - return nil, E.Cause(err, "read response") + conn := newReuseableDNSConn(rawConn, t.logger, t.enablePipeline, connIdleTimeout, t.maxQueries, t.connections, t) + + if t.connections == nil { + defer conn.Close() + } else if t.enablePipeline && t.maxQueries > 0 { + t.addActiveConn(conn) } - return response, nil + + return conn.Exchange(ctx, message) } func ReadMessage(reader io.Reader) (*mDNS.Msg, error) { @@ -117,3 +329,228 @@ func WriteMessage(writer io.Writer, messageId uint16, message *mDNS.Msg) error { buffer.Truncate(2 + len(rawMessage)) return common.Error(writer.Write(buffer.Bytes())) } + +type dnsCallback struct { + access sync.Mutex + message *mDNS.Msg + done chan struct{} +} + +type reuseableDNSConn struct { + net.Conn + logger logger.ContextLogger + access sync.RWMutex + done chan struct{} + closeOnce sync.Once + err error + queryId uint16 + callbacks map[uint16]*dnsCallback + writeLock sync.Mutex + startReadOnce sync.Once + enablePipeline bool + activeQueries int32 + maxQueries int + pool *expiringpool.ExpiringPool[*reuseableDNSConn] + transport dnsTransportManager + idleTimeout time.Duration + idleTimer *time.Timer +} + +func newReuseableDNSConn(conn net.Conn, logger logger.ContextLogger, enablePipeline bool, idleTimeout time.Duration, maxQueries int, pool *expiringpool.ExpiringPool[*reuseableDNSConn], transport dnsTransportManager) *reuseableDNSConn { + c := &reuseableDNSConn{ + Conn: conn, + logger: logger, + done: make(chan struct{}), + callbacks: make(map[uint16]*dnsCallback), + enablePipeline: enablePipeline, + maxQueries: maxQueries, + pool: pool, + transport: transport, + idleTimeout: idleTimeout, + } + if idleTimeout > 0 { + c.idleTimer = time.AfterFunc(idleTimeout, func() { + c.closeWithError(E.New("connection idle timeout")) + }) + } + return c +} + +func (c *reuseableDNSConn) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + atomic.AddInt32(&c.activeQueries, 1) + return c.exchangeWithCleanup(ctx, message, true) +} + +func (c *reuseableDNSConn) exchangeWithoutIncrement(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + return c.exchangeWithCleanup(ctx, message, true) +} + +func (c *reuseableDNSConn) exchangeWithCleanup(ctx context.Context, message *mDNS.Msg, resetTimer bool) (*mDNS.Msg, error) { + if resetTimer && c.enablePipeline && c.idleTimer != nil { + c.idleTimer.Reset(c.idleTimeout) + } + defer func() { + if resetTimer && !c.enablePipeline && c.idleTimer != nil { + c.idleTimer.Reset(c.idleTimeout) + } + newCount := atomic.AddInt32(&c.activeQueries, -1) + if newCount == 0 && c.pool != nil { + if c.enablePipeline && c.maxQueries > 0 && c.transport != nil { + c.transport.removeActiveConn(c) + } + select { + case <-c.done: + default: + c.pool.Put(c) + } + } + }() + + if !c.enablePipeline { + c.writeLock.Lock() + defer c.writeLock.Unlock() + + err := WriteMessage(c.Conn, 0, message) + if err != nil { + wrappedErr := E.Cause(err, "write request") + c.closeWithError(wrappedErr) + return nil, wrappedErr + } + response, err := ReadMessage(c.Conn) + if err != nil { + wrappedErr := E.Cause(err, "read response") + c.closeWithError(wrappedErr) + return nil, wrappedErr + } + return response, nil + } + + c.startReadOnce.Do(func() { + go c.recvLoop() + }) + + c.access.Lock() + c.queryId++ + messageId := c.queryId + callback := &dnsCallback{ + done: make(chan struct{}), + } + c.callbacks[messageId] = callback + c.access.Unlock() + + defer func() { + c.access.Lock() + delete(c.callbacks, messageId) + c.access.Unlock() + }() + + c.writeLock.Lock() + err := WriteMessage(c.Conn, messageId, message) + c.writeLock.Unlock() + if err != nil { + wrappedErr := E.Cause(err, "write request") + c.closeWithError(wrappedErr) + return nil, wrappedErr + } + originalId := message.Id + select { + case <-callback.done: + if callback.message != nil { + callback.message.Id = originalId + return callback.message, nil + } + return nil, E.New("response is nil") + case <-c.done: + return nil, c.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (c *reuseableDNSConn) recvLoop() { + var lastRecvId uint16 + for { + message, err := ReadMessage(c.Conn) + if err != nil { + wrappedErr := E.Cause(err, "read response") + c.closeWithError(wrappedErr) + return + } + + c.access.RLock() + callback, loaded := c.callbacks[message.Id] + c.access.RUnlock() + + if !loaded { + if c.logger != nil { + c.logger.Warn("received response for unknown message ID: ", message.Id) + } + continue + } + + if c.enablePipeline && c.transport != nil && !c.transport.isPipelineDetected() { + consecutivePtr, outOfOrderPtr, totalPtr := c.transport.getDetectionCounters() + totalResp := atomic.AddInt32(totalPtr, 1) + + detected := false + if totalResp > 1 { + diff := uint16(message.Id) - uint16(lastRecvId) + if diff > 0x8000 { + outOfOrder := atomic.AddInt32(outOfOrderPtr, 1) + consecutive := atomic.AddInt32(consecutivePtr, 1) + + if consecutive >= 3 || (totalResp >= 10 && outOfOrder*10 > totalResp*3) { + detected = true + if c.transport.markPipelineDetected() && c.logger != nil { + c.logger.Debug("server supports pipelining") + } + } + } else { + atomic.StoreInt32(consecutivePtr, 0) + } + } + + if !detected && totalResp >= 50 { + detected = true + c.transport.markPipelineDetected() + } + + if detected { + atomic.StoreInt32(consecutivePtr, 0) + atomic.StoreInt32(outOfOrderPtr, 0) + atomic.StoreInt32(totalPtr, 0) + } + } + lastRecvId = message.Id + callback.access.Lock() + select { + case <-callback.done: + default: + callback.message = message + close(callback.done) + } + callback.access.Unlock() + } +} + +func (c *reuseableDNSConn) IsOverMaxQueries() bool { + if c.maxQueries <= 0 { + return false + } + return atomic.LoadInt32(&c.activeQueries) >= int32(c.maxQueries) +} + +func (c *reuseableDNSConn) closeWithError(err error) { + c.closeOnce.Do(func() { + if c.idleTimer != nil { + c.idleTimer.Stop() + } + c.err = err + close(c.done) + c.Conn.Close() + }) +} + +func (c *reuseableDNSConn) Close() { + c.closeWithError(net.ErrClosed) +} diff --git a/dns/transport/tls.go b/dns/transport/tls.go index 4d463296b1..6efa220e13 100644 --- a/dns/transport/tls.go +++ b/dns/transport/tls.go @@ -3,10 +3,12 @@ package transport import ( "context" "sync" + "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/expiringpool" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" @@ -17,7 +19,6 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - "github.com/sagernet/sing/common/x/list" mDNS "github.com/miekg/dns" ) @@ -30,17 +31,21 @@ func RegisterTLS(registry *dns.TransportRegistry) { type TLSTransport struct { *BaseTransport - - dialer tls.Dialer - serverAddr M.Socksaddr - tlsConfig tls.Config - access sync.Mutex - connections list.List[*tlsDNSConn] -} - -type tlsDNSConn struct { - tls.Conn - queryId uint16 + logger logger.ContextLogger + dialer tls.Dialer + serverAddr M.Socksaddr + tlsConfig tls.Config + connections *expiringpool.ExpiringPool[*reuseableDNSConn] + enablePipeline bool + idleTimeout time.Duration + disableKeepAlive bool + maxQueries int + activeConns []*reuseableDNSConn + activeAccess sync.Mutex + pipelineDetected int32 + consecutiveOutOfOrder int32 + outOfOrderCount int32 + totalResponses int32 } func NewTLS(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteTLSDNSServerOptions) (adapter.DNSTransport, error) { @@ -61,16 +66,49 @@ func NewTLS(ctx context.Context, logger log.ContextLogger, tag string, options o if !serverAddr.IsValid() { return nil, E.New("invalid server address: ", serverAddr) } - return NewTLSRaw(logger, dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTLS, tag, options.RemoteDNSServerOptions), transportDialer, serverAddr, tlsConfig), nil + var poolIdleTimeout time.Duration + if options.DisableTCPKeepAlive { + poolIdleTimeout = 2 * time.Minute + } else { + var keepAliveIdle, keepAliveInterval time.Duration + if options.TCPKeepAlive != 0 { + keepAliveIdle = time.Duration(options.TCPKeepAlive) + } else { + keepAliveIdle = C.TCPKeepAliveInitial + } + if options.TCPKeepAliveInterval != 0 { + keepAliveInterval = time.Duration(options.TCPKeepAliveInterval) + } else { + keepAliveInterval = C.TCPKeepAliveInterval + } + poolIdleTimeout = keepAliveIdle + keepAliveInterval + } + maxQueries := options.MaxQueries + if maxQueries <= 0 { + maxQueries = 0 + } + if !options.Pipeline && maxQueries > 0 { + maxQueries = 0 + } + return NewTLSRaw(ctx, logger, dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTLS, tag, options.RemoteDNSServerOptions), transportDialer, serverAddr, tlsConfig, options.Pipeline, poolIdleTimeout, options.DisableTCPKeepAlive, maxQueries), nil } -func NewTLSRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socksaddr, tlsConfig tls.Config) *TLSTransport { - return &TLSTransport{ - BaseTransport: NewBaseTransport(adapter, logger), - dialer: tls.NewDialer(dialer, tlsConfig), - serverAddr: serverAddr, - tlsConfig: tlsConfig, +func NewTLSRaw(ctx context.Context, logger logger.ContextLogger, adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socksaddr, tlsConfig tls.Config, enablePipeline bool, idleTimeout time.Duration, disableKeepAlive bool, maxQueries int) *TLSTransport { + transport := &TLSTransport{ + BaseTransport: NewBaseTransport(adapter, logger), + logger: logger, + dialer: tls.NewDialer(dialer, tlsConfig), + serverAddr: serverAddr, + tlsConfig: tlsConfig, + enablePipeline: enablePipeline, + idleTimeout: idleTimeout, + disableKeepAlive: disableKeepAlive, + maxQueries: maxQueries, } + transport.connections = expiringpool.New(ctx, idleTimeout, func(conn *reuseableDNSConn) { + conn.Close() + }) + return transport } func (t *TLSTransport) Start(stage adapter.StartStage) error { @@ -81,26 +119,120 @@ func (t *TLSTransport) Start(stage adapter.StartStage) error { if err != nil { return err } + if t.connections != nil { + t.connections.Start() + } return dialer.InitializeDetour(t.dialer) } func (t *TLSTransport) Close() error { - t.access.Lock() - for connection := t.connections.Front(); connection != nil; connection = connection.Next() { - connection.Value.Close() + if t.connections != nil { + t.connections.Close() } - t.connections.Init() - t.access.Unlock() return t.BaseTransport.Close() } func (t *TLSTransport) Reset() { - t.access.Lock() - defer t.access.Unlock() - for connection := t.connections.Front(); connection != nil; connection = connection.Next() { - connection.Value.Close() +} + +func (t *TLSTransport) getValidConnFromPool() *reuseableDNSConn { + conn := t.connections.Get() + if conn == nil { + return nil + } + + select { + case <-conn.done: + return nil + default: + return conn } - t.connections.Init() +} + +func (t *TLSTransport) findAndReserveActiveConn() *reuseableDNSConn { + t.activeAccess.Lock() + defer t.activeAccess.Unlock() + + var bestConn *reuseableDNSConn + var minQueries int32 = -1 + var closedCount int + + for _, conn := range t.activeConns { + select { + case <-conn.done: + closedCount++ + default: + if conn.maxQueries <= 0 || atomic.LoadInt32(&conn.activeQueries) < int32(conn.maxQueries) { + current := atomic.LoadInt32(&conn.activeQueries) + if minQueries == -1 || current < minQueries { + minQueries = current + bestConn = conn + } + } + } + } + + if bestConn != nil && minQueries == 0 && closedCount == 0 { + atomic.AddInt32(&bestConn.activeQueries, 1) + return bestConn + } + + if closedCount > 0 { + validConns := make([]*reuseableDNSConn, 0, len(t.activeConns)-closedCount) + for _, conn := range t.activeConns { + select { + case <-conn.done: + default: + validConns = append(validConns, conn) + } + } + t.activeConns = validConns + } + + if bestConn != nil { + atomic.AddInt32(&bestConn.activeQueries, 1) + } + + return bestConn +} + +func (t *TLSTransport) addActiveConn(conn *reuseableDNSConn) { + t.activeAccess.Lock() + defer t.activeAccess.Unlock() + + for _, c := range t.activeConns { + if c == conn { + return + } + } + + t.activeConns = append(t.activeConns, conn) +} + +func (t *TLSTransport) removeActiveConn(conn *reuseableDNSConn) { + t.activeAccess.Lock() + defer t.activeAccess.Unlock() + + for i, c := range t.activeConns { + if c == conn { + last := len(t.activeConns) - 1 + t.activeConns[i] = t.activeConns[last] + t.activeConns = t.activeConns[:last] + return + } + } +} + +func (t *TLSTransport) markPipelineDetected() bool { + return atomic.CompareAndSwapInt32(&t.pipelineDetected, 0, 1) +} + +func (t *TLSTransport) isPipelineDetected() bool { + return atomic.LoadInt32(&t.pipelineDetected) != 0 +} + +func (t *TLSTransport) getDetectionCounters() (*int32, *int32, *int32) { + return &t.consecutiveOutOfOrder, &t.outOfOrderCount, &t.totalResponses } func (t *TLSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { @@ -109,46 +241,59 @@ func (t *TLSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M } defer t.EndQuery() - t.access.Lock() - conn := t.connections.PopFront() - t.access.Unlock() - if conn != nil { - response, err := t.exchange(ctx, message, conn) - if err == nil { - return response, nil - } - t.Logger.DebugContext(ctx, "discarded pooled connection: ", err) + if t.connections == nil { + return t.createNewConnection(ctx, message) } - tlsConn, err := t.dialer.DialTLSContext(ctx, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial TLS connection") + + if t.enablePipeline { + if t.maxQueries == 0 { + conn := t.getValidConnFromPool() + if conn != nil { + return conn.Exchange(ctx, message) + } + return t.createNewConnection(ctx, message) + } else { + conn := t.findAndReserveActiveConn() + if conn != nil { + return conn.exchangeWithoutIncrement(ctx, message) + } + + conn = t.getValidConnFromPool() + if conn != nil { + t.addActiveConn(conn) + return conn.Exchange(ctx, message) + } + + return t.createNewConnection(ctx, message) + } + } else { + conn := t.getValidConnFromPool() + if conn != nil { + response, err := conn.Exchange(ctx, message) + if err == nil { + return response, nil + } + } + return t.createNewConnection(ctx, message) } - return t.exchange(ctx, message, &tlsDNSConn{Conn: tlsConn}) } -func (t *TLSTransport) exchange(ctx context.Context, message *mDNS.Msg, conn *tlsDNSConn) (*mDNS.Msg, error) { - if deadline, ok := ctx.Deadline(); ok { - conn.SetDeadline(deadline) - } - conn.queryId++ - err := WriteMessage(conn, conn.queryId, message) +func (t *TLSTransport) createNewConnection(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + tlsConn, err := t.dialer.DialTLSContext(ctx, t.serverAddr) if err != nil { - conn.Close() - return nil, E.Cause(err, "write request") + return nil, E.Cause(err, "dial TLS connection") } - response, err := ReadMessage(conn) - if err != nil { - conn.Close() - return nil, E.Cause(err, "read response") + var connIdleTimeout time.Duration + if t.connections != nil && t.disableKeepAlive { + connIdleTimeout = t.idleTimeout } - t.access.Lock() - if t.State() >= StateClosing { - t.access.Unlock() - conn.Close() - return response, nil + conn := newReuseableDNSConn(tlsConn, t.logger, t.enablePipeline, connIdleTimeout, t.maxQueries, t.connections, t) + + if t.connections == nil { + defer conn.Close() + } else if t.enablePipeline && t.maxQueries > 0 { + t.addActiveConn(conn) } - conn.SetDeadline(time.Time{}) - t.connections.PushBack(conn) - t.access.Unlock() - return response, nil + + return conn.Exchange(ctx, message) } diff --git a/docs/changelog.md b/docs/changelog.md index 29c4860597..6c3d8759c2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,31 @@ icon: material/alert-decagram --- +#### 1.14.0-alpha.1 + +* Add `source_mac_address` and `source_hostname` rule items **1** +* Add `include_mac_address` and `exclude_mac_address` TUN options **2** +* Update NaiveProxy to 145.0.7632.159 **3** +* Fixes and improvements + +**1**: + +New rule items for matching LAN devices by MAC address and hostname via neighbor resolution. +Supported on Linux, macOS, or in graphical clients on Android and macOS. + +See [Route Rule](/configuration/route/rule/#source_mac_address), [DNS Rule](/configuration/dns/rule/#source_mac_address) and [Neighbor Resolution](/configuration/shared/neighbor/). + +**2**: + +Limit or exclude devices from TUN routing by MAC address. +Only supported on Linux with `auto_route` and `auto_redirect` enabled. + +See [TUN](/configuration/inbound/tun/#include_mac_address). + +**3**: + +This is not an official update from NaiveProxy. Instead, it's a Chromium codebase update maintained by Project S. + #### 1.13.2 * Fixes and improvements diff --git a/docs/configuration/dns/index.md b/docs/configuration/dns/index.md index c6750a01bb..3d55d87d8b 100644 --- a/docs/configuration/dns/index.md +++ b/docs/configuration/dns/index.md @@ -24,7 +24,10 @@ icon: material/alert-decagram "disable_cache": false, "disable_expire": false, "independent_cache": false, + "round_robin_cache": false, "cache_capacity": 0, + "min_cache_ttl": 0, + "max_cache_ttl": 0, "reverse_mapping": false, "client_subnet": "", "fakeip": {} @@ -65,6 +68,10 @@ Disable dns cache expire. Make each DNS server's cache independent for special purposes. If enabled, will slightly degrade performance. +#### round_robin_cache + +Make the order of cached response addresses rotated in round robin manner. + #### cache_capacity !!! question "Since sing-box 1.11.0" @@ -73,6 +80,14 @@ LRU cache capacity. Value less than 1024 will be ignored. +#### min_cache_ttl + +Extend short TTL values to the time given when caching them. + +#### max_cache_ttl + +Set a maximum TTL value for entries in the cache. + #### reverse_mapping Stores a reverse mapping of IP addresses after responding to a DNS query in order to provide domain names when routing. diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 6407e1bf60..cfd9c6ecae 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -2,6 +2,11 @@ icon: material/alert-decagram --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [source_mac_address](#source_mac_address) + :material-plus: [source_hostname](#source_hostname) + !!! quote "Changes in sing-box 1.13.0" :material-plus: [interface_address](#interface_address) @@ -130,7 +135,9 @@ icon: material/alert-decagram "user_id": [ 1000 ], - "clash_mode": "direct", + "clash_mode": [ + "direct" + ], "network_type": [ "wifi" ], @@ -149,6 +156,12 @@ icon: material/alert-decagram "default_interface_address": [ "2000::/3" ], + "source_mac_address": [ + "00:11:22:33:44:55" + ], + "source_hostname": [ + "my-device" + ], "wifi_ssid": [ "My WIFI" ], @@ -408,6 +421,26 @@ Matches network interface (same values as `network_type`) address. Match default interface address. +#### source_mac_address + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. + +Match source device MAC address. + +#### source_hostname + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. + +Match source device hostname from DHCP leases. + #### wifi_ssid !!! quote "" diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index 588e0736a4..36bad88a90 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -2,6 +2,11 @@ icon: material/alert-decagram --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [source_mac_address](#source_mac_address) + :material-plus: [source_hostname](#source_hostname) + !!! quote "sing-box 1.13.0 中的更改" :material-plus: [interface_address](#interface_address) @@ -130,7 +135,9 @@ icon: material/alert-decagram "user_id": [ 1000 ], - "clash_mode": "direct", + "clash_mode": [ + "direct" + ], "network_type": [ "wifi" ], @@ -149,6 +156,12 @@ icon: material/alert-decagram "default_interface_address": [ "2000::/3" ], + "source_mac_address": [ + "00:11:22:33:44:55" + ], + "source_hostname": [ + "my-device" + ], "wifi_ssid": [ "My WIFI" ], @@ -407,6 +420,26 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 匹配默认接口地址。 +#### source_mac_address + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux、macOS,或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 + +匹配源设备 MAC 地址。 + +#### source_hostname + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux、macOS,或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 + +匹配源设备从 DHCP 租约获取的主机名。 + #### wifi_ssid !!! quote "" diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index db9033f8b7..412fd9f059 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -18,7 +18,8 @@ icon: material/new-box "strategy": "", "disable_cache": false, "rewrite_ttl": null, - "client_subnet": null + "client_subnet": null, + "lazy_cache_ttl": null } ``` @@ -54,6 +55,12 @@ If value is an IP address instead of prefix, `/32` or `/128` will be appended au Will overrides `dns.client_subnet`. +#### lazy_cache_ttl + +Serve expired cached response with given extra ttl for this rule. It will attempt to refresh the query in the background. + +Takes priority over the global `dns.lazy_cache_ttl` setting. + ### route-options ```json @@ -61,7 +68,8 @@ Will overrides `dns.client_subnet`. "action": "route-options", "disable_cache": false, "rewrite_ttl": null, - "client_subnet": null + "client_subnet": null, + "lazy_cache_ttl": null } ``` diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index 9e59c6bd2b..170bf04d38 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -18,7 +18,8 @@ icon: material/new-box "strategy": "", "disable_cache": false, "rewrite_ttl": null, - "client_subnet": null + "client_subnet": null, + "lazy_cache_ttl": null } ``` @@ -54,6 +55,12 @@ icon: material/new-box 将覆盖 `dns.client_subnet`. +#### lazy_cache_ttl + +为此规则提供已过期的缓存响应,并使用给定的额外 TTL。它将尝试在后台刷新查询。 + +优先级高于全局的 `dns.lazy_cache_ttl` 设置。 + ### route-options ```json @@ -61,7 +68,8 @@ icon: material/new-box "action": "route-options", "disable_cache": false, "rewrite_ttl": null, - "client_subnet": null + "client_subnet": null, + "lazy_cache_ttl": null } ``` diff --git a/docs/configuration/dns/server/group.md b/docs/configuration/dns/server/group.md new file mode 100644 index 0000000000..20bc8621f9 --- /dev/null +++ b/docs/configuration/dns/server/group.md @@ -0,0 +1,40 @@ +--- +icon: material/new-box +--- + +# Group + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "group", + "tag": "dns-group", + + "servers": [ + "dns-a", + "dns-b" + ] + } + ] + } +} +``` + +### Fields + +#### servers + +==Required== + +List of DNS server tags to include in this group. + +Restrictions: + +- A group cannot contain another group. +- A group cannot contain a `fakeip` server. + +When queried, all servers in the group are queried concurrently, and the first successful response is returned. diff --git a/docs/configuration/dns/server/group.zh.md b/docs/configuration/dns/server/group.zh.md new file mode 100644 index 0000000000..4b7b3a0048 --- /dev/null +++ b/docs/configuration/dns/server/group.zh.md @@ -0,0 +1,40 @@ +--- +icon: material/new-box +--- + +# Group + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "group", + "tag": "dns-group", + + "servers": [ + "dns-a", + "dns-b" + ] + } + ] + } +} +``` + +### 字段 + +#### servers + +==必填== + +此组包含的 DNS 服务器 tag 列表。 + +限制: + +- 组内不能包含另一个组。 +- 组内不能包含 `fakeip` 类型的服务器。 + +查询时,组内所有服务器将被并发查询,最先返回的成功响应将作为结果使用。 diff --git a/docs/configuration/dns/server/http3.md b/docs/configuration/dns/server/http3.md index dd81ba2dae..5d6589f610 100644 --- a/docs/configuration/dns/server/http3.md +++ b/docs/configuration/dns/server/http3.md @@ -20,6 +20,7 @@ icon: material/new-box "server_port": 443, "path": "", + "method": "", "headers": {}, "tls": {}, @@ -58,6 +59,14 @@ The path of the DNS server. `/dns-query` will be used by default. +#### method + +The method of the DNS server. + +Only `GET` and `POST` are supported. + +`POST` will be used by default. + #### headers Additional headers to be sent to the DNS server. diff --git a/docs/configuration/dns/server/https.md b/docs/configuration/dns/server/https.md index 46e69a558e..10f2041ef1 100644 --- a/docs/configuration/dns/server/https.md +++ b/docs/configuration/dns/server/https.md @@ -20,6 +20,7 @@ icon: material/new-box "server_port": 443, "path": "", + "method": "", "headers": {}, "tls": {}, @@ -58,6 +59,14 @@ The path of the DNS server. `/dns-query` will be used by default. +#### method + +The method of the DNS server. + +Only `GET` and `POST` are supported. + +`POST` will be used by default. + #### headers Additional headers to be sent to the DNS server. diff --git a/docs/configuration/dns/server/index.md b/docs/configuration/dns/server/index.md index 4f10948e58..5a70d6a059 100644 --- a/docs/configuration/dns/server/index.md +++ b/docs/configuration/dns/server/index.md @@ -42,6 +42,7 @@ The type of the DNS server. | `fakeip` | [Fake IP](./fakeip/) | | `tailscale` | [Tailscale](./tailscale/) | | `resolved` | [Resolved](./resolved/) | +| `group` | [Group](./group/) | #### tag diff --git a/docs/configuration/dns/server/index.zh.md b/docs/configuration/dns/server/index.zh.md index d6deef5a33..a48c8aeaf3 100644 --- a/docs/configuration/dns/server/index.zh.md +++ b/docs/configuration/dns/server/index.zh.md @@ -42,6 +42,7 @@ DNS 服务器的类型。 | `fakeip` | [Fake IP](./fakeip/) | | `tailscale` | [Tailscale](./tailscale/) | | `resolved` | [Resolved](./resolved/) | +| `group` | [Group](./group/) | #### tag diff --git a/docs/configuration/endpoint/tailscale.md b/docs/configuration/endpoint/tailscale.md index 6cf10e2ba9..2138b5709b 100644 --- a/docs/configuration/endpoint/tailscale.md +++ b/docs/configuration/endpoint/tailscale.md @@ -34,6 +34,7 @@ icon: material/new-box "relay_server_static_endpoints": [], "system_interface": false, "system_interface_name": "", + "system_interface_gso": false, "system_interface_mtu": 0, "udp_timeout": "5m", @@ -136,6 +137,16 @@ Create a system TUN interface for Tailscale. Custom TUN interface name. By default, `tailscale` (or `utun` on macOS) will be used. +#### system_interface_gso + +!!! quote "" + + Only supported on Linux. + +Try to enable generic segmentation offload. + +Enabled by default when `system_interface` is true. + #### system_interface_mtu !!! question "Since sing-box 1.13.0" diff --git a/docs/configuration/endpoint/tailscale.zh.md b/docs/configuration/endpoint/tailscale.zh.md index f881dd67f2..2bb007fafc 100644 --- a/docs/configuration/endpoint/tailscale.zh.md +++ b/docs/configuration/endpoint/tailscale.zh.md @@ -34,6 +34,7 @@ icon: material/new-box "relay_server_static_endpoints": [], "system_interface": false, "system_interface_name": "", + "system_interface_gso": false, "system_interface_mtu": 0, "udp_timeout": "5m", @@ -135,6 +136,16 @@ icon: material/new-box 自定义 TUN 接口名。默认使用 `tailscale`(macOS 上为 `utun`)。 +#### system_interface_gso + +!!! quote "" + + 仅支持 Linux。 + +尝试启用通用分段卸载。 + +当 `system_interface` 为 true 时,默认启用。 + #### system_interface_mtu !!! question "自 sing-box 1.13.0 起" diff --git a/docs/configuration/endpoint/wireguard.md b/docs/configuration/endpoint/wireguard.md index dc3b82289a..3cc1c08a99 100644 --- a/docs/configuration/endpoint/wireguard.md +++ b/docs/configuration/endpoint/wireguard.md @@ -9,6 +9,7 @@ "system": false, "name": "", + "gso": false, "mtu": 1408, "address": [], "private_key": "", @@ -47,6 +48,16 @@ Requires privilege and cannot conflict with exists system interfaces. Custom interface name for system interface. +#### gso + +!!! quote "" + + Only supported on Linux. + +Try to enable generic segmentation offload. + +Enabled by default when `system` is true. + #### mtu WireGuard MTU. diff --git a/docs/configuration/endpoint/wireguard.zh.md b/docs/configuration/endpoint/wireguard.zh.md index 1935135f87..04af65a2d7 100644 --- a/docs/configuration/endpoint/wireguard.zh.md +++ b/docs/configuration/endpoint/wireguard.zh.md @@ -9,6 +9,7 @@ "system": false, "name": "", + "gso": false, "mtu": 1408, "address": [], "private_key": "", @@ -47,6 +48,16 @@ 为系统接口自定义设备名称。 +#### gso + +!!! quote "" + + 仅支持 Linux。 + +尝试启用通用分段卸载。 + +当 `system` 为 true 时,默认启用。 + #### mtu WireGuard MTU。 diff --git a/docs/configuration/experimental/index.md b/docs/configuration/experimental/index.md index a1a515cf85..c3785b8417 100644 --- a/docs/configuration/experimental/index.md +++ b/docs/configuration/experimental/index.md @@ -13,14 +13,16 @@ "cache_file": {}, "clash_api": {}, "v2ray_api": {} - } + }, + "urltest_unified_delay": true } ``` ### Fields -| Key | Format | -|--------------|----------------------------| -| `cache_file` | [Cache File](./cache-file/) | -| `clash_api` | [Clash API](./clash-api/) | -| `v2ray_api` | [V2Ray API](./v2ray-api/) | \ No newline at end of file +| Key | Format | +|-------------------------|-------------------------------------------| +| `cache_file` | [Cache File](./cache-file/) | +| `clash_api` | [Clash API](./clash-api/) | +| `v2ray_api` | [V2Ray API](./v2ray-api/) | +| `urltest_unified_delay` | [Unified Delay](./urltest-unified-delay/) | \ No newline at end of file diff --git a/docs/configuration/experimental/index.zh.md b/docs/configuration/experimental/index.zh.md index 01246c44ef..fbae161091 100644 --- a/docs/configuration/experimental/index.zh.md +++ b/docs/configuration/experimental/index.zh.md @@ -13,14 +13,16 @@ "cache_file": {}, "clash_api": {}, "v2ray_api": {} - } + }, + "urltest_unified_delay": true } ``` ### 字段 -| 键 | 格式 | -|--------------|--------------------------| -| `cache_file` | [缓存文件](./cache-file/) | -| `clash_api` | [Clash API](./clash-api/) | -| `v2ray_api` | [V2Ray API](./v2ray-api/) | \ No newline at end of file +| 键 | 格式 | +|-------------------------|--------------------------------------| +| `cache_file` | [缓存文件](./cache-file/) | +| `clash_api` | [Clash API](./clash-api/) | +| `v2ray_api` | [V2Ray API](./v2ray-api/) | +| `urltest_unified_delay` | [统一延迟](./urltest-unified-delay/) | \ No newline at end of file diff --git a/docs/configuration/experimental/urltest-unified-delay.md b/docs/configuration/experimental/urltest-unified-delay.md new file mode 100644 index 0000000000..ac15dbcf87 --- /dev/null +++ b/docs/configuration/experimental/urltest-unified-delay.md @@ -0,0 +1,13 @@ +### Structure + +```json +{ + "urltest_unified_delay": true +} +``` + +### Fields + +#### urltest_unified_delay + +When unified delay is enabled, two delay tests are conducted to eliminate latency differences caused by connection handshakes and other variations in different types of nodes. \ No newline at end of file diff --git a/docs/configuration/experimental/urltest-unified-delay.zh.md b/docs/configuration/experimental/urltest-unified-delay.zh.md new file mode 100644 index 0000000000..6d4f0c8619 --- /dev/null +++ b/docs/configuration/experimental/urltest-unified-delay.zh.md @@ -0,0 +1,13 @@ +### 结构 + +```json +{ + "urltest_unified_delay": true +} +``` + +### 字段 + +#### urltest_unified_delay + +开启统一延迟时,会计算 RTT,以消除连接握手等带来的不同类型节点的延迟差异。 \ No newline at end of file diff --git a/docs/configuration/inbound/anytls.md b/docs/configuration/inbound/anytls.md index f3780119f6..b40bf2e6f1 100644 --- a/docs/configuration/inbound/anytls.md +++ b/docs/configuration/inbound/anytls.md @@ -20,7 +20,17 @@ icon: material/new-box } ], "padding_scheme": [], - "tls": {} + "tls": {}, + "fallback": { + "server": "127.0.0.1", + "server_port": 8080 + }, + "fallback_for_alpn": { + "http/1.1": { + "server": "127.0.0.1", + "server_port": 8081 + } + } } ``` @@ -59,3 +69,17 @@ Default padding scheme: #### tls TLS configuration, see [TLS](/configuration/shared/tls/#inbound). + +#### fallback + +!!! failure "" + + There is no evidence that GFW detects and blocks AnyTLS servers based on HTTP responses, and opening the standard http/s port on the server is a much bigger signature. + +Fallback server configuration. Disabled if `fallback` and `fallback_for_alpn` are empty. + +#### fallback_for_alpn + +Fallback server configuration for specified ALPN. + +If not empty, TLS fallback requests with ALPN not in this table will be rejected. diff --git a/docs/configuration/inbound/anytls.zh.md b/docs/configuration/inbound/anytls.zh.md index 55b6749ed1..dd8805f32a 100644 --- a/docs/configuration/inbound/anytls.zh.md +++ b/docs/configuration/inbound/anytls.zh.md @@ -20,7 +20,17 @@ icon: material/new-box } ], "padding_scheme": [], - "tls": {} + "tls": {}, + "fallback": { + "server": "127.0.0.1", + "server_port": 8080 + }, + "fallback_for_alpn": { + "http/1.1": { + "server": "127.0.0.1", + "server_port": 8081 + } + } } ``` @@ -59,3 +69,17 @@ AnyTLS 填充方案行数组。 #### tls TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 + +#### fallback + +!!! quote "" + + 没有证据表明 GFW 基于 HTTP 响应检测并阻止 AnyTLS 服务器,并且在服务器上打开标准 http/s 端口是一个更大的特征。 + +回退服务器配置。如果 `fallback` 和 `fallback_for_alpn` 为空,则禁用回退。 + +#### fallback_for_alpn + +为 ALPN 指定回退服务器配置。 + +如果不为空,ALPN 不在此列表中的 TLS 回退请求将被拒绝。 diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md index 7e67e488c5..25008b67ce 100644 --- a/docs/configuration/inbound/tun.md +++ b/docs/configuration/inbound/tun.md @@ -2,6 +2,11 @@ icon: material/new-box --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [include_mac_address](#include_mac_address) + :material-plus: [exclude_mac_address](#exclude_mac_address) + !!! quote "Changes in sing-box 1.13.0" :material-plus: [auto_redirect_reset_mark](#auto_redirect_reset_mark) @@ -125,6 +130,12 @@ icon: material/new-box "exclude_package": [ "com.android.captiveportallogin" ], + "include_mac_address": [ + "00:11:22:33:44:55" + ], + "exclude_mac_address": [ + "66:77:88:99:aa:bb" + ], "platform": { "http_proxy": { "enabled": false, @@ -548,6 +559,30 @@ Limit android packages in route. Exclude android packages in route. +#### include_mac_address + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux with `auto_route` and `auto_redirect` enabled. + +Limit MAC addresses in route. Not limited by default. + +Conflict with `exclude_mac_address`. + +#### exclude_mac_address + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux with `auto_route` and `auto_redirect` enabled. + +Exclude MAC addresses in route. + +Conflict with `include_mac_address`. + #### platform Platform-specific settings, provided by client applications. diff --git a/docs/configuration/inbound/tun.zh.md b/docs/configuration/inbound/tun.zh.md index d8520aedbd..5b6b95ccaf 100644 --- a/docs/configuration/inbound/tun.zh.md +++ b/docs/configuration/inbound/tun.zh.md @@ -2,6 +2,11 @@ icon: material/new-box --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [include_mac_address](#include_mac_address) + :material-plus: [exclude_mac_address](#exclude_mac_address) + !!! quote "sing-box 1.13.0 中的更改" :material-plus: [auto_redirect_reset_mark](#auto_redirect_reset_mark) @@ -126,6 +131,12 @@ icon: material/new-box "exclude_package": [ "com.android.captiveportallogin" ], + "include_mac_address": [ + "00:11:22:33:44:55" + ], + "exclude_mac_address": [ + "66:77:88:99:aa:bb" + ], "platform": { "http_proxy": { "enabled": false, @@ -536,6 +547,30 @@ TCP/IP 栈。 排除路由的 Android 应用包名。 +#### include_mac_address + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux,且需要 `auto_route` 和 `auto_redirect` 已启用。 + +限制被路由的 MAC 地址。默认不限制。 + +与 `exclude_mac_address` 冲突。 + +#### exclude_mac_address + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux,且需要 `auto_route` 和 `auto_redirect` 已启用。 + +排除路由的 MAC 地址。 + +与 `include_mac_address` 冲突。 + #### platform 平台特定的设置,由客户端应用提供。 diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 1f6eec1375..572ab9d361 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -13,6 +13,7 @@ sing-box uses JSON for configuration files. "endpoints": [], "inbounds": [], "outbounds": [], + "providers": [], "route": {}, "services": [], "experimental": {} @@ -30,6 +31,7 @@ sing-box uses JSON for configuration files. | `endpoints` | [Endpoint](./endpoint/) | | `inbounds` | [Inbound](./inbound/) | | `outbounds` | [Outbound](./outbound/) | +| `providers` | [Provider](./provider/) | | `route` | [Route](./route/) | | `services` | [Service](./service/) | | `experimental` | [Experimental](./experimental/) | diff --git a/docs/configuration/index.zh.md b/docs/configuration/index.zh.md index 3bdc352187..e794af99b4 100644 --- a/docs/configuration/index.zh.md +++ b/docs/configuration/index.zh.md @@ -13,6 +13,7 @@ sing-box 使用 JSON 作为配置文件格式。 "endpoints": [], "inbounds": [], "outbounds": [], + "providers": [], "route": {}, "services": [], "experimental": {} @@ -30,6 +31,7 @@ sing-box 使用 JSON 作为配置文件格式。 | `endpoints` | [端点](./endpoint/) | | `inbounds` | [入站](./inbound/) | | `outbounds` | [出站](./outbound/) | +| `providers` | [提供者](./provider/) | | `route` | [路由](./route/) | | `services` | [服务](./service/) | | `experimental` | [实验性](./experimental/) | diff --git a/docs/configuration/outbound/direct.md b/docs/configuration/outbound/direct.md index 3e28db8fc6..071489445c 100644 --- a/docs/configuration/outbound/direct.md +++ b/docs/configuration/outbound/direct.md @@ -18,6 +18,7 @@ icon: material/alert-decagram "override_address": "1.0.0.1", "override_port": 53, + "proxy_protocol": 0, ... // Dial Fields } @@ -41,6 +42,10 @@ Override the connection destination address. Override the connection destination port. +#### proxy_protocol + +Write [Proxy Protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) in the connection header. + Protocol value can be `1` or `2`. ### Dial Fields diff --git a/docs/configuration/outbound/direct.zh.md b/docs/configuration/outbound/direct.zh.md index 55d3bf8c2d..99dda473da 100644 --- a/docs/configuration/outbound/direct.zh.md +++ b/docs/configuration/outbound/direct.zh.md @@ -18,6 +18,7 @@ icon: material/alert-decagram "override_address": "1.0.0.1", "override_port": 53, + "proxy_protocol": 0, ... // 拨号字段 } @@ -41,6 +42,12 @@ icon: material/alert-decagram 覆盖连接目标端口。 +#### proxy_protocol + +写出 [代理协议](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) 到连接头。 + +可用协议版本值:`1` 或 `2`。 + ### 拨号字段 参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/outbound/fallback.md b/docs/configuration/outbound/fallback.md new file mode 100644 index 0000000000..37950a03fa --- /dev/null +++ b/docs/configuration/outbound/fallback.md @@ -0,0 +1,85 @@ +### Structure + +```json +{ + "type": "urltest", + "tag": "fallback", + + "outbounds": [ + "proxy-a", + "proxy-b", + "proxy-c" + ], + "providers": [ + "provider-a", + "provider-b" + ], + "fallback": { + "enabled": true, + "max_delay": "200ms" + }, + "exclude": "", + "include": "", + "url": "", + "interval": "", + "idle_timeout": "", + "ttl": "10m", + "use_all_providers": false, + "interrupt_exist_connections": false +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Fields + +#### outbounds + +List of outbound tags to test. + +#### providers + +List of [Provider](/configuration/provider) tags to test. + +#### fallback +If the current node times out, the first available node will be selected in the order of proxies. + +- `enabled` Indicates whether to enable Automatic rollback. + +- `max_delay` is an optional configuration. If a node is available but its delay exceeds this value, the node is considered unavailable, discarded, and the matching continues to select the next node. However, if all nodes are unavailable, but there is a node that has been eliminated by this rule, the node with the lowest delay is selected. + +#### exclude + +Exclude regular expression to filter `providers` nodes. + +#### include + +Include regular expression to filter `providers` nodes. + +#### url + +The URL to test. `https://www.gstatic.com/generate_204` will be used if empty. + +#### interval + +The test interval. `3m` will be used if empty. + +#### idle_timeout + +The idle timeout. `30m` will be used if empty. + +#### ttl + +The time to live used for `sticky-sessions` strategy timeout. `10m` will be used if empty. + +#### use_all_providers + +Whether to use all providers for testing. `false` will be used if empty. + +#### interrupt_exist_connections + +Interrupt existing connections when the selected outbound has changed. + +Only inbound connections are affected by this setting, internal connections will always be interrupted. diff --git a/docs/configuration/outbound/fallback.zh.md b/docs/configuration/outbound/fallback.zh.md new file mode 100644 index 0000000000..53c68bef95 --- /dev/null +++ b/docs/configuration/outbound/fallback.zh.md @@ -0,0 +1,85 @@ +### 结构 + +```json +{ + "type": "urltest", + "tag": "fallback", + + "outbounds": [ + "proxy-a", + "proxy-b", + "proxy-c" + ], + "providers": [ + "provider-a", + "provider-b" + ], + "fallback": { + "enabled": true, + "max_delay": "200ms" + }, + "exclude": "", + "include": "", + "url": "", + "interval": "", + "idle_timeout": "", + "ttl": "10m", + "use_all_providers": false, + "interrupt_exist_connections": false +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签。 + +### 字段 + +#### outbounds + +用于测试的出站标签列表。 + +#### providers + +用于测试的[订阅](/zh/configuration/provider)标签列表。 + +#### fallback +当前节点超时时,则会按代理顺序选择第一个可用节点。 + +- `enabled` 是否开启自动回退。 + +- `max_delay` 为可选配置。若某节点可用,但是延迟超过该值,则认为该节点不可用,淘汰忽略该节点,继续匹配选择下一个节点,但若所有节点均不可用,但是存在被该规则淘汰的节点,则选择延迟最低的被淘汰节点。 + +#### exclude + +排除 `providers` 节点的正则表达式。 + +#### include + +包含 `providers` 节点的正则表达式。 + +#### url + +用于测试的链接。默认使用 `https://www.gstatic.com/generate_204`。 + +#### interval + +测试间隔。 默认使用 `3m`。 + +#### idle_timeout + +空闲超时。默认使用 `30m`。 + +#### ttl + +用于 `sticky-sessions` 策略超时的生存时间。默认使用 `10m`。 + +#### use_all_providers + +是否使用所有提供者。默认使用 `false`。 + +#### interrupt_exist_connections + +当选定的出站发生更改时,中断现有连接。 + +仅入站连接受此设置影响,内部连接将始终被中断。 diff --git a/docs/configuration/outbound/index.md b/docs/configuration/outbound/index.md index 47b8a96a5c..79a4193ac1 100644 --- a/docs/configuration/outbound/index.md +++ b/docs/configuration/outbound/index.md @@ -37,6 +37,8 @@ | `selector` | [Selector](./selector/) | | `urltest` | [URLTest](./urltest/) | | `naive` | [NaiveProxy](./naive/) | +| `loadbalance` | [LoadBalance](./loadbalance/) | +| `fallback` | [FallBack](./fallback/) | #### tag diff --git a/docs/configuration/outbound/index.zh.md b/docs/configuration/outbound/index.zh.md index a1c4a7addc..d8c2292336 100644 --- a/docs/configuration/outbound/index.zh.md +++ b/docs/configuration/outbound/index.zh.md @@ -37,6 +37,8 @@ | `selector` | [Selector](./selector/) | | `urltest` | [URLTest](./urltest/) | | `naive` | [NaiveProxy](./naive/) | +| `loadbalance` | [LoadBalance](./loadbalance/) | +| `fallback` | [FallBack](./fallback/) | #### tag diff --git a/docs/configuration/outbound/loadbalance.md b/docs/configuration/outbound/loadbalance.md new file mode 100644 index 0000000000..b6a3f758d8 --- /dev/null +++ b/docs/configuration/outbound/loadbalance.md @@ -0,0 +1,88 @@ +### Structure + +```json +{ + "type": "loadbalance", + "tag": "balance", + "strategy": "round-robin", + + "outbounds": [ + "proxy-a", + "proxy-b", + "proxy-c" + ], + "providers": [ + "provider-a", + "provider-b" + ], + "exclude": "", + "include": "", + "url": "", + "interval": "", + "idle_timeout": "", + "ttl": "10m", + "use_all_providers": false, + "interrupt_exist_connections": false +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Fields + +#### strategy + +Load Balancing Strategies. + +* `round-robin` will distribute all requests among different proxy nodes within the strategy group. + +* `consistent-hashing` will assign requests with the same `target address` to the same proxy node within the strategy group. + +* `sticky-sessions`: requests with the same `source address` and `target address` will be directed to the same proxy node within the strategy group, with a cache expiration of specified ttl. + +!!! note + When the `target address` is a domain, it uses top-level domain matching. + +#### outbounds + +List of outbound tags to test. + +#### providers + +List of [Provider](/configuration/provider) tags to test. + +#### exclude + +Exclude regular expression to filter `providers` nodes. + +#### include + +Include regular expression to filter `providers` nodes. + +#### url + +The URL to test. `https://www.gstatic.com/generate_204` will be used if empty. + +#### interval + +The test interval. `3m` will be used if empty. + +#### idle_timeout + +The idle timeout. `30m` will be used if empty. + +#### ttl + +The time to live used for `sticky-sessions` strategy timeout. `10m` will be used if empty. + +#### use_all_providers + +Whether to use all providers for testing. `false` will be used if empty. + +#### interrupt_exist_connections + +Interrupt existing connections when the selected outbound has changed. + +Only inbound connections are affected by this setting, internal connections will always be interrupted. diff --git a/docs/configuration/outbound/loadbalance.zh.md b/docs/configuration/outbound/loadbalance.zh.md new file mode 100644 index 0000000000..c8bab73ec8 --- /dev/null +++ b/docs/configuration/outbound/loadbalance.zh.md @@ -0,0 +1,88 @@ +### 结构 + +```json +{ + "type": "loadbalance", + "tag": "balance", + "strategy": "round-robin", + + "outbounds": [ + "proxy-a", + "proxy-b", + "proxy-c" + ], + "providers": [ + "provider-a", + "provider-b" + ], + "exclude": "", + "include": "", + "url": "", + "interval": "", + "idle_timeout": "", + "ttl": "10m", + "use_all_providers": false, + "interrupt_exist_connections": false +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签。 + +### 字段 + +#### strategy + +负载均衡策略。 + +* `round-robin` 将在策略组内的不同代理节点之间分配所有请求。 + +* `consistent-hashing` 将具有相同 `目标地址` 的请求分配给策略组内的同一代理节点。 + +* `sticky-sessions`:具有相同 `源地址` 和 `目标地址` 的请求将被导向策略组内的同一代理节点,缓存过期时间为指定的 ttl。 + +!!! note + 当 `目标地址` 是域名时,使用顶级域名匹配。 + +#### outbounds + +用于测试的出站标签列表。 + +#### providers + +用于测试的[订阅](/zh/configuration/provider)标签列表。 + +#### exclude + +排除 `providers` 节点的正则表达式。 + +#### include + +包含 `providers` 节点的正则表达式。 + +#### url + +用于测试的链接。默认使用 `https://www.gstatic.com/generate_204`。 + +#### interval + +测试间隔。 默认使用 `3m`。 + +#### idle_timeout + +空闲超时。默认使用 `30m`。 + +#### ttl + +用于 `sticky-sessions` 策略超时的生存时间。默认使用 `10m`。 + +#### use_all_providers + +是否使用所有提供者。默认使用 `false`。 + +#### interrupt_exist_connections + +当选定的出站发生更改时,中断现有连接。 + +仅入站连接受此设置影响,内部连接将始终被中断。 diff --git a/docs/configuration/outbound/selector.md b/docs/configuration/outbound/selector.md index ee75358f5b..a596db9e28 100644 --- a/docs/configuration/outbound/selector.md +++ b/docs/configuration/outbound/selector.md @@ -10,7 +10,14 @@ "proxy-b", "proxy-c" ], + "providers": [ + "provider-a", + "provider-b", + ], + "exclude": "", + "include": "", "default": "proxy-c", + "use_all_providers": false, "interrupt_exist_connections": false } ``` @@ -23,14 +30,32 @@ #### outbounds -==Required== - List of outbound tags to select. +#### providers + +List of [Provider](/configuration/provider) tags to select. + +#### use_all_providers + +Use all [Provider](/configuration/provider) to fill `outbounds`. + +#### exclude + +Exclude regular expression to filter `providers` nodes. The priority of the exclude expression is higher than the include expression. + +#### include + +Include regular expression to filter `providers` nodes. + #### default The default outbound tag. The first outbound will be used if empty. +#### use_all_providers + +Whether to use all providers for testing. `false` will be used if empty. + #### interrupt_exist_connections Interrupt existing connections when the selected outbound has changed. diff --git a/docs/configuration/outbound/selector.zh.md b/docs/configuration/outbound/selector.zh.md index ffe2d70ae1..092a5559a8 100644 --- a/docs/configuration/outbound/selector.zh.md +++ b/docs/configuration/outbound/selector.zh.md @@ -10,7 +10,14 @@ "proxy-b", "proxy-c" ], + "providers": [ + "provider-a", + "provider-b", + ], + "exclude": "", + "include": "", "default": "proxy-c", + "use_all_providers": false, "interrupt_exist_connections": false } ``` @@ -23,14 +30,28 @@ #### outbounds -==必填== - 用于选择的出站标签列表。 +#### providers + +用于选择的[订阅](/zh/configuration/provider)标签列表。 + +#### exclude + +排除 `providers` 节点的正则表达式。 + +#### include + +包含 `providers` 节点的正则表达式。 + #### default 默认的出站标签。默认使用第一个出站。 +#### use_all_providers + +是否使用所有提供者。默认使用 `false`。 + #### interrupt_exist_connections 当选定的出站发生更改时,中断现有连接。 diff --git a/docs/configuration/outbound/urltest.md b/docs/configuration/outbound/urltest.md index f4b3b0aa8e..bdff4bf704 100644 --- a/docs/configuration/outbound/urltest.md +++ b/docs/configuration/outbound/urltest.md @@ -10,10 +10,17 @@ "proxy-b", "proxy-c" ], + "providers": [ + "provider-a", + "provider-b", + ], + "exclude": "", + "include": "", "url": "", "interval": "", - "tolerance": 0, + "tolerance": 50, "idle_timeout": "", + "use_all_providers": false, "interrupt_exist_connections": false } ``` @@ -22,10 +29,20 @@ #### outbounds -==Required== - List of outbound tags to test. +#### providers + +List of [Provider](/configuration/provider) tags to test. + +#### exclude + +Exclude regular expression to filter `providers` nodes. + +#### include + +Include regular expression to filter `providers` nodes. + #### url The URL to test. `https://www.gstatic.com/generate_204` will be used if empty. @@ -42,8 +59,13 @@ The test tolerance in milliseconds. `50` will be used if empty. The idle timeout. `30m` will be used if empty. +#### use_all_providers + +Whether to use all providers for testing. `false` will be used if empty. + #### interrupt_exist_connections Interrupt existing connections when the selected outbound has changed. Only inbound connections are affected by this setting, internal connections will always be interrupted. + diff --git a/docs/configuration/outbound/urltest.zh.md b/docs/configuration/outbound/urltest.zh.md index 4372298afc..8a53bc1ede 100644 --- a/docs/configuration/outbound/urltest.zh.md +++ b/docs/configuration/outbound/urltest.zh.md @@ -10,10 +10,17 @@ "proxy-b", "proxy-c" ], + "providers": [ + "provider-a", + "provider-b", + ], + "exclude": "", + "include": "", "url": "", "interval": "", "tolerance": 50, "idle_timeout": "", + "use_all_providers": false, "interrupt_exist_connections": false } ``` @@ -22,10 +29,20 @@ #### outbounds -==必填== - 用于测试的出站标签列表。 +#### providers + +用于测试的[订阅](/zh/configuration/provider)标签列表。 + +#### exclude + +排除 `providers` 节点的正则表达式。 + +#### include + +包含 `providers` 节点的正则表达式。 + #### url 用于测试的链接。默认使用 `https://www.gstatic.com/generate_204`。 @@ -42,8 +59,12 @@ 空闲超时。默认使用 `30m`。 +#### use_all_providers + +是否使用所有提供者。默认使用 `false`。 + #### interrupt_exist_connections 当选定的出站发生更改时,中断现有连接。 -仅入站连接受此设置影响,内部连接将始终被中断。 \ No newline at end of file +仅入站连接受此设置影响,内部连接将始终被中断。 diff --git a/docs/configuration/outbound/wireguard.md b/docs/configuration/outbound/wireguard.md index 648ba60725..52ffb3953b 100644 --- a/docs/configuration/outbound/wireguard.md +++ b/docs/configuration/outbound/wireguard.md @@ -6,10 +6,6 @@ icon: material/delete-clock WireGuard outbound is deprecated and will be removed in sing-box 1.13.0, check [Migration](/migration/#migrate-wireguard-outbound-to-endpoint). -!!! quote "Changes in sing-box 1.11.0" - - :material-delete-alert: [gso](#gso) - !!! quote "Changes in sing-box 1.8.0" :material-plus: [gso](#gso) @@ -25,6 +21,7 @@ icon: material/delete-clock "server_port": 1080, "system_interface": false, "interface_name": "wg0", + "gso": false, "local_address": [ "10.0.0.1/32" ], @@ -48,10 +45,6 @@ icon: material/delete-clock "mtu": 1408, "network": "tcp", - // Deprecated - - "gso": false, - ... // Dial Fields } ``` @@ -84,10 +77,6 @@ Custom interface name for system interface. #### gso -!!! failure "Deprecated in sing-box 1.11.0" - - GSO will be automatically enabled when available since sing-box 1.11.0. - !!! question "Since sing-box 1.8.0" !!! quote "" @@ -96,6 +85,8 @@ Custom interface name for system interface. Try to enable generic segmentation offload. +Enabled by default when `system_interface` is true. + #### local_address ==Required== diff --git a/docs/configuration/outbound/wireguard.zh.md b/docs/configuration/outbound/wireguard.zh.md index 3b22affd4c..ef7c726e34 100644 --- a/docs/configuration/outbound/wireguard.zh.md +++ b/docs/configuration/outbound/wireguard.zh.md @@ -6,10 +6,6 @@ icon: material/delete-clock WireGuard 出站已被弃用,且将在 sing-box 1.13.0 中被移除,参阅 [迁移指南](/migration/#migrate-wireguard-outbound-to-endpoint)。 -!!! quote "sing-box 1.11.0 中的更改" - - :material-delete-alert: [gso](#gso) - !!! quote "sing-box 1.8.0 中的更改" :material-plus: [gso](#gso) @@ -25,6 +21,7 @@ icon: material/delete-clock "server_port": 1080, "system_interface": false, "interface_name": "wg0", + "gso": false, "local_address": [ "10.0.0.1/32" ], @@ -35,10 +32,6 @@ icon: material/delete-clock "workers": 4, "mtu": 1408, "network": "tcp", - - // 废弃的 - - "gso": false, ... // 拨号字段 } @@ -72,10 +65,6 @@ icon: material/delete-clock #### gso -!!! failure "已在 sing-box 1.11.0 废弃" - - 自 sing-box 1.11.0 起,GSO 将可用时自动启用。 - !!! question "自 sing-box 1.8.0 起" !!! quote "" @@ -84,6 +73,8 @@ icon: material/delete-clock 尝试启用通用分段卸载。 +当 `system_interface` 为 true 时,默认启用。 + #### local_address ==必填== diff --git a/docs/configuration/provider/index.md b/docs/configuration/provider/index.md new file mode 100644 index 0000000000..6cdc731538 --- /dev/null +++ b/docs/configuration/provider/index.md @@ -0,0 +1,142 @@ +# Provider + +### Structure + +List of subscription providers. + +=== "Local File" + + ```json + { + "providers": [ + { + "type": "local", + "tag": "provider", + "path": "provider.txt", + "health_check": { + "enabled": false, + "url": "", + "interval": "", + "timeout": "", + }, + "override_dialer": {}, + "override_tls": {} + } + ] + } + ``` + +=== "Remote File" + + ```json + { + "providers": [ + { + "type": "remote", + "tag": "provider", + "health_check": { + "enabled": false, + "url": "", + "interval": "", + "timeout": "", + }, + "url": "", + "exclude": "", + "include": "", + "user_agent": "", + "download_detour": "", + "update_interval": "", + "override_dialer": {}, + "override_tls": {} + } + ] + } + ``` + +### Fields + +#### type + +==Required== + +Type of the provider. `local` or `remote`. + +#### tag + +==Required== + +Tag of the provider. + +The node `node_name` from `provider` will be tagged as `provider/node_name`. + +### Local or Remote Fields + +#### health_check + +Health check configuration. + +##### health_check.enabled + +Health check enabled. + +##### health_check.url + +Health check URL. + +##### health_check.interval + +Health check interval. The minimum value is `1m`, the default value is `10m`. + +##### health_check.timeout + +Health check timeout. the default value is `3s`. + +##### override_dialer + +Override dialer fields of outbounds in provider, see [Dialer Fields Override](/configuration/provider/override_dialer/) for details. + +##### override_tls + +Override TLS fields of outbounds in provider, see [TLS Fields Override](/configuration/provider/override_tls/) for details. + +### Local Fields + +#### path + +==Required== + +!!! note "" + + Will be automatically reloaded if file modified since sing-box 1.10.0. + +Local file path. + +### Remote Fields + +#### url + +==Required== + +URL to the provider. + +#### exclude + +Exclude regular expression to filter nodes. + +#### include + +Include regular expression to filter nodes. + +#### user_agent + +User agent used to download the provider. + +#### download_detour + +The tag of the outbound used to download from the provider. + +Default outbound will be used if empty. + +#### update_interval + +Update interval. The minimum value is `1m`, the default value is `24h`. diff --git a/docs/configuration/provider/index.zh.md b/docs/configuration/provider/index.zh.md new file mode 100644 index 0000000000..16ade581f7 --- /dev/null +++ b/docs/configuration/provider/index.zh.md @@ -0,0 +1,142 @@ +# 订阅 + +### 结构 + +订阅源列表。 + +=== "本地文件" + + ```json + { + "providers": [ + { + "type": "local", + "tag": "provider", + "path": "provider.txt", + "health_check": { + "enabled": false, + "url": "", + "interval": "", + "timeout": "", + }, + "override_dialer": {}, + "override_tls": {} + } + ] + } + ``` + +=== "远程文件" + + ```json + { + "providers": [ + { + "type": "remote", + "tag": "provider", + "health_check": { + "enabled": false, + "url": "", + "interval": "", + "timeout": "", + }, + "url": "", + "exclude": "", + "include": "", + "user_agent": "", + "download_detour": "", + "update_interval": "", + "override_dialer": {}, + "override_tls": {} + } + ] + } + ``` + +### 字段 + +#### type + +==必填== + +订阅源的类型。`local` 或 `remote`。 + +#### tag + +==必填== + +订阅源的标签。 + +来自 `provider` 的节点 `node_name`,导入后的标签为 `provider/node_name`。 + +### 本地或远程字段 + +#### health_check + +健康检查配置。 + +##### health_check.enabled + +是否启用健康检查。 + +##### health_check.url + +健康检查的 URL。 + +##### health_check.interval + +健康检查的时间间隔。最小为 `1m`,默认为 `10m`。 + +##### health_check.timeout + +健康检查的超时时间。默认为 `3s`。 + +##### override_dialer + +覆写订阅内容的拨号字段, 参阅 [拨号字段覆写](/zh/configuration/provider/override_dialer/)。 + +##### override_tls + +覆写订阅内容的 TLS 字段, 参阅 [TLS 字段覆写](/zh/configuration/provider/override_tls/)。 + +### 本地字段 + +#### path + +==必填== + +!!! note "" + + 自 sing-box 1.10.0 起, 文件更改将自动重新加载。 + +本地文件路径。 + +### 远程字段 + +#### url + +==必填== + +订阅源的 URL。 + +#### exclude + +排除节点的正则表达式。 + +#### include + +包含节点的正则表达式。 + +#### user_agent + +用于下载订阅内容的 User-Agent。 + +#### download_detour + +用于下载订阅内容的出站的标签。 + +如果为空,将使用默认出站。 + +#### update_interval + +更新订阅的时间间隔。最小为 `1m`,默认为 `24h`。 \ No newline at end of file diff --git a/docs/configuration/provider/override_dialer.md b/docs/configuration/provider/override_dialer.md new file mode 100644 index 0000000000..446a808050 --- /dev/null +++ b/docs/configuration/provider/override_dialer.md @@ -0,0 +1,33 @@ +### Structure + +```json +{ + "detour": "upstream-out", + "bind_interface": "en0", + "inet4_bind_address": "0.0.0.0", + "inet6_bind_address": "::", + "routing_mark": 1234, + "reuse_addr": false, + "connect_timeout": "5s", + "tcp_fast_open": false, + "tcp_multi_path": false, + "udp_fragment": false, + "domain_resolver": "", // or {} + "network_strategy": "default", + "network_type": [], + "fallback_network_type": [], + "fallback_delay": "300ms", + "tcp_keep_alive": "5m", + "tcp_keep_alive_interval": "75s", + "tcp_keep_alive_count": 0, + "disable_tcp_keep_alive": false, + + // Deprecated + + "domain_strategy": "prefer_ipv6" +} +``` + +### Fields + +`detour` `bind_interface` `inet4_bind_address` `inet6_bind_address` `routing_mark` `reuse_addr` `connect_timeout` `tcp_fast_open` `tcp_multi_path` `udp_fragment` `domain_resolver` `network_strategy` `network_type` `fallback_network_type` `fallback_delay` `tcp_keep_alive` `tcp_keep_alive_interval` `tcp_keep_alive_count` `disable_tcp_keep_alive` `domain_strategy` see [Dial Fields](/configuration/shared/dial). diff --git a/docs/configuration/provider/override_dialer.zh.md b/docs/configuration/provider/override_dialer.zh.md new file mode 100644 index 0000000000..e0c697a14f --- /dev/null +++ b/docs/configuration/provider/override_dialer.zh.md @@ -0,0 +1,33 @@ +### 结构 + +```json +{ + "detour": "upstream-out", + "bind_interface": "en0", + "inet4_bind_address": "0.0.0.0", + "inet6_bind_address": "::", + "routing_mark": 1234, + "reuse_addr": false, + "connect_timeout": "5s", + "tcp_fast_open": false, + "tcp_multi_path": false, + "udp_fragment": false, + "domain_resolver": "", // 或 {} + "network_strategy": "default", + "network_type": [], + "fallback_network_type": [], + "fallback_delay": "300ms", + "tcp_keep_alive": "5m", + "tcp_keep_alive_interval": "75s", + "tcp_keep_alive_count": 0, + "disable_tcp_keep_alive": false, + + // 废弃的 + + "domain_strategy": "prefer_ipv6" +} +``` + +### 字段 + +`detour` `bind_interface` `inet4_bind_address` `inet6_bind_address` `routing_mark` `reuse_addr` `connect_timeout` `tcp_fast_open` `tcp_multi_path` `udp_fragment` `domain_resolver` `network_strategy` `network_type` `fallback_network_type` `fallback_delay` `tcp_keep_alive` `tcp_keep_alive_interval` `tcp_keep_alive_count` `disable_tcp_keep_alive` `domain_strategy` 详情参阅 [拨号字段](/zh/configuration/shared/dial)。 diff --git a/docs/configuration/provider/override_tls.md b/docs/configuration/provider/override_tls.md new file mode 100644 index 0000000000..e401b380e1 --- /dev/null +++ b/docs/configuration/provider/override_tls.md @@ -0,0 +1,16 @@ +### Structure + +```json +{ + "enabled": true, + "disable_sni": false, + "server_name": "example.com", + "insecure": false, + "kernel_tx": false, + "kernel_rx": false +} +``` + +### Fields + +`enabled` `disable_sni` `server_name` `insecure` `kernel_tx` `kernel_rx` see [TLS Fields](/configuration/shared/tls/#outbound). \ No newline at end of file diff --git a/docs/configuration/provider/override_tls.zh.md b/docs/configuration/provider/override_tls.zh.md new file mode 100644 index 0000000000..7c0c061986 --- /dev/null +++ b/docs/configuration/provider/override_tls.zh.md @@ -0,0 +1,16 @@ +### 结构 + +```json +{ + "enabled": true, + "disable_sni": false, + "server_name": "example.com", + "insecure": false, + "kernel_tx": false, + "kernel_rx": false +} +``` + +### 字段 + +`enabled` `disable_sni` `server_name` `insecure` `kernel_tx` `kernel_rx` 详情参阅 [TLS 字段](/zh/configuration/shared/tls/#outbound)。 \ No newline at end of file diff --git a/docs/configuration/route/index.md b/docs/configuration/route/index.md index 1fc9bfd231..40104b619e 100644 --- a/docs/configuration/route/index.md +++ b/docs/configuration/route/index.md @@ -4,6 +4,11 @@ icon: material/alert-decagram # Route +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [find_neighbor](#find_neighbor) + :material-plus: [dhcp_lease_files](#dhcp_lease_files) + !!! quote "Changes in sing-box 1.12.0" :material-plus: [default_domain_resolver](#default_domain_resolver) @@ -35,6 +40,9 @@ icon: material/alert-decagram "override_android_vpn": false, "default_interface": "", "default_mark": 0, + "find_process": false, + "find_neighbor": false, + "dhcp_lease_files": [], "default_domain_resolver": "", // or {} "default_network_strategy": "", "default_network_type": [], @@ -107,6 +115,38 @@ Set routing mark by default. Takes no effect if `outbound.routing_mark` is set. +#### find_process + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Enable process search for logging when no `process_name`, `process_path`, `package_name`, `user` or `user_id` rules exist. + +#### find_neighbor + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux and macOS. + +Enable neighbor resolution for logging when no `source_mac_address` or `source_hostname` rules exist. + +See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. + +#### dhcp_lease_files + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux and macOS. + +Custom DHCP lease file paths for hostname and MAC address resolution. + +Automatically detected from common DHCP servers (dnsmasq, odhcpd, ISC dhcpd, Kea) if empty. + #### default_domain_resolver !!! question "Since sing-box 1.12.0" diff --git a/docs/configuration/route/index.zh.md b/docs/configuration/route/index.zh.md index fa50bfe7d9..518830b835 100644 --- a/docs/configuration/route/index.zh.md +++ b/docs/configuration/route/index.zh.md @@ -4,6 +4,11 @@ icon: material/alert-decagram # 路由 +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [find_neighbor](#find_neighbor) + :material-plus: [dhcp_lease_files](#dhcp_lease_files) + !!! quote "sing-box 1.12.0 中的更改" :material-plus: [default_domain_resolver](#default_domain_resolver) @@ -37,6 +42,9 @@ icon: material/alert-decagram "override_android_vpn": false, "default_interface": "", "default_mark": 0, + "find_process": false, + "find_neighbor": false, + "dhcp_lease_files": [], "default_network_strategy": "", "default_fallback_delay": "" } @@ -106,6 +114,38 @@ icon: material/alert-decagram 如果设置了 `outbound.routing_mark` 设置,则不生效。 +#### find_process + +!!! quote "" + + 仅支持 Linux、Windows 和 macOS。 + +在没有 `process_name`、`process_path`、`package_name`、`user` 或 `user_id` 规则时启用进程搜索以输出日志。 + +#### find_neighbor + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux 和 macOS。 + +在没有 `source_mac_address` 或 `source_hostname` 规则时启用邻居解析以输出日志。 + +参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 + +#### dhcp_lease_files + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux 和 macOS。 + +用于主机名和 MAC 地址解析的自定义 DHCP 租约文件路径。 + +为空时自动从常见 DHCP 服务器(dnsmasq、odhcpd、ISC dhcpd、Kea)检测。 + #### default_domain_resolver !!! question "自 sing-box 1.12.0 起" diff --git a/docs/configuration/route/rule.md b/docs/configuration/route/rule.md index 31f768fe23..94e66d72e9 100644 --- a/docs/configuration/route/rule.md +++ b/docs/configuration/route/rule.md @@ -2,6 +2,11 @@ icon: material/new-box --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [source_mac_address](#source_mac_address) + :material-plus: [source_hostname](#source_hostname) + !!! quote "Changes in sing-box 1.13.0" :material-plus: [interface_address](#interface_address) @@ -130,7 +135,9 @@ icon: material/new-box "user_id": [ 1000 ], - "clash_mode": "direct", + "clash_mode": [ + "direct" + ], "network_type": [ "wifi" ], @@ -159,6 +166,12 @@ icon: material/new-box "tailscale", "wireguard" ], + "source_mac_address": [ + "00:11:22:33:44:55" + ], + "source_hostname": [ + "my-device" + ], "rule_set": [ "geoip-cn", "geosite-cn" @@ -449,6 +462,26 @@ Match specified outbounds' preferred routes. | `tailscale` | Match MagicDNS domains and peers' allowed IPs | | `wireguard` | Match peers's allowed IPs | +#### source_mac_address + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. + +Match source device MAC address. + +#### source_hostname + +!!! question "Since sing-box 1.14.0" + +!!! quote "" + + Only supported on Linux, macOS, or in graphical clients on Android and macOS. See [Neighbor Resolution](/configuration/shared/neighbor/) for setup. + +Match source device hostname from DHCP leases. + #### rule_set !!! question "Since sing-box 1.8.0" diff --git a/docs/configuration/route/rule.zh.md b/docs/configuration/route/rule.zh.md index 1ffe57d688..674f4347f9 100644 --- a/docs/configuration/route/rule.zh.md +++ b/docs/configuration/route/rule.zh.md @@ -2,6 +2,11 @@ icon: material/new-box --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [source_mac_address](#source_mac_address) + :material-plus: [source_hostname](#source_hostname) + !!! quote "sing-box 1.13.0 中的更改" :material-plus: [interface_address](#interface_address) @@ -127,7 +132,9 @@ icon: material/new-box "user_id": [ 1000 ], - "clash_mode": "direct", + "clash_mode": [ + "direct" + ], "network_type": [ "wifi" ], @@ -156,6 +163,12 @@ icon: material/new-box "tailscale", "wireguard" ], + "source_mac_address": [ + "00:11:22:33:44:55" + ], + "source_hostname": [ + "my-device" + ], "rule_set": [ "geoip-cn", "geosite-cn" @@ -446,6 +459,26 @@ icon: material/new-box | `tailscale` | 匹配 MagicDNS 域名和对端的 allowed IPs | | `wireguard` | 匹配对端的 allowed IPs | +#### source_mac_address + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux、macOS,或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 + +匹配源设备 MAC 地址。 + +#### source_hostname + +!!! question "自 sing-box 1.14.0 起" + +!!! quote "" + + 仅支持 Linux、macOS,或在 Android 和 macOS 图形客户端中支持。参阅 [邻居解析](/configuration/shared/neighbor/) 了解设置方法。 + +匹配源设备从 DHCP 租约获取的主机名。 + #### rule_set !!! question "自 sing-box 1.8.0 起" diff --git a/docs/configuration/route/rule_action.md b/docs/configuration/route/rule_action.md index 523ffec206..26adb49ae5 100644 --- a/docs/configuration/route/rule_action.md +++ b/docs/configuration/route/rule_action.md @@ -278,6 +278,7 @@ Timeout for sniffing. "action": "resolve", "server": "", "strategy": "", + "match_only": true, "disable_cache": false, "rewrite_ttl": null, "client_subnet": null @@ -296,6 +297,11 @@ DNS resolution strategy, available values are: `prefer_ipv4`, `prefer_ipv6`, `ip `dns.strategy` will be used by default. +#### match_only +Matching IP-related rules does not affect outbound domain name transmission. + +It allows for domestic and international traffic splitting with minimal configuration, and when used with fakeip, it does not affect the sending of domain names to proxy servers. + #### disable_cache !!! question "Since sing-box 1.12.0" diff --git a/docs/configuration/route/rule_action.zh.md b/docs/configuration/route/rule_action.zh.md index 16efb53a8d..2f5bc13c85 100644 --- a/docs/configuration/route/rule_action.zh.md +++ b/docs/configuration/route/rule_action.zh.md @@ -267,6 +267,7 @@ UDP 连接超时时间。 "action": "resolve", "server": "", "strategy": "", + "match_only": true, "disable_cache": false, "rewrite_ttl": null, "client_subnet": null @@ -285,6 +286,11 @@ DNS 解析策略,可用值有:`prefer_ipv4`、`prefer_ipv6`、`ipv4_only`、 默认使用 `dns.strategy`。 +#### match_only +匹配 IP 类规则,不影响出站传递域名。 + +可以用最小配置实现国内外的分流,配合 fakeip 的情况下不影响向代理服务器发送域名。 + #### disable_cache !!! question "自 sing-box 1.12.0 起" diff --git a/docs/configuration/rule-set/index.md b/docs/configuration/rule-set/index.md index 73ec7b859f..daae56e564 100644 --- a/docs/configuration/rule-set/index.md +++ b/docs/configuration/rule-set/index.md @@ -42,6 +42,7 @@ "type": "remote", "tag": "", "format": "source", // or binary + "path": "", "url": "", "download_detour": "", // optional "update_interval": "" // optional @@ -82,8 +83,6 @@ Format of rule-set file, `source` or `binary`. Optional when `path` or `url` uses `json` or `srs` as extension. -### Local Fields - #### path ==Required== diff --git a/docs/configuration/shared/dial.md b/docs/configuration/shared/dial.md index 306952fc4a..3493268f15 100644 --- a/docs/configuration/shared/dial.md +++ b/docs/configuration/shared/dial.md @@ -7,6 +7,7 @@ icon: material/new-box :material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive) :material-plus: [tcp_keep_alive](#tcp_keep_alive) :material-plus: [tcp_keep_alive_interval](#tcp_keep_alive_interval) + :material-plus: [tcp_keep_alive_count](#tcp_keep_alive_count) :material-plus: [bind_address_no_port](#bind_address_no_port) !!! quote "Changes in sing-box 1.12.0" @@ -40,6 +41,7 @@ icon: material/new-box "disable_tcp_keep_alive": false, "tcp_keep_alive": "", "tcp_keep_alive_interval": "", + "tcp_keep_alive_count": 0, "udp_fragment": false, "domain_resolver": "", // or {} @@ -159,6 +161,12 @@ TCP keep alive interval. `75s` will be used by default. +#### tcp_keep_alive_count + +TCP keep-alive probe count. + +Uses system default if not set or set to `0`. + #### udp_fragment Enable UDP fragmentation. diff --git a/docs/configuration/shared/dial.zh.md b/docs/configuration/shared/dial.zh.md index 4930935178..6b82deb4f6 100644 --- a/docs/configuration/shared/dial.zh.md +++ b/docs/configuration/shared/dial.zh.md @@ -7,6 +7,7 @@ icon: material/new-box :material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive) :material-plus: [tcp_keep_alive](#tcp_keep_alive) :material-plus: [tcp_keep_alive_interval](#tcp_keep_alive_interval) + :material-plus: [tcp_keep_alive_count](#tcp_keep_alive_count) :material-plus: [bind_address_no_port](#bind_address_no_port) !!! quote "sing-box 1.12.0 中的更改" @@ -40,6 +41,7 @@ icon: material/new-box "disable_tcp_keep_alive": false, "tcp_keep_alive": "", "tcp_keep_alive_interval": "", + "tcp_keep_alive_count": 0, "udp_fragment": false, "domain_resolver": "", // 或 {} @@ -157,6 +159,12 @@ TCP keep alive 间隔。 默认使用 `75s`。 +#### tcp_keep_alive_count + +TCP keep-alive 探测次数。 + +未设置或设置为 `0` 时使用系统默认值。 + #### udp_fragment 启用 UDP 分段。 diff --git a/docs/configuration/shared/listen.md b/docs/configuration/shared/listen.md index 55325564a4..87be405405 100644 --- a/docs/configuration/shared/listen.md +++ b/docs/configuration/shared/listen.md @@ -5,7 +5,8 @@ icon: material/new-box !!! quote "Changes in sing-box 1.13.0" :material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive) - :material-alert: [tcp_keep_alive](#tcp_keep_alive) + :material-alert: [tcp_keep_alive](#tcp_keep_alive) + :material-plus: [tcp_keep_alive_count](#tcp_keep_alive_count) !!! quote "Changes in sing-box 1.12.0" @@ -37,6 +38,7 @@ icon: material/new-box "disable_tcp_keep_alive": false, "tcp_keep_alive": "", "tcp_keep_alive_interval": "", + "tcp_keep_alive_count": 0, "udp_fragment": false, "udp_timeout": "", "detour": "", @@ -131,6 +133,14 @@ TCP keep alive interval. `75s` will be used by default. +#### tcp_keep_alive_count + +!!! question "Since sing-box 1.13.0" + +TCP keep-alive probe count. + +Uses system default if not set or set to `0`. + #### udp_fragment Enable UDP fragmentation. @@ -200,3 +210,11 @@ the original packet address will be sent in the response instead of the mapped d This option is used for compatibility with clients that do not support receiving UDP packets with domain addresses, such as Surge. + +#### proxy_protocol + +Parse [Proxy Protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) in the connection header. + +#### proxy_protocol_accept_no_header + +Accept connections without Proxy Protocol header. \ No newline at end of file diff --git a/docs/configuration/shared/listen.zh.md b/docs/configuration/shared/listen.zh.md index 905cea3cd8..c49a558ba4 100644 --- a/docs/configuration/shared/listen.zh.md +++ b/docs/configuration/shared/listen.zh.md @@ -5,7 +5,8 @@ icon: material/new-box !!! quote "sing-box 1.13.0 中的更改" :material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive) - :material-alert: [tcp_keep_alive](#tcp_keep_alive) + :material-alert: [tcp_keep_alive](#tcp_keep_alive) + :material-plus: [tcp_keep_alive_count](#tcp_keep_alive_count) !!! quote "sing-box 1.12.0 中的更改" @@ -37,6 +38,7 @@ icon: material/new-box "disable_tcp_keep_alive": false, "tcp_keep_alive": "", "tcp_keep_alive_interval": "", + "tcp_keep_alive_count": 0, "udp_fragment": false, "udp_timeout": "", "detour": "", @@ -131,6 +133,14 @@ TCP keep alive 间隔。 默认使用 `75s`。 +#### tcp_keep_alive_count + +!!! question "自 sing-box 1.13.0 起" + +TCP keep-alive 探测次数。 + +未设置或设置为 `0` 时使用系统默认值。 + #### udp_fragment 启用 UDP 分段。 @@ -198,3 +208,11 @@ UDP NAT 过期时间。 如果启用,对于地址为域的 UDP 代理请求,将在响应中发送原始包地址而不是映射的域。 此选项用于兼容不支持接收带有域地址的 UDP 包的客户端,如 Surge。 + +#### proxy_protocol + +解析连接头中的 [代理协议](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt)。 + +#### proxy_protocol_accept_no_header + +接受没有代理协议标头的连接。 \ No newline at end of file diff --git a/docs/configuration/shared/neighbor.md b/docs/configuration/shared/neighbor.md new file mode 100644 index 0000000000..c67d995ebe --- /dev/null +++ b/docs/configuration/shared/neighbor.md @@ -0,0 +1,49 @@ +--- +icon: material/lan +--- + +# Neighbor Resolution + +Match LAN devices by MAC address and hostname using +[`source_mac_address`](/configuration/route/rule/#source_mac_address) and +[`source_hostname`](/configuration/route/rule/#source_hostname) rule items. + +Neighbor resolution is automatically enabled when these rule items exist. +Use [`route.find_neighbor`](/configuration/route/#find_neighbor) to force enable it for logging without rules. + +## Linux + +Works natively. No special setup required. + +Hostname resolution requires DHCP lease files, +automatically detected from common DHCP servers (dnsmasq, odhcpd, ISC dhcpd, Kea). +Custom paths can be set via [`route.dhcp_lease_files`](/configuration/route/#dhcp_lease_files). + +## Android + +!!! quote "" + + Only supported in graphical clients. + +Requires Android 11 or above and ROOT. + +Must use [VPNHotspot](https://github.com/Mygod/VPNHotspot) to share the VPN connection. +ROM built-in features like "Use VPN for connected devices" can share VPN +but cannot provide MAC address or hostname information. + +Set **IP Masquerade Mode** to **None** in VPNHotspot settings. + +Only route/DNS rules are supported. TUN include/exclude routes are not supported. + +### Hostname Visibility + +Hostname is only visible in sing-box if it is visible in VPNHotspot. +For Apple devices, change **Private Wi-Fi Address** from **Rotating** to **Fixed** in the Wi-Fi settings +of the connected network. Non-Apple devices are always visible. + +## macOS + +Requires the standalone version (macOS system extension). +The App Store version can share the VPN as a hotspot but does not support MAC address or hostname reading. + +See [VPN Hotspot](/manual/misc/vpn-hotspot/#macos) for Internet Sharing setup. diff --git a/docs/configuration/shared/neighbor.zh.md b/docs/configuration/shared/neighbor.zh.md new file mode 100644 index 0000000000..96297fcb57 --- /dev/null +++ b/docs/configuration/shared/neighbor.zh.md @@ -0,0 +1,49 @@ +--- +icon: material/lan +--- + +# 邻居解析 + +通过 +[`source_mac_address`](/configuration/route/rule/#source_mac_address) 和 +[`source_hostname`](/configuration/route/rule/#source_hostname) 规则项匹配局域网设备的 MAC 地址和主机名。 + +当这些规则项存在时,邻居解析自动启用。 +使用 [`route.find_neighbor`](/configuration/route/#find_neighbor) 可在没有规则时强制启用以输出日志。 + +## Linux + +原生支持,无需特殊设置。 + +主机名解析需要 DHCP 租约文件, +自动从常见 DHCP 服务器(dnsmasq、odhcpd、ISC dhcpd、Kea)检测。 +可通过 [`route.dhcp_lease_files`](/configuration/route/#dhcp_lease_files) 设置自定义路径。 + +## Android + +!!! quote "" + + 仅在图形客户端中支持。 + +需要 Android 11 或以上版本和 ROOT。 + +必须使用 [VPNHotspot](https://github.com/Mygod/VPNHotspot) 共享 VPN 连接。 +ROM 自带的「通过 VPN 共享连接」等功能可以共享 VPN, +但无法提供 MAC 地址或主机名信息。 + +在 VPNHotspot 设置中将 **IP 遮掩模式** 设为 **无**。 + +仅支持路由/DNS 规则。不支持 TUN 的 include/exclude 路由。 + +### 设备可见性 + +MAC 地址和主机名仅在 VPNHotspot 中可见时 sing-box 才能读取。 +对于 Apple 设备,需要在所连接网络的 Wi-Fi 设置中将**私有无线局域网地址**从**轮替**改为**固定**。 +非 Apple 设备始终可见。 + +## macOS + +需要独立版本(macOS 系统扩展)。 +App Store 版本可以共享 VPN 热点但不支持 MAC 地址或主机名读取。 + +参阅 [VPN 热点](/manual/misc/vpn-hotspot/#macos) 了解互联网共享设置。 diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index ac2d700280..1fa47b217a 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -19,16 +19,20 @@ import ( ) var ( - bucketSelected = []byte("selected") - bucketExpand = []byte("group_expand") - bucketMode = []byte("clash_mode") - bucketRuleSet = []byte("rule_set") + bucketSelected = []byte("selected") + bucketExpand = []byte("group_expand") + bucketMode = []byte("clash_mode") + bucketRuleSet = []byte("rule_set") + bucketExternalUI = []byte("external_ui") + bucketOutboundProvider = []byte("outbound_provider") bucketNameList = []string{ string(bucketSelected), string(bucketExpand), string(bucketMode), string(bucketRuleSet), + string(bucketExternalUI), + string(bucketOutboundProvider), string(bucketRDRC), } @@ -359,3 +363,69 @@ func (c *CacheFile) SaveRuleSet(tag string, set *adapter.SavedBinary) error { return bucket.Put([]byte(tag), setBinary) }) } + +func (c *CacheFile) LoadExternalUI(tag string) *adapter.SavedBinary { + var savedSet adapter.SavedBinary + err := c.DB.View(func(t *bbolt.Tx) error { + bucket := c.bucket(t, bucketExternalUI) + if bucket == nil { + return os.ErrNotExist + } + setBinary := bucket.Get([]byte(tag)) + if len(setBinary) == 0 { + return os.ErrInvalid + } + return savedSet.UnmarshalBinary(setBinary) + }) + if err != nil { + return nil + } + return &savedSet +} + +func (c *CacheFile) SaveExternalUI(tag string, info *adapter.SavedBinary) error { + return c.DB.Batch(func(t *bbolt.Tx) error { + bucket, err := c.createBucket(t, bucketExternalUI) + if err != nil { + return err + } + setBinary, err := info.MarshalBinary() + if err != nil { + return err + } + return bucket.Put([]byte(tag), setBinary) + }) +} + +func (c *CacheFile) LoadSubscription(tag string) *adapter.SavedBinary { + var savedSet adapter.SavedBinary + err := c.DB.View(func(t *bbolt.Tx) error { + bucket := c.bucket(t, bucketOutboundProvider) + if bucket == nil { + return os.ErrNotExist + } + setBinary := bucket.Get([]byte(tag)) + if len(setBinary) == 0 { + return os.ErrInvalid + } + return savedSet.UnmarshalBinary(setBinary) + }) + if err != nil { + return nil + } + return &savedSet +} + +func (c *CacheFile) SaveSubscription(tag string, sub *adapter.SavedBinary) error { + return c.DB.Batch(func(t *bbolt.Tx) error { + bucket, err := c.createBucket(t, bucketOutboundProvider) + if err != nil { + return err + } + setBinary, err := sub.MarshalBinary() + if err != nil { + return err + } + return bucket.Put([]byte(tag), setBinary) + }) +} diff --git a/experimental/clashapi.go b/experimental/clashapi.go index 4ad07c8b88..0e66daf5f1 100644 --- a/experimental/clashapi.go +++ b/experimental/clashapi.go @@ -55,8 +55,8 @@ func extraClashModeFromRule(rules []option.Rule) []string { for _, rule := range rules { switch rule.Type { case C.RuleTypeDefault: - if rule.DefaultOptions.ClashMode != "" { - clashMode = append(clashMode, rule.DefaultOptions.ClashMode) + if len(rule.DefaultOptions.ClashMode) > 0 { + clashMode = append(clashMode, rule.DefaultOptions.ClashMode...) } case C.RuleTypeLogical: clashMode = append(clashMode, extraClashModeFromRule(rule.LogicalOptions.Rules)...) @@ -70,8 +70,8 @@ func extraClashModeFromDNSRule(rules []option.DNSRule) []string { for _, rule := range rules { switch rule.Type { case C.RuleTypeDefault: - if rule.DefaultOptions.ClashMode != "" { - clashMode = append(clashMode, rule.DefaultOptions.ClashMode) + if len(rule.DefaultOptions.ClashMode) > 0 { + clashMode = append(clashMode, rule.DefaultOptions.ClashMode...) } case C.RuleTypeLogical: clashMode = append(clashMode, extraClashModeFromDNSRule(rule.LogicalOptions.Rules)...) diff --git a/experimental/clashapi/api_meta_group.go b/experimental/clashapi/api_meta_group.go index 31dbdaf692..17a8eea929 100644 --- a/experimental/clashapi/api_meta_group.go +++ b/experimental/clashapi/api_meta_group.go @@ -84,6 +84,8 @@ func getGroupDelay(server *Server) func(w http.ResponseWriter, r *http.Request) var result map[string]uint16 if urlTestGroup, isURLTestGroup := outboundGroup.(adapter.URLTestGroup); isURLTestGroup { result, err = urlTestGroup.URLTest(ctx) + } else if loadBalanceGroup, isLoadBalanceGroup := outboundGroup.(adapter.LoadBalanceGroup); isLoadBalanceGroup { + result, err = loadBalanceGroup.URLTest(ctx) } else { outbounds := common.FilterNotNil(common.Map(outboundGroup.All(), func(it string) adapter.Outbound { itOutbound, _ := server.outbound.Outbound(it) diff --git a/experimental/clashapi/api_meta_upgrade.go b/experimental/clashapi/api_meta_upgrade.go index df70088edf..376e31a11a 100644 --- a/experimental/clashapi/api_meta_upgrade.go +++ b/experimental/clashapi/api_meta_upgrade.go @@ -23,14 +23,13 @@ func updateExternalUI(server *Server) func(w http.ResponseWriter, r *http.Reques return } server.logger.Info("upgrading external UI") - err := server.downloadExternalUI() + err := server.checkAndDownloadExternalUI(true) if err != nil { - server.logger.Error(E.Cause(err, "upgrade external ui")) + server.logger.Error(E.Cause(err, "upgrade external UI")) render.Status(r, http.StatusInternalServerError) render.JSON(w, r, newError(err.Error())) return } - server.logger.Info("updated external UI") render.JSON(w, r, render.M{"status": "ok"}) } } diff --git a/experimental/clashapi/configs.go b/experimental/clashapi/configs.go index 8ae1d258bb..a87e380ce8 100644 --- a/experimental/clashapi/configs.go +++ b/experimental/clashapi/configs.go @@ -12,7 +12,8 @@ import ( func configRouter(server *Server, logFactory log.Factory) http.Handler { r := chi.NewRouter() r.Get("/", getConfigs(server, logFactory)) - r.Put("/", updateConfigs) + // r.Put("/", updateConfigs) + r.Put("/", reload(server)) r.Patch("/", patchConfigs(server)) return r } @@ -28,6 +29,7 @@ type configSchema struct { Mode string `json:"mode"` // sing-box added ModeList []string `json:"mode-list"` + Modes []string `json:"modes"` LogLevel string `json:"log-level"` IPv6 bool `json:"ipv6"` Tun map[string]any `json:"tun"` @@ -44,6 +46,8 @@ func getConfigs(server *Server, logFactory log.Factory) func(w http.ResponseWrit render.JSON(w, r, &configSchema{ Mode: server.mode, ModeList: server.modeList, + Modes: server.modeList, + AllowLan: true, BindAddress: "*", LogLevel: log.FormatLevel(logLevel), }) diff --git a/experimental/clashapi/ctxkeys.go b/experimental/clashapi/ctxkeys.go index 3a88802627..aa13ce1ff9 100644 --- a/experimental/clashapi/ctxkeys.go +++ b/experimental/clashapi/ctxkeys.go @@ -5,6 +5,8 @@ var ( CtxKeyProviderName = contextKey("provider name") CtxKeyProxy = contextKey("proxy") CtxKeyProvider = contextKey("provider") + CtxKeyRule = contextKey("rule") + CtxKeyRuleUUID = contextKey("rule uuid") ) type contextKey string diff --git a/experimental/clashapi/provider.go b/experimental/clashapi/provider.go index 352b28944e..f2487e49a9 100644 --- a/experimental/clashapi/provider.go +++ b/experimental/clashapi/provider.go @@ -4,48 +4,78 @@ import ( "context" "net/http" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/json/badjson" + "github.com/go-chi/chi/v5" "github.com/go-chi/render" ) -func proxyProviderRouter() http.Handler { +func proxyProviderRouter(server *Server) http.Handler { r := chi.NewRouter() - r.Get("/", getProviders) + r.Get("/", getProviders(server)) r.Route("/{name}", func(r chi.Router) { - r.Use(parseProviderName, findProviderByName) - r.Get("/", getProvider) + r.Use(parseProviderName, findProviderByName(server)) + r.Get("/", getProvider(server)) r.Put("/", updateProvider) r.Get("/healthcheck", healthCheckProvider) }) return r } -func getProviders(w http.ResponseWriter, r *http.Request) { - render.JSON(w, r, render.M{ - "providers": render.M{}, - }) +func getProviders(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + providerMap := make(render.M) + for _, provider := range server.provider.Providers() { + providerMap[provider.Tag()] = providerInfo(server, provider) + } + render.JSON(w, r, render.M{ + "providers": providerMap, + }) + } } -func getProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) - render.JSON(w, r, provider)*/ - render.NoContent(w, r) +func getProvider(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + provider := r.Context().Value(CtxKeyProvider).(adapter.Provider) + render.JSON(w, r, providerInfo(server, provider)) + } +} + +func providerInfo(server *Server, p adapter.Provider) *badjson.JSONObject { + var info badjson.JSONObject + proxies := make([]*badjson.JSONObject, 0) + for _, detour := range p.Outbounds() { + proxies = append(proxies, proxyInfo(server, detour)) + } + info.Put("type", "Proxy") // Proxy, Rule + info.Put("vehicleType", C.ProviderDisplayName(p.Type())) // HTTP, File, Compatible + info.Put("name", p.Tag()) + info.Put("proxies", proxies) + info.Put("updatedAt", p.UpdatedAt()) + if p, ok := p.(adapter.ProviderSubscriptionInfo); ok { + info.Put("subscriptionInfo", p.SubscriptionInfo()) + } + return &info } func updateProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) - if err := provider.Update(); err != nil { - render.Status(r, http.StatusServiceUnavailable) - render.JSON(w, r, newError(err.Error())) - return - }*/ + provider := r.Context().Value(CtxKeyProvider).(adapter.Provider) + if provider, isUpdater := provider.(adapter.ProviderUpdater); isUpdater { + if err := provider.Update(); err != nil { + render.Status(r, http.StatusServiceUnavailable) + render.JSON(w, r, newError(err.Error())) + return + } + } render.NoContent(w, r) } func healthCheckProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.ProxyProvider) - provider.HealthCheck()*/ + provider := r.Context().Value(CtxKeyProvider).(adapter.Provider) + provider.HealthCheck(r.Context()) render.NoContent(w, r) } @@ -57,18 +87,19 @@ func parseProviderName(next http.Handler) http.Handler { }) } -func findProviderByName(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - /*name := r.Context().Value(CtxKeyProviderName).(string) - providers := tunnel.ProxyProviders() - provider, exist := providers[name] - if !exist {*/ - render.Status(r, http.StatusNotFound) - render.JSON(w, r, ErrNotFound) - //return - //} +func findProviderByName(server *Server) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := r.Context().Value(CtxKeyProviderName).(string) + provider, exist := server.provider.Get(name) + if !exist { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + return + } - // ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) - // next.ServeHTTP(w, r.WithContext(ctx)) - }) + ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } } diff --git a/experimental/clashapi/reload.go b/experimental/clashapi/reload.go new file mode 100644 index 0000000000..10c24b400e --- /dev/null +++ b/experimental/clashapi/reload.go @@ -0,0 +1,19 @@ +//go:build !ios + +package clashapi + +import ( + "net/http" + + "github.com/go-chi/render" +) + +func reload(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + defer func() { + server.logger.Warn("sing-box restarting...") + server.router.Reload() + }() + render.NoContent(w, r) + } +} diff --git a/experimental/clashapi/reload_stub.go b/experimental/clashapi/reload_stub.go new file mode 100644 index 0000000000..4326f8d236 --- /dev/null +++ b/experimental/clashapi/reload_stub.go @@ -0,0 +1,19 @@ +//go:build ios + +package clashapi + +import ( + "net/http" + + "github.com/go-chi/render" +) + +var ErrOSNotSupported = &HTTPError{ + Message: "OS not supported", +} + +func reload(server *Server) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, ErrOSNotSupported) + } +} diff --git a/experimental/clashapi/restart.go b/experimental/clashapi/restart.go new file mode 100644 index 0000000000..03c1081048 --- /dev/null +++ b/experimental/clashapi/restart.go @@ -0,0 +1,64 @@ +package clashapi + +import ( + "context" + "net/http" + "os" + "os/exec" + "runtime" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/service" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func restartRouter(ctx context.Context, logFactory log.Factory) http.Handler { + r := chi.NewRouter() + r.Post("/", restart(ctx, logFactory)) + return r +} + +func restart(ctx context.Context, logFactory log.Factory) func(w http.ResponseWriter, r *http.Request) { + restartExecutable := func(execPath string) { + inbound := service.FromContext[adapter.InboundManager](ctx) + dnsTransport := service.FromContext[adapter.DNSTransportManager](ctx) + common.Close(inbound, dnsTransport) + var err error + logger := logFactory.Logger() + logger.Info("sing-box restarting") + if runtime.GOOS == "windows" { + cmd := exec.Command(execPath, os.Args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Start() + if err != nil { + logger.Error("sing-box restarting: ", err) + } + + os.Exit(0) + } + + err = syscall.Exec(execPath, os.Args, os.Environ()) + if err != nil { + logger.Error("sing-box restarting: ", err) + } + } + return func(w http.ResponseWriter, r *http.Request) { + execPath, err := os.Executable() + if err != nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, newError(err.Error())) + return + } + + go restartExecutable(execPath) + + render.JSON(w, r, render.M{"status": "ok"}) + } +} diff --git a/experimental/clashapi/ruleprovider.go b/experimental/clashapi/ruleprovider.go index 4a410854a5..f618b19d46 100644 --- a/experimental/clashapi/ruleprovider.go +++ b/experimental/clashapi/ruleprovider.go @@ -1,58 +1,93 @@ package clashapi import ( + "context" "net/http" + "strings" + + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badjson" "github.com/go-chi/chi/v5" "github.com/go-chi/render" ) -func ruleProviderRouter() http.Handler { +func ruleProviderRouter(router adapter.Router) http.Handler { r := chi.NewRouter() - r.Get("/", getRuleProviders) + r.Get("/", getRuleProviders(router)) r.Route("/{name}", func(r chi.Router) { - r.Use(parseProviderName, findRuleProviderByName) + r.Use(parseProviderName, findRuleProviderByName(router)) r.Get("/", getRuleProvider) r.Put("/", updateRuleProvider) }) return r } -func getRuleProviders(w http.ResponseWriter, r *http.Request) { - render.JSON(w, r, render.M{ - "providers": []string{}, - }) +func ruleSetInfo(ruleSet adapter.RuleSet) *badjson.JSONObject { + var info badjson.JSONObject + info.Put("name", ruleSet.Name()) + info.Put("type", "Rule") + info.Put("vehicleType", strings.ToUpper(ruleSet.Type())) + info.Put("behavior", strings.ToUpper(ruleSet.Format())) + info.Put("ruleCount", ruleSet.RuleCount()) + info.Put("updatedAt", ruleSet.UpdatedTime().Format("2006-01-02T15:04:05.999999999-07:00")) + return &info +} + +func getRuleProviders(router adapter.Router) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + providerMap := render.M{} + for i, ruleSet := range router.RuleSets() { + var tag string + if ruleSet.Name() == "" { + tag = F.ToString(i) + } else { + tag = ruleSet.Name() + } + providerMap[tag] = ruleSetInfo(ruleSet) + } + render.JSON(w, r, render.M{ + "providers": providerMap, + }) + } } func getRuleProvider(w http.ResponseWriter, r *http.Request) { - // provider := r.Context().Value(CtxKeyProvider).(provider.RuleProvider) - // render.JSON(w, r, provider) - render.NoContent(w, r) + ruleSet := r.Context().Value(CtxKeyProvider).(adapter.RuleSet) + response, err := ruleSetInfo(ruleSet).MarshalJSON() + if err != nil { + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, newError(err.Error())) + return + } + w.Write(response) } func updateRuleProvider(w http.ResponseWriter, r *http.Request) { - /*provider := r.Context().Value(CtxKeyProvider).(provider.RuleProvider) - if err := provider.Update(); err != nil { - render.Status(r, http.StatusServiceUnavailable) + ruleSet := r.Context().Value(CtxKeyProvider).(adapter.RuleSet) + err := ruleSet.Update(r.Context()) + if err != nil { + render.Status(r, http.StatusInternalServerError) render.JSON(w, r, newError(err.Error())) return - }*/ + } render.NoContent(w, r) } -func findRuleProviderByName(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - /*name := r.Context().Value(CtxKeyProviderName).(string) - providers := tunnel.RuleProviders() - provider, exist := providers[name] - if !exist {*/ - render.Status(r, http.StatusNotFound) - render.JSON(w, r, ErrNotFound) - //return - //} - - // ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) - // next.ServeHTTP(w, r.WithContext(ctx)) - }) +func findRuleProviderByName(router adapter.Router) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := r.Context().Value(CtxKeyProviderName).(string) + provider, exist := router.RuleSet(name) + if !exist { + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + return + } + ctx := context.WithValue(r.Context(), CtxKeyProvider, provider) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } } diff --git a/experimental/clashapi/rules.go b/experimental/clashapi/rules.go index bc8fbb2bba..383d583d1a 100644 --- a/experimental/clashapi/rules.go +++ b/experimental/clashapi/rules.go @@ -1,6 +1,7 @@ package clashapi import ( + "context" "net/http" "github.com/sagernet/sing-box/adapter" @@ -9,9 +10,13 @@ import ( "github.com/go-chi/render" ) -func ruleRouter(router adapter.Router) http.Handler { +func ruleRouter(router adapter.Router, dnsRouter adapter.DNSRouter) http.Handler { r := chi.NewRouter() - r.Get("/", getRules(router)) + r.Get("/", getRules(router, dnsRouter)) + r.Route("/{uuid}", func(r chi.Router) { + r.Use(parseRuleUUID, findRuleByUUID(router, dnsRouter)) + r.Put("/", changeRuleStatus) + }) return r } @@ -19,18 +24,32 @@ type Rule struct { Type string `json:"type"` Payload string `json:"payload"` Proxy string `json:"proxy"` + + Disabled bool `json:"disabled,omitempty"` + UUID string `json:"uuid,omitempty"` } -func getRules(router adapter.Router) func(w http.ResponseWriter, r *http.Request) { +func getRules(router adapter.Router, dnsRouter adapter.DNSRouter) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - rawRules := router.Rules() - var rules []Rule - for _, rule := range rawRules { + for _, rule := range dnsRouter.Rules() { + rules = append(rules, Rule{ + Type: rule.Type(), + Payload: rule.String(), + Proxy: rule.Action().String(), + + Disabled: rule.Disabled(), + UUID: rule.UUID(), + }) + } + for _, rule := range router.Rules() { rules = append(rules, Rule{ Type: rule.Type(), Payload: rule.String(), Proxy: rule.Action().String(), + + Disabled: rule.Disabled(), + UUID: rule.UUID(), }) } render.JSON(w, r, render.M{ @@ -38,3 +57,39 @@ func getRules(router adapter.Router) func(w http.ResponseWriter, r *http.Request }) } } + +func parseRuleUUID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uuid := getEscapeParam(r, "uuid") + ctx := context.WithValue(r.Context(), CtxKeyRuleUUID, uuid) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func findRuleByUUID(router adapter.Router, dnsRouter adapter.DNSRouter) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uuid := r.Context().Value(CtxKeyRuleUUID).(string) + routeRule, exist := router.Rule(uuid) + if exist { + ctx := context.WithValue(r.Context(), CtxKeyRule, routeRule) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + dnsRule, dnsExist := dnsRouter.Rule(uuid) + if dnsExist { + ctx := context.WithValue(r.Context(), CtxKeyRule, adapter.Rule(dnsRule)) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + render.Status(r, http.StatusNotFound) + render.JSON(w, r, ErrNotFound) + }) + } +} + +func changeRuleStatus(w http.ResponseWriter, r *http.Request) { + rule := r.Context().Value(CtxKeyRule).(adapter.Rule) + rule.ChangeStatus() + render.NoContent(w, r) +} diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index c36611821e..e0d984f48a 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -45,6 +45,7 @@ type Server struct { router adapter.Router dnsRouter adapter.DNSRouter outbound adapter.OutboundManager + provider adapter.ProviderManager endpoint adapter.EndpointManager logger log.Logger httpServer *http.Server @@ -60,16 +61,29 @@ type Server struct { externalUI string externalUIDownloadURL string externalUIDownloadDetour string + externalUIUpdateInterval time.Duration + cacheFile adapter.CacheFile + lastEtag string + lastUpdated time.Time + ticker *time.Ticker } func NewServer(ctx context.Context, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) { trafficManager := trafficontrol.NewManager() chiRouter := chi.NewRouter() + updateInterval := time.Duration(options.ExternalUIUpdateInterval) + if updateInterval <= 0 { + updateInterval = 0 + } + if updateInterval > 0 && updateInterval < time.Hour { + updateInterval = time.Hour + } s := &Server{ ctx: ctx, router: service.FromContext[adapter.Router](ctx), dnsRouter: service.FromContext[adapter.DNSRouter](ctx), outbound: service.FromContext[adapter.OutboundManager](ctx), + provider: service.FromContext[adapter.ProviderManager](ctx), endpoint: service.FromContext[adapter.EndpointManager](ctx), logger: logFactory.NewLogger("clash-api"), httpServer: &http.Server{ @@ -82,6 +96,8 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op externalController: options.ExternalController != "", externalUIDownloadURL: options.ExternalUIDownloadURL, externalUIDownloadDetour: options.ExternalUIDownloadDetour, + externalUIUpdateInterval: updateInterval, + cacheFile: service.FromContext[adapter.CacheFile](ctx), } s.urlTestHistory = service.FromContext[adapter.URLTestHistoryStorage](ctx) if s.urlTestHistory == nil { @@ -120,15 +136,19 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op r.Get("/version", version) r.Mount("/configs", configRouter(s, logFactory)) r.Mount("/proxies", proxyRouter(s, s.router)) - r.Mount("/rules", ruleRouter(s.router)) + r.Mount("/rules", ruleRouter(s.router, s.dnsRouter)) r.Mount("/connections", connectionRouter(s.ctx, s.router, trafficManager)) - r.Mount("/providers/proxies", proxyProviderRouter()) - r.Mount("/providers/rules", ruleProviderRouter()) + r.Mount("/providers/proxies", proxyProviderRouter(s)) + r.Mount("/providers/rules", ruleProviderRouter(s.router)) r.Mount("/script", scriptRouter()) r.Mount("/profile", profileRouter()) r.Mount("/cache", cacheRouter(ctx)) r.Mount("/dns", dnsRouter(s.dnsRouter)) + if service.FromContext[adapter.PlatformInterface](ctx) == nil { + r.Mount("/restart", restartRouter(ctx, logFactory)) + } + s.setupMetaAPI(r) }) if options.ExternalUI != "" { @@ -148,9 +168,8 @@ func (s *Server) Name() string { func (s *Server) Start(stage adapter.StartStage) error { switch stage { case adapter.StartStateStart: - cacheFile := service.FromContext[adapter.CacheFile](s.ctx) - if cacheFile != nil { - mode := cacheFile.LoadMode() + if s.cacheFile != nil { + mode := s.cacheFile.LoadMode() if common.Any(s.modeList, func(it string) bool { return strings.EqualFold(it, mode) }) { @@ -159,7 +178,18 @@ func (s *Server) Start(stage adapter.StartStage) error { } case adapter.StartStateStarted: if s.externalController { - s.checkAndDownloadExternalUI() + if s.externalUI != "" && s.externalUIUpdateInterval != 0 { + if s.cacheFile != nil { + if savedExternalUI := s.cacheFile.LoadExternalUI("ExternalUI"); savedExternalUI != nil { + s.lastUpdated = savedExternalUI.LastUpdated + s.lastEtag = savedExternalUI.LastEtag + } + } + } + s.checkAndDownloadExternalUI(false) + if s.externalUIUpdateInterval != 0 && !s.lastUpdated.IsZero() { + go s.loopUpdate() + } var ( listener net.Listener err error @@ -188,7 +218,26 @@ func (s *Server) Start(stage adapter.StartStage) error { return nil } +func (s *Server) loopUpdate() { + s.ticker = time.NewTicker(s.externalUIUpdateInterval) + if time.Since(s.lastUpdated) > s.externalUIUpdateInterval { + s.checkAndDownloadExternalUI(true) + } + for { + runtime.GC() + select { + case <-s.ctx.Done(): + return + case <-s.ticker.C: + s.checkAndDownloadExternalUI(true) + } + } +} + func (s *Server) Close() error { + if s.ticker != nil { + s.ticker.Stop() + } return common.Close( common.PtrOrNil(s.httpServer), s.trafficManager, @@ -225,9 +274,8 @@ func (s *Server) SetMode(newMode string) { s.modeUpdateHook.Emit(struct{}{}) } s.dnsRouter.ClearCache() - cacheFile := service.FromContext[adapter.CacheFile](s.ctx) - if cacheFile != nil { - err := cacheFile.StoreMode(newMode) + if s.cacheFile != nil { + err := s.cacheFile.StoreMode(newMode) if err != nil { s.logger.Error(E.Cause(err, "save mode")) } diff --git a/experimental/clashapi/server_resources.go b/experimental/clashapi/server_resources.go index ad9fff5369..8c9ddf1e1a 100644 --- a/experimental/clashapi/server_resources.go +++ b/experimental/clashapi/server_resources.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" @@ -20,20 +21,29 @@ import ( "github.com/sagernet/sing/service/filemanager" ) -func (s *Server) checkAndDownloadExternalUI() { +func (s *Server) checkAndDownloadExternalUI(update bool) error { if s.externalUI == "" { - return + return nil } entries, err := os.ReadDir(s.externalUI) if err != nil { - os.MkdirAll(s.externalUI, 0o755) + filemanager.MkdirAll(s.ctx, s.externalUI, 0o755) + } + if len(entries) != 0 && s.lastUpdated.IsZero() { + info, _ := os.Stat(s.externalUI) + s.lastUpdated = info.ModTime() } - if len(entries) == 0 { + if len(entries) == 0 || update { + if len(entries) == 0 && s.lastEtag != "" { + s.lastEtag = "" + } err = s.downloadExternalUI() if err != nil { - s.logger.Error("download external ui error: ", err) + s.logger.Error("download external UI error: ", err) + return err } } + return nil } func (s *Server) downloadExternalUI() error { @@ -54,7 +64,7 @@ func (s *Server) downloadExternalUI() error { outbound := s.outbound.Default() detour = outbound } - s.logger.Info("downloading external ui using outbound/", detour.Type(), "[", detour.Tag(), "]") + s.logger.Info("downloading external UI using outbound/", detour.Type(), "[", detour.Tag(), "]") httpClient := &http.Client{ Transport: &http.Transport{ ForceAttemptHTTP2: true, @@ -68,20 +78,60 @@ func (s *Server) downloadExternalUI() error { }, }, } - defer httpClient.CloseIdleConnections() - response, err := httpClient.Get(downloadURL) + request, err := http.NewRequest("GET", downloadURL, nil) if err != nil { return err } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return E.New("download external ui failed: ", response.Status) + if s.lastEtag != "" { + request.Header.Set("If-None-Match", s.lastEtag) + } + response, err := httpClient.Do(request.WithContext(s.ctx)) + if err != nil { + return err } + switch response.StatusCode { + case http.StatusOK: + case http.StatusNotModified: + s.lastUpdated = time.Now() + os.Chtimes(s.externalUI, s.lastUpdated, s.lastUpdated) + if s.cacheFile != nil { + if savedExternalUI := s.cacheFile.LoadExternalUI("ExternalUI"); savedExternalUI != nil { + savedExternalUI.LastUpdated = s.lastUpdated + err = s.cacheFile.SaveExternalUI("ExternalUI", savedExternalUI) + if err != nil { + s.logger.Error("save external UI updated time: ", err) + return nil + } + } + } + s.logger.Info("update external UI: not modified") + return nil + default: + return E.New("download external UI failed: ", response.Status) + } + defer response.Body.Close() + removeAllInDirectory(s.ctx, s.externalUI) err = s.downloadZIP(response.Body, s.externalUI) if err != nil { - removeAllInDirectory(s.externalUI) + removeAllInDirectory(s.ctx, s.externalUI) + return err + } + eTagHeader := response.Header.Get("Etag") + if eTagHeader != "" { + s.lastEtag = eTagHeader } - return err + s.lastUpdated = time.Now() + if s.cacheFile != nil { + err = s.cacheFile.SaveExternalUI("ExternalUI", &adapter.SavedBinary{ + LastEtag: s.lastEtag, + LastUpdated: s.lastUpdated, + }) + if err != nil { + s.logger.Error("save external UI cache file: ", err) + } + } + s.logger.Info("updated external UI") + return nil } func (s *Server) downloadZIP(body io.Reader, output string) error { @@ -89,7 +139,7 @@ func (s *Server) downloadZIP(body io.Reader, output string) error { if err != nil { return err } - defer os.Remove(tempFile.Name()) + defer filemanager.Remove(s.ctx, tempFile.Name()) _, err = io.Copy(tempFile, body) tempFile.Close() if err != nil { @@ -113,7 +163,7 @@ func (s *Server) downloadZIP(body io.Reader, output string) error { if len(pathElements) > 1 { saveDirectory = filepath.Join(saveDirectory, filepath.Join(pathElements[:len(pathElements)-1]...)) } - err = os.MkdirAll(saveDirectory, 0o755) + err = filemanager.MkdirAll(s.ctx, saveDirectory, 0o755) if err != nil { return err } @@ -140,13 +190,13 @@ func downloadZIPEntry(ctx context.Context, zipFile *zip.File, savePath string) e return common.Error(io.Copy(saveFile, reader)) } -func removeAllInDirectory(directory string) { +func removeAllInDirectory(ctx context.Context, directory string) { dirEntries, err := os.ReadDir(directory) if err != nil { return } for _, dirEntry := range dirEntries { - os.RemoveAll(filepath.Join(directory, dirEntry.Name())) + filemanager.RemoveAll(ctx, filepath.Join(directory, dirEntry.Name())) } } diff --git a/experimental/clashapi/trafficontrol/tracker.go b/experimental/clashapi/trafficontrol/tracker.go index 23500cd04d..089e1480d2 100644 --- a/experimental/clashapi/trafficontrol/tracker.go +++ b/experimental/clashapi/trafficontrol/tracker.go @@ -2,10 +2,12 @@ package trafficontrol import ( "net" + "net/netip" "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" F "github.com/sagernet/sing/common/format" @@ -16,16 +18,17 @@ import ( ) type TrackerMetadata struct { - ID uuid.UUID - Metadata adapter.InboundContext - CreatedAt time.Time - ClosedAt time.Time - Upload *atomic.Int64 - Download *atomic.Int64 - Chain []string - Rule adapter.Rule - Outbound string - OutboundType string + ID uuid.UUID + Metadata adapter.InboundContext + CreatedAt time.Time + ClosedAt time.Time + Upload *atomic.Int64 + Download *atomic.Int64 + Chain []string + Rule adapter.Rule + Outbound string + OutboundType string + outboundManager adapter.OutboundManager } func (t TrackerMetadata) MarshalJSON() ([]byte, error) { @@ -36,10 +39,16 @@ func (t TrackerMetadata) MarshalJSON() ([]byte, error) { inbound = t.Metadata.InboundType } var domain string - if t.Metadata.Domain != "" { + if t.Metadata.Destination.Fqdn != "" { + domain = t.Metadata.Destination.Fqdn + } else { domain = t.Metadata.Domain + } + var destinationAddr netip.Addr + if len(t.Metadata.DestinationAddresses) > 0 { + destinationAddr = t.Metadata.DestinationAddresses[0] } else { - domain = t.Metadata.Destination.Fqdn + destinationAddr = t.Metadata.Destination.Addr } var processPath string if t.Metadata.ProcessInfo != nil { @@ -64,23 +73,52 @@ func (t TrackerMetadata) MarshalJSON() ([]byte, error) { } else { rule = "final" } + chains := t.Chain + if t.OutboundType == C.TypeLoadBalance { + realOutboundChain := t.Metadata.GetRealOutboundChain() + if len(realOutboundChain) > 0 && t.outboundManager != nil { + var subChain []string + for _, realOutbound := range realOutboundChain { + next := realOutbound + for { + detour, loaded := t.outboundManager.Outbound(next) + if !loaded { + break + } + subChain = append(subChain, next) + group, isGroup := detour.(adapter.OutboundGroup) + if !isGroup { + break + } + next = group.Now() + if next == "" { + break + } + } + } + chains = make([]string, len(subChain)+len(t.Chain)) + copy(chains, common.Reverse(subChain)) + copy(chains[len(subChain):], t.Chain) + } + } return json.Marshal(map[string]any{ "id": t.ID, "metadata": map[string]any{ "network": t.Metadata.Network, "type": inbound, "sourceIP": t.Metadata.Source.Addr, - "destinationIP": t.Metadata.Destination.Addr, + "destinationIP": destinationAddr, "sourcePort": F.ToString(t.Metadata.Source.Port), "destinationPort": F.ToString(t.Metadata.Destination.Port), "host": domain, + "sniffHost": t.Metadata.SniffHost, "dnsMode": "normal", "processPath": processPath, }, "upload": t.Upload.Load(), "download": t.Download.Load(), "start": t.CreatedAt, - "chains": t.Chain, + "chains": chains, "rule": rule, "rulePayload": "", }) @@ -156,15 +194,16 @@ func NewTCPTracker(conn net.Conn, manager *Manager, metadata adapter.InboundCont manager.PushDownloaded(n) }}), metadata: TrackerMetadata{ - ID: id, - Metadata: metadata, - CreatedAt: time.Now(), - Upload: upload, - Download: download, - Chain: common.Reverse(chain), - Rule: matchRule, - Outbound: outbound, - OutboundType: outboundType, + ID: id, + Metadata: metadata, + CreatedAt: time.Now(), + Upload: upload, + Download: download, + Chain: common.Reverse(chain), + Rule: matchRule, + Outbound: outbound, + OutboundType: outboundType, + outboundManager: outboundManager, }, manager: manager, } @@ -237,15 +276,16 @@ func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata adapter.Inbound manager.PushDownloaded(n) }}), metadata: TrackerMetadata{ - ID: id, - Metadata: metadata, - CreatedAt: time.Now(), - Upload: upload, - Download: download, - Chain: common.Reverse(chain), - Rule: matchRule, - Outbound: outbound, - OutboundType: outboundType, + ID: id, + Metadata: metadata, + CreatedAt: time.Now(), + Upload: upload, + Download: download, + Chain: common.Reverse(chain), + Rule: matchRule, + Outbound: outbound, + OutboundType: outboundType, + outboundManager: outboundManager, }, manager: manager, } diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go index 122425d293..37707bdfd9 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.ProviderRegistry(), include.OutboundRegistry(), include.EndpointRegistry(), dnsRegistry, include.ServiceRegistry()) } func parseConfig(ctx context.Context, configContent string) (option.Options, error) { @@ -144,6 +144,18 @@ func (s *platformInterfaceStub) SendNotification(notification *adapter.Notificat return nil } +func (s *platformInterfaceStub) UsePlatformNeighborResolver() bool { + return false +} + +func (s *platformInterfaceStub) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return os.ErrInvalid +} + +func (s *platformInterfaceStub) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return nil +} + func (s *platformInterfaceStub) UsePlatformLocalDNSTransport() bool { return false } diff --git a/experimental/libbox/neighbor.go b/experimental/libbox/neighbor.go new file mode 100644 index 0000000000..e38aa8023f --- /dev/null +++ b/experimental/libbox/neighbor.go @@ -0,0 +1,53 @@ +package libbox + +import ( + "net" + "net/netip" +) + +type NeighborEntry struct { + Address string + MacAddress string + Hostname string +} + +type NeighborEntryIterator interface { + Next() *NeighborEntry + HasNext() bool +} + +type NeighborSubscription struct { + done chan struct{} +} + +func (s *NeighborSubscription) Close() { + close(s.done) +} + +func tableToIterator(table map[netip.Addr]net.HardwareAddr) NeighborEntryIterator { + entries := make([]*NeighborEntry, 0, len(table)) + for address, mac := range table { + entries = append(entries, &NeighborEntry{ + Address: address.String(), + MacAddress: mac.String(), + }) + } + return &neighborEntryIterator{entries} +} + +type neighborEntryIterator struct { + entries []*NeighborEntry +} + +func (i *neighborEntryIterator) HasNext() bool { + return len(i.entries) > 0 +} + +func (i *neighborEntryIterator) Next() *NeighborEntry { + if len(i.entries) == 0 { + return nil + } + entry := i.entries[0] + i.entries = i.entries[1:] + return entry +} diff --git a/experimental/libbox/neighbor_darwin.go b/experimental/libbox/neighbor_darwin.go new file mode 100644 index 0000000000..d7484a69b4 --- /dev/null +++ b/experimental/libbox/neighbor_darwin.go @@ -0,0 +1,123 @@ +//go:build darwin + +package libbox + +import ( + "net" + "net/netip" + "os" + "slices" + "time" + + "github.com/sagernet/sing-box/route" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + + xroute "golang.org/x/net/route" + "golang.org/x/sys/unix" +) + +func SubscribeNeighborTable(listener NeighborUpdateListener) (*NeighborSubscription, error) { + entries, err := route.ReadNeighborEntries() + if err != nil { + return nil, E.Cause(err, "initial neighbor dump") + } + table := make(map[netip.Addr]net.HardwareAddr) + for _, entry := range entries { + table[entry.Address] = entry.MACAddress + } + listener.UpdateNeighborTable(tableToIterator(table)) + routeSocket, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0) + if err != nil { + return nil, E.Cause(err, "open route socket") + } + err = unix.SetNonblock(routeSocket, true) + if err != nil { + unix.Close(routeSocket) + return nil, E.Cause(err, "set route socket nonblock") + } + subscription := &NeighborSubscription{ + done: make(chan struct{}), + } + go subscription.loop(listener, routeSocket, table) + return subscription, nil +} + +func (s *NeighborSubscription) loop(listener NeighborUpdateListener, routeSocket int, table map[netip.Addr]net.HardwareAddr) { + routeSocketFile := os.NewFile(uintptr(routeSocket), "route") + defer routeSocketFile.Close() + buffer := buf.NewPacket() + defer buffer.Release() + for { + select { + case <-s.done: + return + default: + } + tv := unix.NsecToTimeval(int64(3 * time.Second)) + _ = unix.SetsockoptTimeval(routeSocket, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv) + n, err := routeSocketFile.Read(buffer.FreeBytes()) + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-s.done: + return + default: + } + continue + } + messages, err := xroute.ParseRIB(xroute.RIBTypeRoute, buffer.FreeBytes()[:n]) + if err != nil { + continue + } + changed := false + for _, message := range messages { + routeMessage, isRouteMessage := message.(*xroute.RouteMessage) + if !isRouteMessage { + continue + } + if routeMessage.Flags&unix.RTF_LLINFO == 0 { + continue + } + address, mac, isDelete, ok := route.ParseRouteNeighborMessage(routeMessage) + if !ok { + continue + } + if isDelete { + if _, exists := table[address]; exists { + delete(table, address) + changed = true + } + } else { + existing, exists := table[address] + if !exists || !slices.Equal(existing, mac) { + table[address] = mac + changed = true + } + } + } + if changed { + listener.UpdateNeighborTable(tableToIterator(table)) + } + } +} + +func ReadBootpdLeases() NeighborEntryIterator { + leaseIPToMAC, ipToHostname, macToHostname := route.ReloadLeaseFiles([]string{"/var/db/dhcpd_leases"}) + entries := make([]*NeighborEntry, 0, len(leaseIPToMAC)) + for address, mac := range leaseIPToMAC { + entry := &NeighborEntry{ + Address: address.String(), + MacAddress: mac.String(), + } + hostname, found := ipToHostname[address] + if !found { + hostname = macToHostname[mac.String()] + } + entry.Hostname = hostname + entries = append(entries, entry) + } + return &neighborEntryIterator{entries} +} diff --git a/experimental/libbox/neighbor_linux.go b/experimental/libbox/neighbor_linux.go new file mode 100644 index 0000000000..ae10bdd2ee --- /dev/null +++ b/experimental/libbox/neighbor_linux.go @@ -0,0 +1,88 @@ +//go:build linux + +package libbox + +import ( + "net" + "net/netip" + "slices" + "time" + + "github.com/sagernet/sing-box/route" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +func SubscribeNeighborTable(listener NeighborUpdateListener) (*NeighborSubscription, error) { + entries, err := route.ReadNeighborEntries() + if err != nil { + return nil, E.Cause(err, "initial neighbor dump") + } + table := make(map[netip.Addr]net.HardwareAddr) + for _, entry := range entries { + table[entry.Address] = entry.MACAddress + } + listener.UpdateNeighborTable(tableToIterator(table)) + connection, err := netlink.Dial(unix.NETLINK_ROUTE, &netlink.Config{ + Groups: 1 << (unix.RTNLGRP_NEIGH - 1), + }) + if err != nil { + return nil, E.Cause(err, "subscribe neighbor updates") + } + subscription := &NeighborSubscription{ + done: make(chan struct{}), + } + go subscription.loop(listener, connection, table) + return subscription, nil +} + +func (s *NeighborSubscription) loop(listener NeighborUpdateListener, connection *netlink.Conn, table map[netip.Addr]net.HardwareAddr) { + defer connection.Close() + for { + select { + case <-s.done: + return + default: + } + err := connection.SetReadDeadline(time.Now().Add(3 * time.Second)) + if err != nil { + return + } + messages, err := connection.Receive() + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-s.done: + return + default: + } + continue + } + changed := false + for _, message := range messages { + address, mac, isDelete, ok := route.ParseNeighborMessage(message) + if !ok { + continue + } + if isDelete { + if _, exists := table[address]; exists { + delete(table, address) + changed = true + } + } else { + existing, exists := table[address] + if !exists || !slices.Equal(existing, mac) { + table[address] = mac + changed = true + } + } + } + if changed { + listener.UpdateNeighborTable(tableToIterator(table)) + } + } +} diff --git a/experimental/libbox/neighbor_stub.go b/experimental/libbox/neighbor_stub.go new file mode 100644 index 0000000000..d465bc7bb0 --- /dev/null +++ b/experimental/libbox/neighbor_stub.go @@ -0,0 +1,9 @@ +//go:build !linux && !darwin + +package libbox + +import "os" + +func SubscribeNeighborTable(_ NeighborUpdateListener) (*NeighborSubscription, error) { + return nil, os.ErrInvalid +} diff --git a/experimental/libbox/platform.go b/experimental/libbox/platform.go index 63c54ccf2c..d2cac4cf68 100644 --- a/experimental/libbox/platform.go +++ b/experimental/libbox/platform.go @@ -21,6 +21,13 @@ type PlatformInterface interface { SystemCertificates() StringIterator ClearDNSCache() SendNotification(notification *Notification) error + StartNeighborMonitor(listener NeighborUpdateListener) error + CloseNeighborMonitor(listener NeighborUpdateListener) error + RegisterMyInterface(name string) +} + +type NeighborUpdateListener interface { + UpdateNeighborTable(entries NeighborEntryIterator) } type ConnectionOwner struct { diff --git a/experimental/libbox/service.go b/experimental/libbox/service.go index 3a13f6d169..b521f0f8e9 100644 --- a/experimental/libbox/service.go +++ b/experimental/libbox/service.go @@ -78,6 +78,7 @@ func (w *platformInterfaceWrapper) OpenInterface(options *tun.Options, platformO } options.FileDescriptor = dupFd w.myTunName = options.Name + w.iif.RegisterMyInterface(options.Name) return tun.New(*options) } @@ -220,6 +221,46 @@ func (w *platformInterfaceWrapper) SendNotification(notification *adapter.Notifi return w.iif.SendNotification((*Notification)(notification)) } +func (w *platformInterfaceWrapper) UsePlatformNeighborResolver() bool { + return true +} + +func (w *platformInterfaceWrapper) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return w.iif.StartNeighborMonitor(&neighborUpdateListenerWrapper{listener: listener}) +} + +func (w *platformInterfaceWrapper) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return w.iif.CloseNeighborMonitor(nil) +} + +type neighborUpdateListenerWrapper struct { + listener adapter.NeighborUpdateListener +} + +func (w *neighborUpdateListenerWrapper) UpdateNeighborTable(entries NeighborEntryIterator) { + var result []adapter.NeighborEntry + for entries.HasNext() { + entry := entries.Next() + if entry == nil { + continue + } + address, err := netip.ParseAddr(entry.Address) + if err != nil { + continue + } + macAddress, err := net.ParseMAC(entry.MacAddress) + if err != nil { + continue + } + result = append(result, adapter.NeighborEntry{ + Address: address, + MACAddress: macAddress, + Hostname: entry.Hostname, + }) + } + w.listener.UpdateNeighborTable(result) +} + func AvailablePort(startPort int32) (int32, error) { for port := int(startPort); ; port++ { if port > 65535 { diff --git a/go.mod b/go.mod index c00a9a2d5e..dc8c5c4891 100644 --- a/go.mod +++ b/go.mod @@ -14,21 +14,24 @@ require ( github.com/godbus/dbus/v5 v5.2.2 github.com/gofrs/uuid/v5 v5.4.0 github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 + github.com/jsimonetti/rtnetlink v1.4.0 github.com/keybase/go-keychain v0.0.1 github.com/libdns/acmedns v0.5.0 github.com/libdns/alidns v1.0.6 github.com/libdns/cloudflare v0.2.2 github.com/logrusorgru/aurora v2.0.3+incompatible + github.com/mdlayher/netlink v1.9.0 github.com/metacubex/utls v1.8.4 github.com/mholt/acmez/v3 v3.1.6 github.com/miekg/dns v1.1.72 github.com/openai/openai-go/v3 v3.24.0 github.com/oschwald/maxminddb-golang v1.13.1 + github.com/pires/go-proxyproto v0.8.1 github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1 github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a github.com/sagernet/cors v1.2.1 - github.com/sagernet/cronet-go v0.0.0-20260303101018-cba7b9ac0399 - github.com/sagernet/cronet-go/all v0.0.0-20260303101018-cba7b9ac0399 + github.com/sagernet/cronet-go v0.0.0-20260306075351-e5943141aa40 + github.com/sagernet/cronet-go/all v0.0.0-20260306075351-e5943141aa40 github.com/sagernet/fswatch v0.1.1 github.com/sagernet/gomobile v0.1.12 github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 @@ -39,7 +42,7 @@ require ( github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 - github.com/sagernet/sing-tun v0.8.2 + github.com/sagernet/sing-tun v0.8.3-0.20260305131414-5083da5745ed github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6.0.20260303140313-3bcf9a4b9349 @@ -58,6 +61,7 @@ require ( golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 howett.net/plist v1.0.1 ) @@ -92,48 +96,45 @@ require ( github.com/hashicorp/yamux v0.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jsimonetti/rtnetlink v1.4.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/libdns/libdns v1.1.1 // indirect - github.com/mdlayher/netlink v1.9.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect - github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect - github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260306074725-2e4f95b376d3 // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260306074725-2e4f95b376d3 // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/nftables v0.3.0-beta.4 // indirect github.com/spf13/pflag v1.0.9 // indirect @@ -163,6 +164,5 @@ require ( golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index 9348343a07..6f5c06f8af 100644 --- a/go.sum +++ b/go.sum @@ -162,68 +162,68 @@ github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkk github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= -github.com/sagernet/cronet-go v0.0.0-20260303101018-cba7b9ac0399 h1:x3tVYQHdqqnKbEd9/H4KIGhtHTjA+KfiiaXedI3/w8Q= -github.com/sagernet/cronet-go v0.0.0-20260303101018-cba7b9ac0399/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw= -github.com/sagernet/cronet-go/all v0.0.0-20260303101018-cba7b9ac0399 h1:mD3ehudpYf1IFgCTv25d/B6KnBc/lLFq1jmSQIK24y0= -github.com/sagernet/cronet-go/all v0.0.0-20260303101018-cba7b9ac0399/go.mod h1:MbYagcGGIaRo9tNrgafbCTO+Qc7eVEh32ZWMprSB8b0= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260303100323-125d0d93b3e6 h1:ghRKgSaswefPwQF8AYtUlNyumILOB0ptJWxgZ8MFrEE= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260303100323-125d0d93b3e6 h1:Behr7YCnQP2dsvzAJDIoMd5nTVU9/d6MMtk/S3MctwA= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260303100323-125d0d93b3e6 h1:6UL9XdGU/44oTHj36e+EBDJ0RonFoObmd299NG/qQCU= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:Q9apxjtkj6iMIBQlTo71QsOTrNlhHneaXQb1Q0IshU8= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260303100323-125d0d93b3e6 h1:0N+xlnMkFEeqgFe3X/PEvHt+/t+BPgxmbx7wzNcYppg= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:7f2vTXtePikBSV1bdD0zs5/WuZM+bRuej3mREpWL/qQ= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6 h1:HMlnhEYs+axOa0tAJ79se3QsYB8CpRCQo9mewWWFeeg= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:Ux/U6vF+1AoGLSJK3jVa9Kqkn64MX4Ivv7fy0ikDrpQ= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6 h1:5Dhuere2bQFzfGvKxA7TFgA5MoTtgcZMmJQuKwQKlyA= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260303100323-125d0d93b3e6 h1:aMRcLow4UpZWZ28fR9FjveTL/4okrigZySIkEVZnlgA= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260303100323-125d0d93b3e6 h1:y4g8oNtEfSdcKrBKsH5vMAjzGthvhHFNU80sanYDQEM= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260303100323-125d0d93b3e6 h1:CXN6OPILi5trwffmYiiJ9rqJL3XAWx1menLrBBwA0gU= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260303100323-125d0d93b3e6 h1:ZphFHQeFOTpqCWPwFcQRnrePXajml8LbKlYFJ5n0isU= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260303100323-125d0d93b3e6 h1:nKzFK84oANHz7I6bab+25bBY+pdpAbO0b3NJroyLldo= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:HqqZUGRXcWvvwlbuvjk/efo8TKW1H/aHdqQTde+Xs9Q= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260303100323-125d0d93b3e6 h1:D2v9lZZG5sm4x/CkG7uqc6ZU3YlhFQ+GmJfvZMK0h/s= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260303100323-125d0d93b3e6 h1:TWveNeXHrA5r8XOlf+vw7U2b2M0ip6GNF89jcUi1ogw= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260303100323-125d0d93b3e6 h1:DVCBoXOZI4PNG0cbCLg8lrphRXoLFcAIDLNmzsCVg3I= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260303100323-125d0d93b3e6 h1:7s5xqNlBUWkIXdruPYi3/txXekQhGWxrYxbnB0cnARo= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260303100323-125d0d93b3e6 h1:eyEb+Q7VH4hpE1nV+EmEnN2XX5WilgBpIsfCw4C/7no= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260303100323-125d0d93b3e6 h1:9F1W7+z1hHST6GSzdpQ8Q0NCkneAL18dkRA1HfxH09A= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260303100323-125d0d93b3e6 h1:MmQIR3iJsdvw1ONBP3geK57i9c3+v9dXPMNdZYcYGKw= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260303100323-125d0d93b3e6 h1:j6Pk1Wsl+PCbKRXtp7a912D2D6zqX5Nk51wDQU9TEDc= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260303100323-125d0d93b3e6 h1:0DnFhbRfNqwguNCxiinA7BowQ/RaFt627sjW09JNp80= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6 h1:3CZmlEk2/WW5UHLFJZxXPJ9IJxX3td8U3PyqWSGMl3c= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:eHkVRptoZf3BuuskkjcclO2dwQrX4zluoVGODMrX7n0= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6 h1:UgFmE0cZo9euu8/7sTAhj1G8lldavwXBdcPNyTE29CQ= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260303100323-125d0d93b3e6 h1:xbg3ZB9tLMGDQe4+aewG0Z4bEP/2pLtYBcDzILv5eEc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:M0bTSTSTnSMlPY2WaZT6fL5TFICqk8v4cm+QVf8Fcao= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/cronet-go v0.0.0-20260306075351-e5943141aa40 h1:A9P5YN0Tq+quO9vISIOL+PkExbGWAroyNIk9pI309ls= +github.com/sagernet/cronet-go v0.0.0-20260306075351-e5943141aa40/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw= +github.com/sagernet/cronet-go/all v0.0.0-20260306075351-e5943141aa40 h1:0W9yjyRZ/9peX7jFlruJgOhydBzqj0u7uRY+NUFlbCE= +github.com/sagernet/cronet-go/all v0.0.0-20260306075351-e5943141aa40/go.mod h1:U54HWP2v0xDyTEpAcof98Y923Lr1ymOvFWpa8aVBBAk= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260306074725-2e4f95b376d3 h1:Par4t1sZVTJodVxVoGoaSi4MTojaDrraHXCK5Xjt/rM= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260306074725-2e4f95b376d3 h1:Wg7qunP2EtGnQSHaAL2a/shion6Y5QatyFtAoMcZjdg= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260306074725-2e4f95b376d3 h1:JZSGrRe1y5yR+REJLK2X1ZxHcUnXc110m7rEuqkhurk= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260306074725-2e4f95b376d3 h1:DwgYmuUd36tXSJuu3wK1HntOifcRPifDc/s6X6LdVSQ= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260306074725-2e4f95b376d3 h1:jjjSy31cytxMRYLoNlwA98YasRAe0P5EEsw5c4Pwvv0= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260306074725-2e4f95b376d3 h1:2b/N8xhl+MBRIg70sHYuJ/3V3gJu3F4aVTndxFnbICU= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260306074725-2e4f95b376d3 h1:CysHa5F+LqLumG3HUfUbQzWIbG13QMTUMkkc2DTHclU= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260306074725-2e4f95b376d3 h1:Lf9FtR/87jNgc+0yeCCxlvlu2RLSrlaaYfVlYCJeFq0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260306074725-2e4f95b376d3 h1:1X6PNucfXzZB21EOP0aBn+m06UgL6e4oJZJ2bcqrbtM= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260306074725-2e4f95b376d3 h1:XvHeLlblB6nXilTqfDI+SxyIuR2FUkpNkL9mXNt/wNg= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260306074725-2e4f95b376d3 h1:ACHr8UvOHs/+S29L7UcCrTe3P53NuZbKzHmwCpteyoo= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260306074725-2e4f95b376d3 h1:MzSFaCUaGn/a4jAGw7Qnm0t5ssnx1z87YEqwvG1ZhRU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260306074725-2e4f95b376d3 h1:i7lFKCd4AcKut4Co/jEzvb9d1d10K3t4un9NarqAyo0= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260306074725-2e4f95b376d3 h1:aLBHE3UGmBf+f+Vf5ceYDzsKPufDfYoMILrMhqwsJYI= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260306074725-2e4f95b376d3 h1:5CZoDiP1u3REF7LcBYoQgBuWacnBcxWeERU5UrQDqHg= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260306074725-2e4f95b376d3 h1:4W7D6UUZH5/636fE2VMHJ+YLofmYWaBhAlvaj23C20I= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260306074725-2e4f95b376d3 h1:zK/9ebQ3Ykcvomc+JEIou8rgIxbU1O6bBB7z2A3irO4= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260306074725-2e4f95b376d3 h1:q79ByUHlbxPcADvOZ2G8ayCnLBlF/fzHtvLennf2clo= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260306074725-2e4f95b376d3 h1:3RNNwgX1rltXu7gIGD12gxlIJc1s8e2stB2BzMtl9tE= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260306074725-2e4f95b376d3 h1:/hD/Vk7/Jlg07Ic1atNjU1mXii91ziN6e3zxFYTKqio= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260306074725-2e4f95b376d3 h1:d7Z63bQ/U7ZmB1MkC1dtAtIn6h40WrHey9S/vnfDb5g= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260306074725-2e4f95b376d3 h1:1c6ZqstM62BrbTFrCA4vINFTCooCM8uph6uIGfAEfqQ= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260306074725-2e4f95b376d3 h1:ZTHDXreHG+9XT0hD+MIu1etqPQAfKBApFS8Z1XMT7Nw= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260306074725-2e4f95b376d3 h1:ry0S9V5pSNTg2wXra1rBajSITvXRufgw0u3w/mE0GB4= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260306074725-2e4f95b376d3 h1:iO5cm5MiqvKQB7QkY2b8QFgnMt3jDdOiDopX2aNsFOM= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260306074725-2e4f95b376d3 h1:DC03qT5UTbDgUzJ78xajYXq5UYcFHBLHKIoH+PRpCf0= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260306074725-2e4f95b376d3 h1:uLqZSA2OAynMxrokxVO2pW3unWA8DNjion/I4ihX/84= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260306074725-2e4f95b376d3 h1:WHTBryhjXaniv5fMjSr/FvWKyAhdomD7rLagh4ano10= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260306074725-2e4f95b376d3 h1:TmikX4Xtalpv2Jts/MuB5qwg+KmTKbrpPf5deZGLIqA= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260306074725-2e4f95b376d3/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs= github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o= github.com/sagernet/gomobile v0.1.12 h1:XwzjZaclFF96deLqwAgK8gU3w0M2A8qxgDmhV+A0wjg= @@ -248,8 +248,8 @@ github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnq github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w= github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= -github.com/sagernet/sing-tun v0.8.2 h1:rQr/x3eQCHh3oleIaoJdPdJwqzZp4+QWcJLT0Wz2xKY= -github.com/sagernet/sing-tun v0.8.2/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs= +github.com/sagernet/sing-tun v0.8.3-0.20260305131414-5083da5745ed h1:0XZgwnEX2HgQ/0J0The6KPEAezBz5bLl18PMTRHNN9E= +github.com/sagernet/sing-tun v0.8.3-0.20260305131414-5083da5745ed/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= diff --git a/include/registry.go b/include/registry.go index f090845b51..e2e9b403cc 100644 --- a/include/registry.go +++ b/include/registry.go @@ -3,11 +3,12 @@ package include import ( "context" - "github.com/sagernet/sing-box" + box "github.com/sagernet/sing-box" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/endpoint" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/adapter/provider" "github.com/sagernet/sing-box/adapter/service" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" @@ -24,6 +25,7 @@ import ( "github.com/sagernet/sing-box/protocol/http" "github.com/sagernet/sing-box/protocol/mixed" "github.com/sagernet/sing-box/protocol/naive" + "github.com/sagernet/sing-box/protocol/pass" "github.com/sagernet/sing-box/protocol/redirect" "github.com/sagernet/sing-box/protocol/shadowsocks" "github.com/sagernet/sing-box/protocol/shadowtls" @@ -34,13 +36,15 @@ import ( "github.com/sagernet/sing-box/protocol/tun" "github.com/sagernet/sing-box/protocol/vless" "github.com/sagernet/sing-box/protocol/vmess" + providerLocal "github.com/sagernet/sing-box/provider/local" + "github.com/sagernet/sing-box/provider/remote" "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(), ProviderRegistry(), OutboundRegistry(), EndpointRegistry(), DNSTransportRegistry(), ServiceRegistry()) } func InboundRegistry() *inbound.Registry { @@ -69,15 +73,27 @@ func InboundRegistry() *inbound.Registry { return registry } +func ProviderRegistry() *provider.Registry { + registry := provider.NewRegistry() + + providerLocal.RegisterProviderInline(registry) + providerLocal.RegisterProviderLocal(registry) + remote.RegisterProvider(registry) + + return registry +} + func OutboundRegistry() *outbound.Registry { registry := outbound.NewRegistry() direct.RegisterOutbound(registry) + pass.RegisterOutbound(registry) block.RegisterOutbound(registry) group.RegisterSelector(registry) group.RegisterURLTest(registry) + group.RegisterLoadBalance(registry) socks.RegisterOutbound(registry) http.RegisterOutbound(registry) @@ -113,6 +129,7 @@ func DNSTransportRegistry() *dns.TransportRegistry { transport.RegisterUDP(registry) transport.RegisterTLS(registry) transport.RegisterHTTPS(registry) + transport.RegisterGroup(registry) hosts.RegisterTransport(registry) local.RegisterTransport(registry) fakeip.RegisterTransport(registry) diff --git a/mkdocs.yml b/mkdocs.yml index 081ba3aa18..09e088f7b4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -96,6 +96,7 @@ nav: - FakeIP: configuration/dns/server/fakeip.md - Tailscale: configuration/dns/server/tailscale.md - Resolved: configuration/dns/server/resolved.md + - Group: configuration/dns/server/group.md - DNS Rule: configuration/dns/rule.md - DNS Rule Action: configuration/dns/rule_action.md - FakeIP: configuration/dns/fakeip.md @@ -129,6 +130,7 @@ nav: - UDP over TCP: configuration/shared/udp-over-tcp.md - TCP Brutal: configuration/shared/tcp-brutal.md - Wi-Fi State: configuration/shared/wifi-state.md + - Neighbor Resolution: configuration/shared/neighbor.md - Endpoint: - configuration/endpoint/index.md - WireGuard: configuration/endpoint/wireguard.md @@ -174,6 +176,10 @@ nav: - DNS: configuration/outbound/dns.md - Selector: configuration/outbound/selector.md - URLTest: configuration/outbound/urltest.md + - Provider: + - configuration/provider/index.md + - Dialer Fields Override: configuration/provider/override_dialer.md + - TLS Fields Override: configuration/provider/override_tls.md - Service: - configuration/service/index.md - DERP: configuration/service/derp.md @@ -276,6 +282,9 @@ plugins: Endpoint: 端点 Inbound: 入站 Outbound: 出站 + Provider: 提供者 + Dialer Fields Override: 拨号字段覆写 + TLS Fields Override: TLS 字段覆写 Manual: 手册 reconfigure_material: true diff --git a/option/anytls.go b/option/anytls.go index 0f78526327..c1a5883510 100644 --- a/option/anytls.go +++ b/option/anytls.go @@ -5,8 +5,10 @@ import "github.com/sagernet/sing/common/json/badoption" type AnyTLSInboundOptions struct { ListenOptions InboundTLSOptionsContainer - Users []AnyTLSUser `json:"users,omitempty"` - PaddingScheme badoption.Listable[string] `json:"padding_scheme,omitempty"` + Users []AnyTLSUser `json:"users,omitempty"` + PaddingScheme badoption.Listable[string] `json:"padding_scheme,omitempty"` + Fallback *ServerOptions `json:"fallback,omitempty"` + FallbackForALPN map[string]*ServerOptions `json:"fallback_for_alpn,omitempty"` } type AnyTLSUser struct { diff --git a/option/direct.go b/option/direct.go index a03f98d412..59f18d62e8 100644 --- a/option/direct.go +++ b/option/direct.go @@ -16,12 +16,12 @@ type DirectInboundOptions struct { type _DirectOutboundOptions struct { DialerOptions + DirectDomainStrategy DomainStrategy `json:"direct_domain_strategy,omitempty"` + ProxyProtocol uint8 `json:"proxy_protocol,omitempty"` // Deprecated: Use Route Action instead OverrideAddress string `json:"override_address,omitempty"` // Deprecated: Use Route Action instead OverridePort uint16 `json:"override_port,omitempty"` - // Deprecated: removed - ProxyProtocol uint8 `json:"proxy_protocol,omitempty"` } type DirectOutboundOptions _DirectOutboundOptions diff --git a/option/dns.go b/option/dns.go index 4c1ac208bf..902c804817 100644 --- a/option/dns.go +++ b/option/dns.go @@ -2,6 +2,7 @@ package option import ( "context" + "net/http" "net/netip" "net/url" @@ -19,10 +20,11 @@ import ( ) type RawDNSOptions struct { - Servers []DNSServerOptions `json:"servers,omitempty"` - Rules []DNSRule `json:"rules,omitempty"` - Final string `json:"final,omitempty"` - ReverseMapping bool `json:"reverse_mapping,omitempty"` + Servers []DNSServerOptions `json:"servers,omitempty"` + Rules []DNSRule `json:"rules,omitempty"` + Final string `json:"final,omitempty"` + ReverseMapping bool `json:"reverse_mapping,omitempty"` + DefaultRejectRcode *DNSRejectRCode `json:"default_reject_rcode,omitempty"` DNSClientOptions } @@ -107,7 +109,11 @@ type DNSClientOptions struct { DisableCache bool `json:"disable_cache,omitempty"` DisableExpire bool `json:"disable_expire,omitempty"` IndependentCache bool `json:"independent_cache,omitempty"` + RoundRobinCache bool `json:"round_robin_cache,omitempty"` CacheCapacity uint32 `json:"cache_capacity,omitempty"` + MinCacheTTL uint32 `json:"min_cache_ttl,omitempty"` + MaxCacheTTL uint32 `json:"max_cache_ttl,omitempty"` + LazyCacheTTL uint32 `json:"lazy_cache_ttl,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` } @@ -386,18 +392,57 @@ type RemoteDNSServerOptions struct { LegacyAddressFallbackDelay badoption.Duration `json:"-"` } +type RemoteTCPDNSServerOptions struct { + RemoteDNSServerOptions + Reuse bool `json:"reuse,omitempty"` + Pipeline bool `json:"pipeline,omitempty"` + MaxQueries int `json:"max_queries,omitempty"` +} + type RemoteTLSDNSServerOptions struct { RemoteDNSServerOptions OutboundTLSOptionsContainer + Pipeline bool `json:"pipeline,omitempty"` + MaxQueries int `json:"max_queries,omitempty"` } -type RemoteHTTPSDNSServerOptions struct { +type _RemoteHTTPSDNSServerOptions struct { RemoteTLSDNSServerOptions Path string `json:"path,omitempty"` Method string `json:"method,omitempty"` Headers badoption.HTTPHeader `json:"headers,omitempty"` } +type GroupDNSServerOptions struct { + Servers []string `json:"servers"` +} + +type RemoteHTTPSDNSServerOptions _RemoteHTTPSDNSServerOptions + +func (o *RemoteHTTPSDNSServerOptions) MarshalJSONContext(ctx context.Context) ([]byte, error) { + switch o.Method { + case http.MethodPost: + o.Method = "" + } + return badjson.MarshallObjectsContext(ctx, (*_RemoteHTTPSDNSServerOptions)(o)) +} + +func (o *RemoteHTTPSDNSServerOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_RemoteHTTPSDNSServerOptions)(o)) + if err != nil { + return err + } + switch o.Method { + case "", http.MethodPost: + o.Method = http.MethodPost + case http.MethodGet: + o.Method = http.MethodGet + default: + return E.New("unsupported method") + } + return nil +} + type FakeIPDNSServerOptions struct { Inet4Range *badoption.Prefix `json:"inet4_range,omitempty"` Inet6Range *badoption.Prefix `json:"inet6_range,omitempty"` diff --git a/option/dns_record.go b/option/dns_record.go index fa72b61b73..e5ca1fe8da 100644 --- a/option/dns_record.go +++ b/option/dns_record.go @@ -48,6 +48,54 @@ func (r *DNSRCode) Build() int { return int(*r) } +type DNSRejectRCode int + +func (r DNSRejectRCode) MarshalJSON() ([]byte, error) { + if int(r) == -1 { + return json.Marshal(string("")) + } + rCodeValue, loaded := dns.RcodeToString[int(r)] + if loaded { + return json.Marshal(rCodeValue) + } + return json.Marshal(int(r)) +} + +func (r *DNSRejectRCode) UnmarshalJSON(bytes []byte) error { + var intValue int + err := json.Unmarshal(bytes, &intValue) + if err == nil { + if intValue == -1 { + *r = -1 + return nil + } + *r = DNSRejectRCode(intValue) + return nil + } + var stringValue string + err = json.Unmarshal(bytes, &stringValue) + if err != nil { + return err + } + if stringValue == "" { + *r = -1 + return nil + } + rCodeValue, loaded := dns.StringToRcode[stringValue] + if !loaded { + return E.New("unknown rcode: " + stringValue) + } + *r = DNSRejectRCode(rCodeValue) + return nil +} + +func (r *DNSRejectRCode) Build() int { + if r == nil { + return -1 + } + return int(*r) +} + type DNSRecordOptions struct { dns.RR fromBase64 bool diff --git a/option/experimental.go b/option/experimental.go index bf0df9e78c..7fcc6b99d2 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -3,10 +3,11 @@ package option import "github.com/sagernet/sing/common/json/badoption" type ExperimentalOptions struct { - CacheFile *CacheFileOptions `json:"cache_file,omitempty"` - ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` - V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"` - Debug *DebugOptions `json:"debug,omitempty"` + CacheFile *CacheFileOptions `json:"cache_file,omitempty"` + ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` + V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"` + Debug *DebugOptions `json:"debug,omitempty"` + URLTestUnifiedDelay bool `json:"urltest_unified_delay,omitempty"` } type CacheFileOptions struct { @@ -23,6 +24,7 @@ type ClashAPIOptions struct { ExternalUI string `json:"external_ui,omitempty"` ExternalUIDownloadURL string `json:"external_ui_download_url,omitempty"` ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty"` + ExternalUIUpdateInterval badoption.Duration `json:"external_ui_update_interval,omitempty"` Secret string `json:"secret,omitempty"` DefaultMode string `json:"default_mode,omitempty"` ModeList []string `json:"-"` diff --git a/option/group.go b/option/group.go index 02b3a5ecb9..dd47168ce4 100644 --- a/option/group.go +++ b/option/group.go @@ -3,16 +3,40 @@ package option import "github.com/sagernet/sing/common/json/badoption" type SelectorOutboundOptions struct { - Outbounds []string `json:"outbounds"` - Default string `json:"default,omitempty"` - InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` + GroupCommonOption + Default string `json:"default,omitempty"` + InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` } type URLTestOutboundOptions struct { - Outbounds []string `json:"outbounds"` + GroupCommonOption + URL string `json:"url,omitempty"` + Interval badoption.Duration `json:"interval,omitempty"` + Tolerance uint16 `json:"tolerance,omitempty"` + IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` + InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` + Fallback URLTestFallbackOptions `json:"fallback,omitempty"` +} + +type GroupCommonOption struct { + Outbounds []string `json:"outbounds"` + Providers []string `json:"providers"` + Exclude *badoption.Regexp `json:"exclude,omitempty"` + Include *badoption.Regexp `json:"include,omitempty"` + UseAllProviders bool `json:"use_all_providers,omitempty"` +} + +type URLTestFallbackOptions struct { + Enabled bool `json:"enabled,omitempty"` + MaxDelay badoption.Duration `json:"max_delay,omitempty"` +} + +type LoadBalanceOutboundOptions struct { + GroupCommonOption URL string `json:"url,omitempty"` Interval badoption.Duration `json:"interval,omitempty"` - Tolerance uint16 `json:"tolerance,omitempty"` IdleTimeout badoption.Duration `json:"idle_timeout,omitempty"` + TTL badoption.Duration `json:"ttl,omitempty"` InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` + Strategy string `json:"strategy,omitempty"` } diff --git a/option/inbound.go b/option/inbound.go index 4fb6081dc0..151b567e37 100644 --- a/option/inbound.go +++ b/option/inbound.go @@ -58,26 +58,24 @@ type InboundOptions struct { } type ListenOptions struct { - Listen *badoption.Addr `json:"listen,omitempty"` - ListenPort uint16 `json:"listen_port,omitempty"` - BindInterface string `json:"bind_interface,omitempty"` - RoutingMark FwMark `json:"routing_mark,omitempty"` - ReuseAddr bool `json:"reuse_addr,omitempty"` - NetNs string `json:"netns,omitempty"` - DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` - TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` - TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` - TCPFastOpen bool `json:"tcp_fast_open,omitempty"` - TCPMultiPath bool `json:"tcp_multi_path,omitempty"` - UDPFragment *bool `json:"udp_fragment,omitempty"` - UDPFragmentDefault bool `json:"-"` - UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` - Detour string `json:"detour,omitempty"` - - // Deprecated: removed - ProxyProtocol bool `json:"proxy_protocol,omitempty"` - // Deprecated: removed - ProxyProtocolAcceptNoHeader bool `json:"proxy_protocol_accept_no_header,omitempty"` + Listen *badoption.Addr `json:"listen,omitempty"` + ListenPort uint16 `json:"listen_port,omitempty"` + BindInterface string `json:"bind_interface,omitempty"` + RoutingMark FwMark `json:"routing_mark,omitempty"` + ReuseAddr bool `json:"reuse_addr,omitempty"` + NetNs string `json:"netns,omitempty"` + DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` + TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` + TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` + TCPKeepAliveCount int `json:"tcp_keep_alive_count,omitempty"` + TCPFastOpen bool `json:"tcp_fast_open,omitempty"` + TCPMultiPath bool `json:"tcp_multi_path,omitempty"` + UDPFragment *bool `json:"udp_fragment,omitempty"` + UDPFragmentDefault bool `json:"-"` + UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` + Detour string `json:"detour,omitempty"` + ProxyProtocol bool `json:"proxy_protocol,omitempty"` + ProxyProtocolAcceptNoHeader bool `json:"proxy_protocol_accept_no_header,omitempty"` InboundOptions } diff --git a/option/options.go b/option/options.go index 8bebd48fc6..fcca94c35a 100644 --- a/option/options.go +++ b/option/options.go @@ -19,6 +19,7 @@ type _Options struct { Endpoints []Endpoint `json:"endpoints,omitempty"` Inbounds []Inbound `json:"inbounds,omitempty"` Outbounds []Outbound `json:"outbounds,omitempty"` + Providers []Provider `json:"providers,omitempty"` Route *RouteOptions `json:"route,omitempty"` Services []Service `json:"services,omitempty"` Experimental *ExperimentalOptions `json:"experimental,omitempty"` diff --git a/option/outbound.go b/option/outbound.go index cb388c4439..9f9045cc96 100644 --- a/option/outbound.go +++ b/option/outbound.go @@ -80,6 +80,7 @@ type DialerOptions struct { DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` + TCPKeepAliveCount int `json:"tcp_keep_alive_count,omitempty"` UDPFragment *bool `json:"udp_fragment,omitempty"` UDPFragmentDefault bool `json:"-"` DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` diff --git a/option/provider.go b/option/provider.go new file mode 100644 index 0000000000..a5b87923e2 --- /dev/null +++ b/option/provider.go @@ -0,0 +1,159 @@ +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/common/json/badoption" + "github.com/sagernet/sing/service" +) + +type ProviderOptionsRegistry interface { + CreateOptions(providerType string) (any, bool) +} +type _Provider struct { + Type string `json:"type"` + Tag string `json:"tag,omitempty"` + Options any `json:"-"` +} + +type Provider _Provider + +func (h *Provider) MarshalJSONContext(ctx context.Context) ([]byte, error) { + return badjson.MarshallObjectsContext(ctx, (*_Provider)(h), h.Options) +} + +func (h *Provider) UnmarshalJSONContext(ctx context.Context, content []byte) error { + err := json.UnmarshalContext(ctx, content, (*_Provider)(h)) + if err != nil { + return err + } + registry := service.FromContext[ProviderOptionsRegistry](ctx) + if registry == nil { + return E.New("missing provider options registry in context") + } + options, loaded := registry.CreateOptions(h.Type) + if !loaded { + return E.New("unknown provider type: ", h.Type) + } + err = badjson.UnmarshallExcludedContext(ctx, content, (*_Provider)(h), options) + if err != nil { + return err + } + h.Options = options + return nil +} + +type ProviderLocalOptions struct { + Path string `json:"path"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` + + OverrideDialer *OverrideDialerOptions `json:"override_dialer,omitempty"` + OverrideTLS *OverrideTLSOptions `json:"override_tls,omitempty"` +} + +type ProviderRemoteOptions struct { + URL string `json:"url"` + Path string `json:"path,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + DownloadDetour string `json:"download_detour,omitempty"` + UpdateInterval badoption.Duration `json:"update_interval,omitempty"` + + Exclude *badoption.Regexp `json:"exclude,omitempty"` + Include *badoption.Regexp `json:"include,omitempty"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` + + OverrideDialer *OverrideDialerOptions `json:"override_dialer,omitempty"` + OverrideTLS *OverrideTLSOptions `json:"override_tls,omitempty"` +} + +type ProviderInlineOptions struct { + Outbounds []Outbound `json:"outbounds,omitempty"` + HealthCheck ProviderHealthCheckOptions `json:"health_check,omitempty"` +} + +type ProviderHealthCheckOptions struct { + Enabled bool `json:"enabled,omitempty"` + URL string `json:"url,omitempty"` + Interval badoption.Duration `json:"interval,omitempty"` + Timeout badoption.Duration `json:"timeout,omitempty"` +} + +type OverrideDialerOptions struct { + Detour *string `json:"detour,omitempty"` + BindInterface *string `json:"bind_interface,omitempty"` + Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"` + Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"` + ProtectPath *string `json:"protect_path,omitempty"` + RoutingMark *FwMark `json:"routing_mark,omitempty"` + ReuseAddr *bool `json:"reuse_addr,omitempty"` + ConnectTimeout *badoption.Duration `json:"connect_timeout,omitempty"` + TCPFastOpen *bool `json:"tcp_fast_open,omitempty"` + TCPMultiPath *bool `json:"tcp_multi_path,omitempty"` + TCPKeepAlive *badoption.Duration `json:"tcp_keep_alive,omitempty"` + TCPKeepAliveInterval *badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` + UDPFragment *bool `json:"udp_fragment,omitempty"` + DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` + NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` + NetworkType *badoption.Listable[InterfaceType] `json:"network_type,omitempty"` + FallbackNetworkType *badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"` + FallbackDelay *badoption.Duration `json:"fallback_delay,omitempty"` + + TCPKeepAliveCount *int `json:"tcp_keep_alive_count,omitempty"` + DisableTCPKeepAlive *bool `json:"disable_tcp_keep_alive,omitempty"` + + // Deprecated: migrated to domain resolver + DomainStrategy *DomainStrategy `json:"domain_strategy,omitempty"` +} + +type OverrideTLSOptions struct { + Enabled *bool `json:"enabled,omitempty"` + DisableSNI *bool `json:"disable_sni,omitempty"` + ServerName *string `json:"server_name,omitempty"` + Insecure *bool `json:"insecure,omitempty"` + ALPN *badoption.Listable[string] `json:"alpn,omitempty"` + MinVersion *string `json:"min_version,omitempty"` + MaxVersion *string `json:"max_version,omitempty"` + CipherSuites *badoption.Listable[string] `json:"cipher_suites,omitempty"` + CurvePreferences *badoption.Listable[CurvePreference] `json:"curve_preferences,omitempty"` + Certificate *badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath *string `json:"certificate_path,omitempty"` + CertificatePublicKeySHA256 *badoption.Listable[[]byte] `json:"certificate_public_key_sha256,omitempty"` + ClientCertificate *badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath *string `json:"client_certificate_path,omitempty"` + ClientKey *badoption.Listable[string] `json:"client_key,omitempty"` + ClientKeyPath *string `json:"client_key_path,omitempty"` + CertificatePinSHA256 *string `json:"certificate_pin_sha256,omitempty"` + Fragment *bool `json:"fragment,omitempty"` + FragmentFallbackDelay *badoption.Duration `json:"fragment_fallback_delay,omitempty"` + RecordFragment *bool `json:"record_fragment,omitempty"` + KernelTx *bool `json:"kernel_tx,omitempty"` + KernelRx *bool `json:"kernel_rx,omitempty"` + ECH *OverrideECHOptions `json:"ech,omitempty"` + UTLS *OverrideUTLSOptions `json:"utls,omitempty"` + Reality *OverrideRealityOptions `json:"reality,omitempty"` +} + +type OverrideECHOptions struct { + Enabled *bool `json:"enabled,omitempty"` + Config *badoption.Listable[string] `json:"config,omitempty"` + ConfigPath *string `json:"config_path,omitempty"` + + // Deprecated: not supported by stdlib + PQSignatureSchemesEnabled *bool `json:"pq_signature_schemes_enabled,omitempty"` + // Deprecated: added by fault + DynamicRecordSizingDisabled *bool `json:"dynamic_record_sizing_disabled,omitempty"` +} + +type OverrideUTLSOptions struct { + Enabled *bool `json:"enabled,omitempty"` + Fingerprint *string `json:"fingerprint,omitempty"` +} + +type OverrideRealityOptions struct { + Enabled *bool `json:"enabled,omitempty"` + PublicKey *string `json:"public_key,omitempty"` + ShortID *string `json:"short_id,omitempty"` +} diff --git a/option/route.go b/option/route.go index f4b6539156..2542b8a930 100644 --- a/option/route.go +++ b/option/route.go @@ -9,6 +9,8 @@ type RouteOptions struct { RuleSet []RuleSet `json:"rule_set,omitempty"` Final string `json:"final,omitempty"` FindProcess bool `json:"find_process,omitempty"` + FindNeighbor bool `json:"find_neighbor,omitempty"` + DHCPLeaseFiles badoption.Listable[string] `json:"dhcp_lease_files,omitempty"` AutoDetectInterface bool `json:"auto_detect_interface,omitempty"` OverrideAndroidVPN bool `json:"override_android_vpn,omitempty"` DefaultInterface string `json:"default_interface,omitempty"` @@ -18,6 +20,7 @@ type RouteOptions struct { DefaultNetworkType badoption.Listable[InterfaceType] `json:"default_network_type,omitempty"` DefaultFallbackNetworkType badoption.Listable[InterfaceType] `json:"default_fallback_network_type,omitempty"` DefaultFallbackDelay badoption.Duration `json:"default_fallback_delay,omitempty"` + DefaultDomainMatchStrategy DomainMatchStrategy `json:"default_domain_match_strategy,omitempty"` } type GeoIPOptions struct { diff --git a/option/rule.go b/option/rule.go index 3e7fd8771b..cc65d1eddb 100644 --- a/option/rule.go +++ b/option/rule.go @@ -94,7 +94,7 @@ type RawDefaultRule struct { PackageName badoption.Listable[string] `json:"package_name,omitempty"` User badoption.Listable[string] `json:"user,omitempty"` UserID badoption.Listable[int32] `json:"user_id,omitempty"` - ClashMode string `json:"clash_mode,omitempty"` + ClashMode badoption.Listable[string] `json:"clash_mode,omitempty"` NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` NetworkIsExpensive bool `json:"network_is_expensive,omitempty"` NetworkIsConstrained bool `json:"network_is_constrained,omitempty"` @@ -103,9 +103,12 @@ type RawDefaultRule struct { InterfaceAddress *badjson.TypedMap[string, badoption.Listable[*badoption.Prefixable]] `json:"interface_address,omitempty"` NetworkInterfaceAddress *badjson.TypedMap[InterfaceType, badoption.Listable[*badoption.Prefixable]] `json:"network_interface_address,omitempty"` DefaultInterfaceAddress badoption.Listable[*badoption.Prefixable] `json:"default_interface_address,omitempty"` + SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"` + SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"` PreferredBy badoption.Listable[string] `json:"preferred_by,omitempty"` RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` + DomainMatchStrategy DomainMatchStrategy `json:"domain_match_strategy,omitempty"` Invert bool `json:"invert,omitempty"` // Deprecated: renamed to rule_set_ip_cidr_match_source @@ -136,9 +139,10 @@ func (r DefaultRule) IsValid() bool { } type RawLogicalRule struct { - Mode string `json:"mode"` - Rules []Rule `json:"rules,omitempty"` - Invert bool `json:"invert,omitempty"` + Mode string `json:"mode"` + Rules []Rule `json:"rules,omitempty"` + DomainMatchStrategy DomainMatchStrategy `json:"domain_match_strategy,omitempty"` + Invert bool `json:"invert,omitempty"` } type LogicalRule struct { diff --git a/option/rule_action.go b/option/rule_action.go index 4310825520..87a54806d1 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -47,6 +47,8 @@ func (r RuleAction) MarshalJSON() ([]byte, error) { v = nil case C.RuleActionTypeSniff: v = r.SniffOptions + case C.RuleActionTypeSniffOverrideDestination: + v = nil case C.RuleActionTypeResolve: v = r.ResolveOptions default: @@ -80,6 +82,8 @@ func (r *RuleAction) UnmarshalJSON(data []byte) error { v = nil case C.RuleActionTypeSniff: v = &r.SniffOptions + case C.RuleActionTypeSniffOverrideDestination: + v = nil case C.RuleActionTypeResolve: v = &r.ResolveOptions default: @@ -100,7 +104,7 @@ type _DNSRuleAction struct { Action string `json:"action,omitempty"` RouteOptions DNSRouteActionOptions `json:"-"` RouteOptionsOptions DNSRouteOptionsActionOptions `json:"-"` - RejectOptions RejectActionOptions `json:"-"` + DNSRejectOptions DNSRejectActionOptions `json:"-"` PredefinedOptions DNSRouteActionPredefined `json:"-"` } @@ -118,7 +122,7 @@ func (r DNSRuleAction) MarshalJSON() ([]byte, error) { case C.RuleActionTypeRouteOptions: v = r.RouteOptionsOptions case C.RuleActionTypeReject: - v = r.RejectOptions + v = r.DNSRejectOptions case C.RuleActionTypePredefined: v = r.PredefinedOptions default: @@ -140,7 +144,7 @@ func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) e case C.RuleActionTypeRouteOptions: v = &r.RouteOptionsOptions case C.RuleActionTypeReject: - v = &r.RejectOptions + v = &r.DNSRejectOptions case C.RuleActionTypePredefined: v = &r.PredefinedOptions default: @@ -192,6 +196,7 @@ type DNSRouteActionOptions struct { DisableCache bool `json:"disable_cache,omitempty"` RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` + LazyCacheTTL *uint32 `json:"lazy_cache_ttl,omitempty"` } type _DNSRouteOptionsActionOptions struct { @@ -199,6 +204,7 @@ type _DNSRouteOptionsActionOptions struct { DisableCache bool `json:"disable_cache,omitempty"` RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` + LazyCacheTTL *uint32 `json:"lazy_cache_ttl,omitempty"` } type DNSRouteOptionsActionOptions _DNSRouteOptionsActionOptions @@ -312,6 +318,7 @@ type RouteActionResolve struct { DisableCache bool `json:"disable_cache,omitempty"` RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` + MatchOnly bool `json:"match_only,omitempty"` } type DNSRouteActionPredefined struct { @@ -320,3 +327,37 @@ type DNSRouteActionPredefined struct { Ns badoption.Listable[DNSRecordOptions] `json:"ns,omitempty"` Extra badoption.Listable[DNSRecordOptions] `json:"extra,omitempty"` } + +type _DNSRejectActionOptions struct { + Rcode *DNSRejectRCode `json:"rcode,omitempty"` + Method string `json:"method,omitempty"` + NoDrop bool `json:"no_drop,omitempty"` +} + +type DNSRejectActionOptions _DNSRejectActionOptions + +func (r DNSRejectActionOptions) MarshalJSON() ([]byte, error) { + switch r.Method { + case C.RuleActionRejectMethodDefault: + r.Method = "" + } + return json.Marshal((_DNSRejectActionOptions)(r)) +} + +func (r *DNSRejectActionOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_DNSRejectActionOptions)(r)) + if err != nil { + return err + } + switch r.Method { + case "", C.RuleActionRejectMethodDefault: + r.Method = C.RuleActionRejectMethodDefault + case C.RuleActionRejectMethodDrop: + default: + return E.New("unknown reject method: " + r.Method) + } + if r.Method == C.RuleActionRejectMethodDrop && r.NoDrop { + return E.New("no_drop is not available in current context") + } + return nil +} diff --git a/option/rule_dns.go b/option/rule_dns.go index dbc1657898..31eb8e72f1 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -97,7 +97,7 @@ type RawDefaultDNSRule struct { User badoption.Listable[string] `json:"user,omitempty"` UserID badoption.Listable[int32] `json:"user_id,omitempty"` Outbound badoption.Listable[string] `json:"outbound,omitempty"` - ClashMode string `json:"clash_mode,omitempty"` + ClashMode badoption.Listable[string] `json:"clash_mode,omitempty"` NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` NetworkIsExpensive bool `json:"network_is_expensive,omitempty"` NetworkIsConstrained bool `json:"network_is_constrained,omitempty"` @@ -106,6 +106,8 @@ type RawDefaultDNSRule struct { InterfaceAddress *badjson.TypedMap[string, badoption.Listable[*badoption.Prefixable]] `json:"interface_address,omitempty"` NetworkInterfaceAddress *badjson.TypedMap[InterfaceType, badoption.Listable[*badoption.Prefixable]] `json:"network_interface_address,omitempty"` DefaultInterfaceAddress badoption.Listable[*badoption.Prefixable] `json:"default_interface_address,omitempty"` + SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"` + SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"` RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` RuleSetIPCIDRAcceptEmpty bool `json:"rule_set_ip_cidr_accept_empty,omitempty"` diff --git a/option/rule_set.go b/option/rule_set.go index b06342280b..b45b493c04 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -21,8 +21,8 @@ type _RuleSet struct { Type string `json:"type,omitempty"` Tag string `json:"tag"` Format string `json:"format,omitempty"` + Path string `json:"path,omitempty"` InlineOptions PlainRuleSet `json:"-"` - LocalOptions LocalRuleSet `json:"-"` RemoteOptions RemoteRuleSet `json:"-"` } @@ -33,7 +33,7 @@ func (r RuleSet) MarshalJSON() ([]byte, error) { var defaultFormat string switch r.Type { case C.RuleSetTypeLocal: - defaultFormat = ruleSetDefaultFormat(r.LocalOptions.Path) + defaultFormat = ruleSetDefaultFormat(r.Path) case C.RuleSetTypeRemote: defaultFormat = ruleSetDefaultFormat(r.RemoteOptions.URL) } @@ -47,7 +47,7 @@ func (r RuleSet) MarshalJSON() ([]byte, error) { r.Type = "" v = r.InlineOptions case C.RuleSetTypeLocal: - v = r.LocalOptions + v = nil case C.RuleSetTypeRemote: v = r.RemoteOptions default: @@ -70,7 +70,7 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { r.Type = C.RuleSetTypeInline v = &r.InlineOptions case C.RuleSetTypeLocal: - v = &r.LocalOptions + v = nil case C.RuleSetTypeRemote: v = &r.RemoteOptions default: @@ -84,7 +84,7 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { if r.Format == "" { switch r.Type { case C.RuleSetTypeLocal: - r.Format = ruleSetDefaultFormat(r.LocalOptions.Path) + r.Format = ruleSetDefaultFormat(r.Path) case C.RuleSetTypeRemote: r.Format = ruleSetDefaultFormat(r.RemoteOptions.URL) } @@ -97,7 +97,8 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { return E.New("unknown rule-set format: " + r.Format) } } else { - r.Format = "" + r.Format = C.RuleSetFormatSource + r.Path = "" } return nil } @@ -116,10 +117,6 @@ func ruleSetDefaultFormat(path string) string { } } -type LocalRuleSet struct { - Path string `json:"path,omitempty"` -} - type RemoteRuleSet struct { URL string `json:"url"` DownloadDetour string `json:"download_detour,omitempty"` @@ -203,6 +200,7 @@ type DefaultHeadlessRule struct { NetworkIsConstrained bool `json:"network_is_constrained,omitempty"` WIFISSID badoption.Listable[string] `json:"wifi_ssid,omitempty"` WIFIBSSID badoption.Listable[string] `json:"wifi_bssid,omitempty"` + DomainMatchStrategy DomainMatchStrategy `json:"domain_match_strategy,omitempty"` NetworkInterfaceAddress *badjson.TypedMap[InterfaceType, badoption.Listable[*badoption.Prefixable]] `json:"network_interface_address,omitempty"` DefaultInterfaceAddress badoption.Listable[*badoption.Prefixable] `json:"default_interface_address,omitempty"` @@ -223,9 +221,10 @@ func (r DefaultHeadlessRule) IsValid() bool { } type LogicalHeadlessRule struct { - Mode string `json:"mode"` - Rules []HeadlessRule `json:"rules,omitempty"` - Invert bool `json:"invert,omitempty"` + Mode string `json:"mode"` + Rules []HeadlessRule `json:"rules,omitempty"` + DomainMatchStrategy DomainMatchStrategy `json:"domain_match_strategy,omitempty"` + Invert bool `json:"invert,omitempty"` } func (r LogicalHeadlessRule) IsValid() bool { diff --git a/option/tailscale.go b/option/tailscale.go index dac8e866a5..37525e36bf 100644 --- a/option/tailscale.go +++ b/option/tailscale.go @@ -27,6 +27,7 @@ type TailscaleEndpointOptions struct { RelayServerStaticEndpoints []netip.AddrPort `json:"relay_server_static_endpoints,omitempty"` SystemInterface bool `json:"system_interface,omitempty"` SystemInterfaceName string `json:"system_interface_name,omitempty"` + SystemInterfaceGSO *bool `json:"system_interface_gso,omitempty"` SystemInterfaceMTU uint32 `json:"system_interface_mtu,omitempty"` UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` } diff --git a/option/tls.go b/option/tls.go index 60343a15f1..80be61d3ad 100644 --- a/option/tls.go +++ b/option/tls.go @@ -12,6 +12,7 @@ import ( type InboundTLSOptions struct { Enabled bool `json:"enabled,omitempty"` ServerName string `json:"server_name,omitempty"` + ServerNames badoption.Listable[string] `json:"server_names,omitempty"` Insecure bool `json:"insecure,omitempty"` ALPN badoption.Listable[string] `json:"alpn,omitempty"` MinVersion string `json:"min_version,omitempty"` @@ -31,6 +32,8 @@ type InboundTLSOptions struct { ACME *InboundACMEOptions `json:"acme,omitempty"` ECH *InboundECHOptions `json:"ech,omitempty"` Reality *InboundRealityOptions `json:"reality,omitempty"` + + RejectUnknownSNI bool `json:"reject_unknown_sni,omitempty"` } type ClientAuthType tls.ClientAuthType @@ -111,6 +114,7 @@ type OutboundTLSOptions struct { ClientCertificatePath string `json:"client_certificate_path,omitempty"` ClientKey badoption.Listable[string] `json:"client_key,omitempty"` ClientKeyPath string `json:"client_key_path,omitempty"` + CertificatePinSHA256 string `json:"certificate_pin_sha256,omitempty"` Fragment bool `json:"fragment,omitempty"` FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"` RecordFragment bool `json:"record_fragment,omitempty"` diff --git a/option/tun.go b/option/tun.go index 72b6e456ba..8397c4ed5a 100644 --- a/option/tun.go +++ b/option/tun.go @@ -18,6 +18,7 @@ type TunInboundOptions struct { IPRoute2TableIndex int `json:"iproute2_table_index,omitempty"` IPRoute2RuleIndex int `json:"iproute2_rule_index,omitempty"` AutoRedirect bool `json:"auto_redirect,omitempty"` + AutoRedirectDisableMarkMode bool `json:"auto_redirect_disable_mark_mode,omitempty"` AutoRedirectInputMark FwMark `json:"auto_redirect_input_mark,omitempty"` AutoRedirectOutputMark FwMark `json:"auto_redirect_output_mark,omitempty"` AutoRedirectResetMark FwMark `json:"auto_redirect_reset_mark,omitempty"` @@ -39,6 +40,8 @@ type TunInboundOptions struct { IncludeAndroidUser badoption.Listable[int] `json:"include_android_user,omitempty"` IncludePackage badoption.Listable[string] `json:"include_package,omitempty"` ExcludePackage badoption.Listable[string] `json:"exclude_package,omitempty"` + IncludeMACAddress badoption.Listable[string] `json:"include_mac_address,omitempty"` + ExcludeMACAddress badoption.Listable[string] `json:"exclude_mac_address,omitempty"` UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` Stack string `json:"stack,omitempty"` Platform *TunPlatformOptions `json:"platform,omitempty"` diff --git a/option/types.go b/option/types.go index fe7d4b3d07..66c607321e 100644 --- a/option/types.go +++ b/option/types.go @@ -194,3 +194,65 @@ func (t *InterfaceType) UnmarshalJSON(content []byte) error { *t = InterfaceType(interfaceType) return nil } + +type DomainMatchStrategy C.DomainMatchStrategy + +func (s DomainMatchStrategy) String() string { + switch C.DomainMatchStrategy(s) { + case C.DomainMatchStrategyAsIS: + return "" + case C.DomainMatchStrategyPreferFQDN: + return "prefer_fqdn" + case C.DomainMatchStrategyPreferSniffHost: + return "prefer_sniffhost" + case C.DomainMatchStrategyFQDNOnly: + return "fqdn_only" + case C.DomainMatchStrategySniffHostOnly: + return "sniffhost_only" + default: + panic(E.New("unknown domain match strategy: ", s)) + } +} + +func (s DomainMatchStrategy) MarshalJSON() ([]byte, error) { + var value string + switch C.DomainMatchStrategy(s) { + case C.DomainMatchStrategyAsIS: + value = "" + // value = "as_is" + case C.DomainMatchStrategyPreferFQDN: + value = "prefer_fqdn" + case C.DomainMatchStrategyPreferSniffHost: + value = "prefer_sniffhost" + case C.DomainMatchStrategyFQDNOnly: + value = "fqdn_only" + case C.DomainMatchStrategySniffHostOnly: + value = "sniffhost_only" + default: + return nil, E.New("unknown domain match strategy: ", s) + } + return json.Marshal(value) +} + +func (s *DomainMatchStrategy) UnmarshalJSON(bytes []byte) error { + var value string + err := json.Unmarshal(bytes, &value) + if err != nil { + return err + } + switch value { + case "", "as_is": + *s = DomainMatchStrategy(C.DomainMatchStrategyAsIS) + case "prefer_fqdn": + *s = DomainMatchStrategy(C.DomainMatchStrategyPreferFQDN) + case "prefer_sniffhost": + *s = DomainMatchStrategy(C.DomainMatchStrategyPreferSniffHost) + case "fqdn_only": + *s = DomainMatchStrategy(C.DomainMatchStrategyFQDNOnly) + case "sniffhost_only": + *s = DomainMatchStrategy(C.DomainMatchStrategySniffHostOnly) + default: + return E.New("unknown domain match strategy: ", value) + } + return nil +} diff --git a/option/wireguard.go b/option/wireguard.go index c86abd112a..c1490a84bc 100644 --- a/option/wireguard.go +++ b/option/wireguard.go @@ -8,6 +8,7 @@ import ( type WireGuardEndpointOptions struct { System bool `json:"system,omitempty"` + GSO *bool `json:"gso,omitempty"` Name string `json:"name,omitempty"` MTU uint32 `json:"mtu,omitempty"` Address badoption.Listable[netip.Prefix] `json:"address"` diff --git a/protocol/anytls/inbound.go b/protocol/anytls/inbound.go index 52d773537a..0341fe1f55 100644 --- a/protocol/anytls/inbound.go +++ b/protocol/anytls/inbound.go @@ -3,6 +3,7 @@ package anytls import ( "context" "net" + "os" "strings" "github.com/sagernet/sing-box/adapter" @@ -30,11 +31,13 @@ func RegisterInbound(registry *inbound.Registry) { type Inbound struct { inbound.Adapter - tlsConfig tls.ServerConfig - router adapter.ConnectionRouterEx - logger logger.ContextLogger - listener *listener.Listener - service *anytls.Service + tlsConfig tls.ServerConfig + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + service *anytls.Service + fallbackAddr M.Socksaddr + fallbackAddrTLSNextProto map[string]M.Socksaddr } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSInboundOptions) (adapter.Inbound, error) { @@ -57,13 +60,39 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo paddingScheme = []byte(strings.Join(options.PaddingScheme, "\n")) } + var fallbackHandler N.TCPConnectionHandlerEx + if options.Fallback != nil && options.Fallback.Server != "" || len(options.FallbackForALPN) > 0 { + if options.Fallback != nil && options.Fallback.Server != "" { + inbound.fallbackAddr = options.Fallback.Build() + if !inbound.fallbackAddr.IsValid() { + return nil, E.New("invalid fallback address: ", inbound.fallbackAddr) + } + } + if len(options.FallbackForALPN) > 0 { + if inbound.tlsConfig == nil { + return nil, E.New("fallback for ALPN is not supported without TLS") + } + fallbackAddrNextProto := make(map[string]M.Socksaddr) + for nextProto, destination := range options.FallbackForALPN { + fallbackAddr := destination.Build() + if !fallbackAddr.IsValid() { + return nil, E.New("invalid fallback address for ALPN ", nextProto, ": ", fallbackAddr) + } + fallbackAddrNextProto[nextProto] = fallbackAddr + } + inbound.fallbackAddrTLSNextProto = fallbackAddrNextProto + } + fallbackHandler = adapter.NewUpstreamContextHandlerEx(inbound.fallbackConnection, nil) + } + service, err := anytls.NewService(anytls.ServiceConfig{ Users: common.Map(options.Users, func(it option.AnyTLSUser) anytls.User { return (anytls.User)(it) }), - PaddingScheme: paddingScheme, - Handler: (*inboundHandler)(inbound), - Logger: logger, + PaddingScheme: paddingScheme, + Handler: (*inboundHandler)(inbound), + FallbackHandler: fallbackHandler, + Logger: logger, }) if err != nil { return nil, err @@ -113,6 +142,35 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a } } +func (h *Inbound) fallbackConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + var fallbackAddr M.Socksaddr + if len(h.fallbackAddrTLSNextProto) > 0 { + if tlsConn, loaded := common.Cast[tls.Conn](conn); loaded { + connectionState := tlsConn.ConnectionState() + if connectionState.NegotiatedProtocol != "" { + if fallbackAddr, loaded = h.fallbackAddrTLSNextProto[connectionState.NegotiatedProtocol]; !loaded { + h.logger.DebugContext(ctx, "process connection from ", metadata.Source, ": fallback disabled for ALPN: ", connectionState.NegotiatedProtocol) + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + } + } + } + if !fallbackAddr.IsValid() { + if !h.fallbackAddr.IsValid() { + h.logger.DebugContext(ctx, "process connection from ", metadata.Source, ": fallback disabled by default") + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + fallbackAddr = h.fallbackAddr + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + metadata.Destination = fallbackAddr + h.logger.InfoContext(ctx, "fallback connection to ", fallbackAddr) + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + type inboundHandler Inbound func (h *inboundHandler) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { diff --git a/protocol/anytls/outbound.go b/protocol/anytls/outbound.go index 2f24c2ef8f..fa5fe2419c 100644 --- a/protocol/anytls/outbound.go +++ b/protocol/anytls/outbound.go @@ -13,7 +13,6 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" - 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/common/uot" @@ -44,13 +43,6 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if options.TLS == nil || !options.TLS.Enabled { return nil, C.ErrTLSRequired } - // TCP Fast Open is incompatible with anytls because TFO creates a lazy connection - // that only establishes on first write. The lazy connection returns an empty address - // before establishment, but anytls SOCKS wrapper tries to access the remote address - // during handshake, causing a null pointer dereference crash. - if options.DialerOptions.TCPFastOpen { - return nil, E.New("tcp_fast_open is not supported with anytls outbound") - } tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS)) if err != nil { diff --git a/protocol/direct/outbound.go b/protocol/direct/outbound.go index 9d24f31aff..07e70ef0c1 100644 --- a/protocol/direct/outbound.go +++ b/protocol/direct/outbound.go @@ -13,13 +13,15 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/ping" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + + "github.com/pires/go-proxyproto" ) func RegisterOutbound(registry *outbound.Registry) { @@ -35,13 +37,15 @@ var ( type Outbound struct { outbound.Adapter - ctx context.Context - logger logger.ContextLogger - dialer dialer.ParallelInterfaceDialer - domainStrategy C.DomainStrategy - fallbackDelay time.Duration - isEmpty bool + ctx context.Context + logger logger.ContextLogger + dialer dialer.ParallelInterfaceDialer + domainStrategy C.DomainStrategy + directDomainStrategy C.DomainStrategy + fallbackDelay time.Duration + isEmpty bool // loopBack *loopBackDetector + proxyProto uint8 } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.DirectOutboundOptions) (adapter.Outbound, error) { @@ -63,21 +67,23 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL ctx: ctx, logger: logger, //nolint:staticcheck - domainStrategy: C.DomainStrategy(options.DomainStrategy), - fallbackDelay: time.Duration(options.FallbackDelay), - dialer: outboundDialer.(dialer.ParallelInterfaceDialer), - isEmpty: reflect.DeepEqual(options.DialerOptions, option.DialerOptions{UDPFragmentDefault: true}), + domainStrategy: C.DomainStrategy(options.DomainStrategy), + directDomainStrategy: C.DomainStrategy(options.DirectDomainStrategy), + fallbackDelay: time.Duration(options.FallbackDelay), + dialer: outboundDialer.(dialer.ParallelInterfaceDialer), + isEmpty: reflect.DeepEqual(options.DialerOptions, option.DialerOptions{UDPFragmentDefault: true}), // loopBack: newLoopBackDetector(router), + proxyProto: options.ProxyProtocol, } - //nolint:staticcheck - if options.ProxyProtocol != 0 { - return nil, E.New("Proxy Protocol is deprecated and removed in sing-box 1.6.0") + if options.ProxyProtocol > 2 { + return nil, E.New("invalid proxy protocol option: ", options.ProxyProtocol) } return outbound, nil } func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { ctx, metadata := adapter.ExtendContext(ctx) + originDestination := metadata.Destination metadata.Outbound = h.Tag() metadata.Destination = destination network = N.NetworkName(network) @@ -92,7 +98,26 @@ func (h *Outbound) DialContext(ctx context.Context, network string, destination return nil, err } return h.loopBack.NewConn(conn), nil*/ - return h.dialer.DialContext(ctx, network, destination) + conn, err := h.dialer.DialContext(ctx, network, destination) + if err != nil { + return nil, err + } + if h.proxyProto > 0 { + source := metadata.Source + if !source.IsValid() { + source = M.SocksaddrFromNet(conn.LocalAddr()) + } + if originDestination.Addr.Is6() { + source = M.SocksaddrFrom(netip.AddrFrom16(source.Addr.As16()), source.Port) + } + header := proxyproto.HeaderProxyFromAddrs(h.proxyProto, source.TCPAddr(), originDestination.TCPAddr()) + _, err = header.WriteTo(conn) + if err != nil { + conn.Close() + return nil, E.Cause(err, "write proxy protocol header") + } + } + return conn, nil } func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { @@ -120,6 +145,7 @@ func (h *Outbound) NewDirectRouteConnection(metadata adapter.InboundContext, rou func (h *Outbound) DialParallel(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr) (net.Conn, error) { ctx, metadata := adapter.ExtendContext(ctx) + originDestination := metadata.Destination metadata.Outbound = h.Tag() metadata.Destination = destination network = N.NetworkName(network) @@ -129,11 +155,48 @@ func (h *Outbound) DialParallel(ctx context.Context, network string, destination case N.NetworkUDP: h.logger.InfoContext(ctx, "outbound packet connection to ", destination) } - return dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, len(destinationAddresses) > 0 && destinationAddresses[0].Is6(), nil, nil, nil, h.fallbackDelay) + var preferIPv6 bool + switch h.directDomainStrategy { + case C.DomainStrategyAsIS: + preferIPv6 = len(destinationAddresses) > 0 && destinationAddresses[0].Is6() + case C.DomainStrategyIPv4Only: + destinationAddresses = common.Filter(destinationAddresses, netip.Addr.Is4) + if len(destinationAddresses) == 0 { + return nil, E.New("no IPv4 address available for ", destination) + } + case C.DomainStrategyIPv6Only: + destinationAddresses = common.Filter(destinationAddresses, netip.Addr.Is6) + if len(destinationAddresses) == 0 { + return nil, E.New("no IPv6 address available for ", destination) + } + case C.DomainStrategyPreferIPv6: + preferIPv6 = len(destinationAddresses) > 0 + } + conn, err := dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, preferIPv6, nil, nil, nil, h.fallbackDelay) + if err != nil { + return nil, err + } + if h.proxyProto > 0 { + source := metadata.Source + if !source.IsValid() { + source = M.SocksaddrFromNet(conn.LocalAddr()) + } + if originDestination.Addr.Is6() { + source = M.SocksaddrFrom(netip.AddrFrom16(source.Addr.As16()), source.Port) + } + header := proxyproto.HeaderProxyFromAddrs(h.proxyProto, source.TCPAddr(), originDestination.TCPAddr()) + _, err = header.WriteTo(conn) + if err != nil { + conn.Close() + return nil, E.Cause(err, "write proxy protocol header") + } + } + return conn, nil } func (h *Outbound) DialParallelNetwork(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr, networkStrategy *C.NetworkStrategy, networkType []C.InterfaceType, fallbackNetworkType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error) { ctx, metadata := adapter.ExtendContext(ctx) + originDestination := metadata.Destination metadata.Outbound = h.Tag() metadata.Destination = destination network = N.NetworkName(network) @@ -143,7 +206,43 @@ func (h *Outbound) DialParallelNetwork(ctx context.Context, network string, dest case N.NetworkUDP: h.logger.InfoContext(ctx, "outbound packet connection to ", destination) } - return dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, len(destinationAddresses) > 0 && destinationAddresses[0].Is6(), networkStrategy, networkType, fallbackNetworkType, fallbackDelay) + var preferIPv6 bool + switch h.directDomainStrategy { + case C.DomainStrategyAsIS: + preferIPv6 = len(destinationAddresses) > 0 && destinationAddresses[0].Is6() + case C.DomainStrategyIPv4Only: + destinationAddresses = common.Filter(destinationAddresses, netip.Addr.Is4) + if len(destinationAddresses) == 0 { + return nil, E.New("no IPv4 address available for ", destination) + } + case C.DomainStrategyIPv6Only: + destinationAddresses = common.Filter(destinationAddresses, netip.Addr.Is6) + if len(destinationAddresses) == 0 { + return nil, E.New("no IPv6 address available for ", destination) + } + case C.DomainStrategyPreferIPv6: + preferIPv6 = len(destinationAddresses) > 0 + } + conn, err := dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, preferIPv6, networkStrategy, networkType, fallbackNetworkType, fallbackDelay) + if err != nil { + return nil, err + } + if h.proxyProto > 0 { + source := metadata.Source + if !source.IsValid() { + source = M.SocksaddrFromNet(conn.LocalAddr()) + } + if originDestination.Addr.Is6() { + source = M.SocksaddrFrom(netip.AddrFrom16(source.Addr.As16()), source.Port) + } + header := proxyproto.HeaderProxyFromAddrs(h.proxyProto, source.TCPAddr(), originDestination.TCPAddr()) + _, err = header.WriteTo(conn) + if err != nil { + conn.Close() + return nil, E.Cause(err, "write proxy protocol header") + } + } + return conn, nil } func (h *Outbound) ListenSerialNetworkPacket(ctx context.Context, destination M.Socksaddr, destinationAddresses []netip.Addr, networkStrategy *C.NetworkStrategy, networkType []C.InterfaceType, fallbackNetworkType []C.InterfaceType, fallbackDelay time.Duration) (net.PacketConn, netip.Addr, error) { diff --git a/protocol/group/loadbalance.go b/protocol/group/loadbalance.go new file mode 100644 index 0000000000..deda184a37 --- /dev/null +++ b/protocol/group/loadbalance.go @@ -0,0 +1,659 @@ +package group + +import ( + "context" + "fmt" + "net" + "net/netip" + "regexp" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/interrupt" + "github.com/sagernet/sing-box/common/urltest" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + tun "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/batch" + 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/common/x/list" + "github.com/sagernet/sing/contrab/freelru" + "github.com/sagernet/sing/contrab/maphash" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/pause" + + "golang.org/x/net/publicsuffix" +) + +func RegisterLoadBalance(registry *outbound.Registry) { + outbound.Register[option.LoadBalanceOutboundOptions](registry, C.TypeLoadBalance, NewLoadBalance) +} + +var _ adapter.OutboundGroup = (*LoadBalance)(nil) + +const ( + StrategyRoundRobin = "round-robin" + StrategyConsistentHashing = "consistent-hashing" + StrategyStickySessions = "sticky-sessions" +) + +type LoadBalance struct { + outbound.Adapter + ctx context.Context + router adapter.Router + outbound adapter.OutboundManager + connection adapter.ConnectionManager + logger log.ContextLogger + tags []string + link string + interval time.Duration + idleTimeout time.Duration + ttl time.Duration + group *LoadBalanceGroup + interruptExternalConnections bool + strategy string + + provider adapter.ProviderManager + providers map[string]adapter.Provider + outboundsCache map[string][]adapter.Outbound + cancel context.CancelFunc + + providerTags []string + exclude *regexp.Regexp + include *regexp.Regexp + useAllProviders bool +} + +func NewLoadBalance(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.LoadBalanceOutboundOptions) (adapter.Outbound, error) { + strategy := options.Strategy + if strategy == "" { + strategy = StrategyRoundRobin + } + switch strategy { + case StrategyRoundRobin, StrategyConsistentHashing, StrategyStickySessions: + default: + return nil, E.New("load-balance strategy not found: ", strategy) + } + outbound := &LoadBalance{ + Adapter: outbound.NewAdapter(C.TypeLoadBalance, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), + ctx: ctx, + router: router, + outbound: service.FromContext[adapter.OutboundManager](ctx), + connection: service.FromContext[adapter.ConnectionManager](ctx), + logger: logger, + tags: options.Outbounds, + link: options.URL, + interval: time.Duration(options.Interval), + ttl: time.Duration(options.TTL), + idleTimeout: time.Duration(options.IdleTimeout), + interruptExternalConnections: options.InterruptExistConnections, + strategy: strategy, + + provider: service.FromContext[adapter.ProviderManager](ctx), + providers: make(map[string]adapter.Provider), + outboundsCache: make(map[string][]adapter.Outbound), + + providerTags: options.Providers, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + useAllProviders: options.UseAllProviders, + } + return outbound, nil +} + +func (s *LoadBalance) Start() error { + if s.useAllProviders { + var providerTags []string + for _, provider := range s.provider.Providers() { + providerTags = append(providerTags, provider.Tag()) + s.providers[provider.Tag()] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + s.providerTags = providerTags + } else { + for i, tag := range s.providerTags { + provider, loaded := s.provider.Get(tag) + if !loaded { + return E.New("outbound provider ", i, " not found: ", tag) + } + s.providers[tag] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + } + if len(s.tags)+len(s.providerTags) == 0 { + return E.New("missing outbound and provider tags") + } + + outbounds := make([]adapter.Outbound, 0, len(s.tags)) + for i, tag := range s.tags { + detour, loaded := s.outbound.Outbound(tag) + if !loaded { + return E.New("outbound ", i, " not found: ", tag) + } + outbounds = append(outbounds, detour) + } + if len(s.tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + s.tags = append(s.tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + group, err := NewLoadBalanceGroup(s.ctx, s.outbound, s.logger, outbounds, s.link, s.interval, s.idleTimeout, s.ttl, s.interruptExternalConnections, s.strategy) + if err != nil { + return err + } + s.group = group + return nil +} + +func (s *LoadBalance) PostStart() error { + s.group.PostStart() + return nil +} + +func (s *LoadBalance) Close() error { + return common.Close( + common.PtrOrNil(s.group), + ) +} + +func (s *LoadBalance) Now() string { + return "" +} + +func (s *LoadBalance) All() []string { + var all []string + for _, outbound := range s.group.outbounds { + all = append(all, outbound.Tag()) + } + return all +} + +func (s *LoadBalance) URLTest(ctx context.Context) (map[string]uint16, error) { + return s.group.URLTest(ctx) +} + +func (s *LoadBalance) CheckOutbounds() { + s.group.CheckOutbounds(true) +} + +func (s *LoadBalance) isGroupActive() bool { + if !s.group.started { + return false + } + return time.Since(s.group.lastActive.Load()) <= s.group.idleTimeout +} + +func (s *LoadBalance) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + s.group.Touch() + metadata := adapter.ContextFrom(ctx) + outbound := s.group.Unwrap(metadata, true) + if outbound == nil || !common.Contains(outbound.Network(), network) { + return nil, E.New("missing supported outbound") + } + if metadata != nil { + metadata.AppendRealOutbound(outbound.Tag()) + } + conn, err := outbound.DialContext(ctx, network, destination) + if err == nil { + return s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil + } + s.logger.ErrorContext(ctx, err) + s.group.history.DeleteURLTestHistory(outbound.Tag()) + return nil, err +} + +func (s *LoadBalance) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + s.group.Touch() + metadata := adapter.ContextFrom(ctx) + outbound := s.group.Unwrap(metadata, true) + if outbound == nil || !common.Contains(outbound.Network(), N.NetworkUDP) { + return nil, E.New("missing supported outbound") + } + if metadata != nil { + metadata.AppendRealOutbound(outbound.Tag()) + } + conn, err := outbound.ListenPacket(ctx, destination) + if err == nil { + return s.group.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil + } + s.logger.ErrorContext(ctx, err) + s.group.history.DeleteURLTestHistory(outbound.Tag()) + return nil, err +} + +func (s *LoadBalance) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + ctx = interrupt.ContextWithIsExternalConnection(ctx) + s.connection.NewConnection(ctx, s, conn, metadata, onClose) +} + +func (s *LoadBalance) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + ctx = interrupt.ContextWithIsExternalConnection(ctx) + s.connection.NewPacketConnection(ctx, s, conn, metadata, onClose) +} + +func (s *LoadBalance) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { + s.group.Touch() + selected := s.group.Unwrap(&metadata, true) + if selected == nil { + return nil, E.New("missing supported outbound") + } + if !common.Contains(selected.Network(), metadata.Network) { + return nil, E.New(metadata.Network, " is not supported by outbound: ", selected.Tag()) + } + return selected.(adapter.DirectRouteOutbound).NewDirectRouteConnection(metadata, routeContext, timeout) +} + +func (s *LoadBalance) onProviderUpdated(tag string) error { + _, loaded := s.providers[tag] + if !loaded { + return E.New("outbound provider not found: ", tag) + } + var ( + tags = s.Dependencies() + outbounds []adapter.Outbound + ) + for _, tag := range tags { + detour, _ := s.outbound.Outbound(tag) + outbounds = append(outbounds, detour) + } + for _, providerTag := range s.providerTags { + if providerTag != tag && s.outboundsCache[providerTag] != nil { + for _, detour := range s.outboundsCache[providerTag] { + tags = append(tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + continue + } + provider := s.providers[providerTag] + var cache []adapter.Outbound + for _, detour := range provider.Outbounds() { + tag := detour.Tag() + if s.exclude != nil && s.exclude.MatchString(tag) { + continue + } + if s.include != nil && !s.include.MatchString(tag) { + continue + } + tags = append(tags, tag) + cache = append(cache, detour) + } + outbounds = append(outbounds, cache...) + s.outboundsCache[providerTag] = cache + } + if len(tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + tags = append(tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + s.tags, s.group.outbounds = tags, outbounds + if s.isGroupActive() { + s.group.access.Lock() + if s.group.ticker != nil { + s.group.ticker.Reset(s.group.interval) + } + s.group.access.Unlock() + ctx, cancel := context.WithCancel(s.ctx) + if s.cancel != nil { + s.cancel() + } + s.cancel = cancel + s.URLTest(ctx) + } + return nil +} + +type strategyFn = func(metadata *adapter.InboundContext, touch bool) adapter.Outbound + +type LoadBalanceGroup struct { + ctx context.Context + router adapter.Router + outbound adapter.OutboundManager + pause pause.Manager + pauseCallback *list.Element[pause.Callback] + logger log.Logger + outbounds []adapter.Outbound + link string + interval time.Duration + idleTimeout time.Duration + ttl time.Duration + history adapter.URLTestHistoryStorage + checking atomic.Bool + interruptGroup *interrupt.Group + interruptExternalConnections bool + access sync.Mutex + ticker *time.Ticker + close chan struct{} + started bool + lastActive common.TypedValue[time.Time] + strategyFn strategyFn +} + +func NewLoadBalanceGroup(ctx context.Context, outboundManager adapter.OutboundManager, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, idleTimeout time.Duration, ttl time.Duration, interruptExternalConnections bool, strategy string) (*LoadBalanceGroup, error) { + if interval == 0 { + interval = C.DefaultURLTestInterval + } + if idleTimeout == 0 { + idleTimeout = C.DefaultURLTestIdleTimeout + } + if interval > idleTimeout { + return nil, E.New("interval must be less or equal than idle_timeout") + } + if ttl == 0 { + ttl = time.Minute * 10 + } + var history adapter.URLTestHistoryStorage + if historyFromCtx := service.PtrFromContext[urltest.HistoryStorage](ctx); historyFromCtx != nil { + history = historyFromCtx + } else if clashServer := service.FromContext[adapter.ClashServer](ctx); clashServer != nil { + history = clashServer.HistoryStorage() + } else { + history = urltest.NewHistoryStorage() + } + if link == "" { + link = "https://www.gstatic.com/generate_204" + } + loadBalanceGroup := &LoadBalanceGroup{ + ctx: ctx, + outbound: outboundManager, + logger: logger, + outbounds: outbounds, + link: link, + interval: interval, + idleTimeout: idleTimeout, + ttl: ttl, + history: history, + close: make(chan struct{}), + pause: service.FromContext[pause.Manager](ctx), + interruptGroup: interrupt.NewGroup(), + interruptExternalConnections: interruptExternalConnections, + } + switch strategy { + case StrategyRoundRobin: + loadBalanceGroup.strategyFn = strategyRoundRobin(loadBalanceGroup, link) + case StrategyConsistentHashing: + loadBalanceGroup.strategyFn = strategyConsistentHashing(loadBalanceGroup, link) + case StrategyStickySessions: + loadBalanceGroup.strategyFn = strategyStickySessions(loadBalanceGroup, link) + } + return loadBalanceGroup, nil +} + +func (g *LoadBalanceGroup) PostStart() { + g.access.Lock() + defer g.access.Unlock() + g.started = true + g.lastActive.Store(time.Now()) + go g.CheckOutbounds(false) +} + +func (g *LoadBalanceGroup) Touch() { + if !g.started { + return + } + g.access.Lock() + defer g.access.Unlock() + if g.ticker != nil { + g.lastActive.Store(time.Now()) + return + } + g.ticker = time.NewTicker(g.interval) + go g.loopCheck() + g.pauseCallback = pause.RegisterTicker(g.pause, g.ticker, g.interval, nil) +} + +func (g *LoadBalanceGroup) Close() error { + g.access.Lock() + defer g.access.Unlock() + if g.ticker == nil { + return nil + } + g.ticker.Stop() + g.pause.UnregisterCallback(g.pauseCallback) + close(g.close) + return nil +} + +func (g *LoadBalanceGroup) loopCheck() { + if time.Since(g.lastActive.Load()) > g.interval { + g.lastActive.Store(time.Now()) + g.CheckOutbounds(false) + } + for { + select { + case <-g.close: + return + case <-g.ticker.C: + } + if time.Since(g.lastActive.Load()) > g.idleTimeout { + g.access.Lock() + g.ticker.Stop() + g.ticker = nil + g.pause.UnregisterCallback(g.pauseCallback) + g.pauseCallback = nil + g.access.Unlock() + return + } + g.CheckOutbounds(false) + } +} + +func (g *LoadBalanceGroup) CheckOutbounds(force bool) { + _, _ = g.urlTest(g.ctx, force) +} + +func (g *LoadBalanceGroup) URLTest(ctx context.Context) (map[string]uint16, error) { + return g.urlTest(ctx, false) +} + +func (g *LoadBalanceGroup) urlTest(ctx context.Context, force bool) (map[string]uint16, error) { + result := make(map[string]uint16) + if g.checking.Swap(true) { + return result, nil + } + defer g.checking.Store(false) + b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10)) + checked := make(map[string]bool) + var resultAccess sync.Mutex + for _, detour := range g.outbounds { + tag := detour.Tag() + realTag := RealTag(detour) + if checked[realTag] { + continue + } + history := g.history.LoadURLTestHistory(realTag) + if !force && history != nil && time.Since(history.Time) < g.interval { + continue + } + checked[realTag] = true + p, loaded := g.outbound.Outbound(realTag) + if !loaded { + continue + } + b.Go(realTag, func() (any, error) { + testCtx, cancel := context.WithTimeout(g.ctx, C.TCPTimeout) + defer cancel() + t, err := urltest.URLTest(testCtx, g.link, p) + if err != nil { + g.logger.Debug("outbound ", tag, " unavailable: ", err) + g.history.DeleteURLTestHistory(realTag) + } else { + g.logger.Debug("outbound ", tag, " available: ", t, "ms") + g.history.StoreURLTestHistory(realTag, &adapter.URLTestHistory{ + Time: time.Now(), + Delay: t, + }) + resultAccess.Lock() + result[tag] = t + resultAccess.Unlock() + } + return nil, nil + }) + } + b.Wait() + return result, nil +} + +func (g *LoadBalanceGroup) Unwrap(metadata *adapter.InboundContext, touch bool) adapter.Outbound { + return g.strategyFn(metadata, touch) +} + +func (g *LoadBalanceGroup) AliveForTestUrl(proxy adapter.Outbound) bool { + if history := g.history.LoadURLTestHistory(RealTag(proxy)); history != nil { + return true + } + return false +} + +func getKey(metadata *adapter.InboundContext) string { + if metadata == nil { + return "" + } + + var metadataHost string + if metadata.Destination.IsFqdn() { + metadataHost = metadata.Destination.Fqdn + } else if metadata.SniffHost != "" { + metadataHost = metadata.SniffHost + } else { + metadataHost = metadata.Domain + } + + if metadataHost != "" { + // ip host + if ip := net.ParseIP(metadataHost); ip != nil { + return metadataHost + } + + if etld, err := publicsuffix.EffectiveTLDPlusOne(metadataHost); err == nil { + return etld + } + } + + var destinationAddr netip.Addr + if len(metadata.DestinationAddresses) > 0 { + destinationAddr = metadata.DestinationAddresses[0] + } else { + destinationAddr = metadata.Destination.Addr + } + + if !destinationAddr.IsValid() { + return "" + } + + return destinationAddr.String() +} + +func getKeyWithSrcAndDst(metadata *adapter.InboundContext) string { + dst := getKey(metadata) + src := "" + if metadata != nil { + src = metadata.Source.Addr.String() + } + + return fmt.Sprintf("%s%s", src, dst) +} + +func jumpHash(key uint64, buckets int32) int32 { + var b, j int64 + + for j < int64(buckets) { + b = j + key = key*2862933555777941757 + 1 + j = int64(float64(b+1) * (float64(int64(1)<<31) / float64((key>>33)+1))) + } + + return int32(b) +} + +func strategyRoundRobin(g *LoadBalanceGroup, url string) strategyFn { + idx := 0 + idxMutex := sync.Mutex{} + return func(metadata *adapter.InboundContext, touch bool) adapter.Outbound { + idxMutex.Lock() + defer idxMutex.Unlock() + + i := 0 + length := len(g.outbounds) + + if touch { + defer func() { + idx = (idx + i) % length + }() + } + + for ; i < length; i++ { + id := (idx + i) % length + proxy := g.outbounds[id] + if g.AliveForTestUrl(proxy) { + i++ + return proxy + } + } + + return g.outbounds[0] + } +} + +func strategyConsistentHashing(g *LoadBalanceGroup, url string) strategyFn { + maxRetry := 5 + hash := maphash.NewHasher[string]() + return func(metadata *adapter.InboundContext, touch bool) adapter.Outbound { + key := hash.Hash(getKey(metadata)) + buckets := int32(len(g.outbounds)) + for i := 0; i < maxRetry; i, key = i+1, key+1 { + idx := jumpHash(key, buckets) + proxy := g.outbounds[idx] + if g.AliveForTestUrl(proxy) { + return proxy + } + } + + // when availability is poor, traverse the entire list to get the available nodes + for _, proxy := range g.outbounds { + if g.AliveForTestUrl(proxy) { + return proxy + } + } + + return g.outbounds[0] + } +} + +func strategyStickySessions(g *LoadBalanceGroup, url string) strategyFn { + maxRetry := 5 + lruCache := common.Must1(freelru.NewSharded[uint64, int](1000, maphash.NewHasher[uint64]().Hash32)) + lruCache.SetLifetime(g.ttl) + hash := maphash.NewHasher[string]() + return func(metadata *adapter.InboundContext, touch bool) adapter.Outbound { + key := hash.Hash(getKeyWithSrcAndDst(metadata)) + length := len(g.outbounds) + idx, has := lruCache.Get(key) + if !has || idx >= length { + idx = int(jumpHash(key+uint64(time.Now().UnixNano()), int32(length))) + } + + nowIdx := idx + for i := 1; i < maxRetry; i++ { + proxy := g.outbounds[nowIdx] + if g.AliveForTestUrl(proxy) { + if !has || nowIdx != idx { + lruCache.Add(key, nowIdx) + } + + return proxy + } else { + nowIdx = int(jumpHash(key+uint64(time.Now().UnixNano()), int32(length))) + } + } + + lruCache.Add(key, 0) + return g.outbounds[0] + } +} diff --git a/protocol/group/selector.go b/protocol/group/selector.go index f3f7377b61..efd7a4f6ab 100644 --- a/protocol/group/selector.go +++ b/protocol/group/selector.go @@ -3,6 +3,7 @@ package group import ( "context" "net" + "regexp" "time" "github.com/sagernet/sing-box/adapter" @@ -42,11 +43,20 @@ type Selector struct { selected common.TypedValue[adapter.Outbound] interruptGroup *interrupt.Group interruptExternalConnections bool + + provider adapter.ProviderManager + providers map[string]adapter.Provider + outboundsCache map[string][]adapter.Outbound + + providerTags []string + exclude *regexp.Regexp + include *regexp.Regexp + useAllProviders bool } func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SelectorOutboundOptions) (adapter.Outbound, error) { outbound := &Selector{ - Adapter: outbound.NewAdapter(C.TypeSelector, tag, nil, options.Outbounds), + Adapter: outbound.NewAdapter(C.TypeSelector, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds), ctx: ctx, outbound: service.FromContext[adapter.OutboundManager](ctx), connection: service.FromContext[adapter.ConnectionManager](ctx), @@ -56,9 +66,15 @@ func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextL outbounds: make(map[string]adapter.Outbound), interruptGroup: interrupt.NewGroup(), interruptExternalConnections: options.InterruptExistConnections, - } - if len(outbound.tags) == 0 { - return nil, E.New("missing tags") + + provider: service.FromContext[adapter.ProviderManager](ctx), + providers: make(map[string]adapter.Provider), + outboundsCache: make(map[string][]adapter.Outbound), + + providerTags: options.Providers, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + useAllProviders: options.UseAllProviders, } return outbound, nil } @@ -72,6 +88,28 @@ func (s *Selector) Network() []string { } func (s *Selector) Start() error { + if s.useAllProviders { + var providerTags []string + for _, provider := range s.provider.Providers() { + providerTags = append(providerTags, provider.Tag()) + s.providers[provider.Tag()] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + s.providerTags = providerTags + } else { + for i, tag := range s.providerTags { + provider, loaded := s.provider.Get(tag) + if !loaded { + return E.New("outbound provider ", i, " not found: ", tag) + } + s.providers[tag] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + } + if len(s.tags)+len(s.providerTags) == 0 { + return E.New("missing outbound and provider tags") + } + for i, tag := range s.tags { detour, loaded := s.outbound.Outbound(tag) if !loaded { @@ -79,31 +117,16 @@ func (s *Selector) Start() error { } s.outbounds[tag] = detour } - - if s.Tag() != "" { - cacheFile := service.FromContext[adapter.CacheFile](s.ctx) - if cacheFile != nil { - selected := cacheFile.LoadSelected(s.Tag()) - if selected != "" { - detour, loaded := s.outbounds[selected] - if loaded { - s.selected.Store(detour) - return nil - } - } - } + if len(s.tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + s.tags = append(s.tags, detour.Tag()) + s.outbounds[detour.Tag()] = detour } - - if s.defaultTag != "" { - detour, loaded := s.outbounds[s.defaultTag] - if !loaded { - return E.New("default outbound not found: ", s.defaultTag) - } - s.selected.Store(detour) - return nil + outbound, err := s.outboundSelect() + if err != nil { + return err } - - s.selected.Store(s.outbounds[s.tags[0]]) + s.selected.Store(outbound) return nil } @@ -119,6 +142,10 @@ func (s *Selector) All() []string { return s.tags } +func (s *Selector) Selected() adapter.Outbound { + return s.selected.Load() +} + func (s *Selector) SelectOutbound(tag string) bool { detour, loaded := s.outbounds[tag] if !loaded { @@ -145,7 +172,7 @@ func (s *Selector) DialContext(ctx context.Context, network string, destination if err != nil { return nil, err } - return s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } func (s *Selector) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { @@ -153,7 +180,7 @@ func (s *Selector) ListenPacket(ctx context.Context, destination M.Socksaddr) (n if err != nil { return nil, err } - return s.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } func (s *Selector) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { @@ -186,7 +213,83 @@ func (s *Selector) NewDirectRouteConnection(metadata adapter.InboundContext, rou func RealTag(detour adapter.Outbound) string { if group, isGroup := detour.(adapter.OutboundGroup); isGroup { - return group.Now() + if now := group.Now(); now != "" { + return now + } } return detour.Tag() } + +func (s *Selector) onProviderUpdated(tag string) error { + _, loaded := s.providers[tag] + if !loaded { + return E.New(s.Tag(), ": ", "outbound provider not found: ", tag) + } + var ( + tags = s.Dependencies() + outboundByTag = make(map[string]adapter.Outbound) + ) + for _, tag := range tags { + outboundByTag[tag] = s.outbounds[tag] + } + for _, providerTag := range s.providerTags { + if providerTag != tag && s.outboundsCache[providerTag] != nil { + for _, detour := range s.outboundsCache[providerTag] { + tags = append(tags, detour.Tag()) + outboundByTag[detour.Tag()] = detour + } + continue + } + provider := s.providers[providerTag] + var cache []adapter.Outbound + for _, detour := range provider.Outbounds() { + tag := detour.Tag() + if s.exclude != nil && s.exclude.MatchString(tag) { + continue + } + if s.include != nil && !s.include.MatchString(tag) { + continue + } + tags = append(tags, tag) + cache = append(cache, detour) + outboundByTag[tag] = detour + } + s.outboundsCache[providerTag] = cache + } + if len(tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + tags = append(tags, detour.Tag()) + outboundByTag[detour.Tag()] = detour + } + s.tags, s.outbounds = tags, outboundByTag + detour, _ := s.outboundSelect() + if s.selected.Swap(detour) != detour { + s.interruptGroup.Interrupt(s.interruptExternalConnections) + } + return nil +} + +func (s *Selector) outboundSelect() (adapter.Outbound, error) { + if s.Tag() != "" { + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil { + selected := cacheFile.LoadSelected(s.Tag()) + if selected != "" { + detour, loaded := s.outbounds[selected] + if loaded { + return detour, nil + } + } + } + } + + if s.defaultTag != "" { + detour, loaded := s.outbounds[s.defaultTag] + if !loaded { + return nil, E.New("default outbound not found: ", s.defaultTag) + } + return detour, nil + } + + return s.outbounds[s.tags[0]], nil +} diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index 26967279db..2e5063a7fa 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -3,6 +3,7 @@ package group import ( "context" "net" + "regexp" "sync" "sync/atomic" "time" @@ -14,7 +15,7 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/batch" E "github.com/sagernet/sing/common/exceptions" @@ -43,8 +44,24 @@ type URLTest struct { interval time.Duration tolerance uint16 idleTimeout time.Duration + fallback URLTestFallback group *URLTestGroup interruptExternalConnections bool + + provider adapter.ProviderManager + providers map[string]adapter.Provider + outboundsCache map[string][]adapter.Outbound + cancel context.CancelFunc + + providerTags []string + exclude *regexp.Regexp + include *regexp.Regexp + useAllProviders bool +} + +type URLTestFallback struct { + enabled bool + maxDelay uint16 } func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.URLTestOutboundOptions) (adapter.Outbound, error) { @@ -61,14 +78,48 @@ func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLo tolerance: options.Tolerance, idleTimeout: time.Duration(options.IdleTimeout), interruptExternalConnections: options.InterruptExistConnections, - } - if len(outbound.tags) == 0 { - return nil, E.New("missing tags") + + provider: service.FromContext[adapter.ProviderManager](ctx), + providers: make(map[string]adapter.Provider), + outboundsCache: make(map[string][]adapter.Outbound), + + providerTags: options.Providers, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + useAllProviders: options.UseAllProviders, + } + if options.Fallback.Enabled { + outbound.fallback = URLTestFallback{ + enabled: true, + maxDelay: uint16(time.Duration(options.Fallback.MaxDelay).Milliseconds()), + } } return outbound, nil } func (s *URLTest) Start() error { + if s.useAllProviders { + var providerTags []string + for _, provider := range s.provider.Providers() { + providerTags = append(providerTags, provider.Tag()) + s.providers[provider.Tag()] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + s.providerTags = providerTags + } else { + for i, tag := range s.providerTags { + provider, loaded := s.provider.Get(tag) + if !loaded { + return E.New("outbound provider ", i, " not found: ", tag) + } + s.providers[tag] = provider + provider.RegisterCallback(s.onProviderUpdated) + } + } + if len(s.tags)+len(s.providerTags) == 0 { + return E.New("missing outbound and provider tags") + } + outbounds := make([]adapter.Outbound, 0, len(s.tags)) for i, tag := range s.tags { detour, loaded := s.outbound.Outbound(tag) @@ -77,7 +128,12 @@ func (s *URLTest) Start() error { } outbounds = append(outbounds, detour) } - group, err := NewURLTestGroup(s.ctx, s.outbound, s.logger, outbounds, s.link, s.interval, s.tolerance, s.idleTimeout, s.interruptExternalConnections) + if len(s.tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + s.tags = append(s.tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + group, err := NewURLTestGroup(s.ctx, s.outbound, s.logger, outbounds, s.link, s.interval, s.tolerance, s.idleTimeout, s.fallback, s.interruptExternalConnections) if err != nil { return err } @@ -117,6 +173,13 @@ func (s *URLTest) CheckOutbounds() { s.group.CheckOutbounds(true) } +func (s *URLTest) isGroupActive() bool { + if !s.group.started { + return false + } + return time.Since(s.group.lastActive.Load()) <= s.group.idleTimeout +} + func (s *URLTest) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { s.group.Touch() var outbound adapter.Outbound @@ -136,7 +199,7 @@ func (s *URLTest) DialContext(ctx context.Context, network string, destination M } conn, err := outbound.DialContext(ctx, network, destination) if err == nil { - return s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } s.logger.ErrorContext(ctx, err) s.group.history.DeleteURLTestHistory(outbound.Tag()) @@ -154,7 +217,7 @@ func (s *URLTest) ListenPacket(ctx context.Context, destination M.Socksaddr) (ne } conn, err := outbound.ListenPacket(ctx, destination) if err == nil { - return s.group.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx)), nil + return s.group.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil } s.logger.ErrorContext(ctx, err) s.group.history.DeleteURLTestHistory(outbound.Tag()) @@ -186,6 +249,65 @@ func (s *URLTest) NewDirectRouteConnection(metadata adapter.InboundContext, rout return selected.(adapter.DirectRouteOutbound).NewDirectRouteConnection(metadata, routeContext, timeout) } +func (s *URLTest) onProviderUpdated(tag string) error { + _, loaded := s.providers[tag] + if !loaded { + return E.New("outbound provider not found: ", tag) + } + var ( + tags = s.Dependencies() + outbounds []adapter.Outbound + ) + for _, tag := range tags { + detour, _ := s.outbound.Outbound(tag) + outbounds = append(outbounds, detour) + } + for _, providerTag := range s.providerTags { + if providerTag != tag && s.outboundsCache[providerTag] != nil { + for _, detour := range s.outboundsCache[providerTag] { + tags = append(tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + continue + } + provider := s.providers[providerTag] + var cache []adapter.Outbound + for _, detour := range provider.Outbounds() { + tag := detour.Tag() + if s.exclude != nil && s.exclude.MatchString(tag) { + continue + } + if s.include != nil && !s.include.MatchString(tag) { + continue + } + tags = append(tags, tag) + cache = append(cache, detour) + } + outbounds = append(outbounds, cache...) + s.outboundsCache[providerTag] = cache + } + if len(tags) == 0 { + detour, _ := s.outbound.Outbound("Compatible") + tags = append(tags, detour.Tag()) + outbounds = append(outbounds, detour) + } + s.tags, s.group.outbounds = tags, outbounds + if s.isGroupActive() { + s.group.access.Lock() + if s.group.ticker != nil { + s.group.ticker.Reset(s.group.interval) + } + s.group.access.Unlock() + ctx, cancel := context.WithCancel(s.ctx) + if s.cancel != nil { + s.cancel() + } + s.cancel = cancel + s.URLTest(ctx) + } + return nil +} + type URLTestGroup struct { ctx context.Context router adapter.Router @@ -209,9 +331,11 @@ type URLTestGroup struct { close chan struct{} started bool lastActive common.TypedValue[time.Time] + + fallback URLTestFallback } -func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManager, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, tolerance uint16, idleTimeout time.Duration, interruptExternalConnections bool) (*URLTestGroup, error) { +func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManager, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, tolerance uint16, idleTimeout time.Duration, fallback URLTestFallback, interruptExternalConnections bool) (*URLTestGroup, error) { if interval == 0 { interval = C.DefaultURLTestInterval } @@ -242,6 +366,7 @@ func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManage tolerance: tolerance, idleTimeout: idleTimeout, history: history, + fallback: fallback, close: make(chan struct{}), pause: service.FromContext[pause.Manager](ctx), interruptGroup: interrupt.NewGroup(), @@ -287,6 +412,8 @@ func (g *URLTestGroup) Close() error { func (g *URLTestGroup) Select(network string) (adapter.Outbound, bool) { var minDelay uint16 var minOutbound adapter.Outbound + var fallbackIgnoreOutboundDelay uint16 + var fallbackIgnoreOutbound adapter.Outbound switch network { case N.NetworkTCP: if g.selectedOutboundTCP != nil { @@ -311,11 +438,30 @@ func (g *URLTestGroup) Select(network string) (adapter.Outbound, bool) { if history == nil { continue } + if g.fallback.enabled && g.fallback.maxDelay > 0 && history.Delay > g.fallback.maxDelay { + if fallbackIgnoreOutboundDelay == 0 || history.Delay < fallbackIgnoreOutboundDelay { + fallbackIgnoreOutboundDelay = history.Delay + fallbackIgnoreOutbound = detour + } + continue + } + if g.fallback.enabled { + minDelay = history.Delay + minOutbound = detour + if minDelay == 0 { + continue + } else { + break + } + } if minDelay == 0 || minDelay > history.Delay+g.tolerance { minDelay = history.Delay minOutbound = detour } } + if minOutbound == nil && fallbackIgnoreOutbound != nil { + return fallbackIgnoreOutbound, true + } if minOutbound == nil { for _, detour := range g.outbounds { if !common.Contains(detour.Network(), network) { @@ -405,7 +551,11 @@ func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint }) } b.Wait() - g.performUpdateCheck() + select { + case <-ctx.Done(): + default: + g.performUpdateCheck() + } return result, nil } diff --git a/protocol/pass/outbound.go b/protocol/pass/outbound.go new file mode 100644 index 0000000000..1946904545 --- /dev/null +++ b/protocol/pass/outbound.go @@ -0,0 +1,40 @@ +package pass + +import ( + "context" + "net" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.StubOptions](registry, C.TypePass, New) +} + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger +} + +func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, _ option.StubOptions) (adapter.Outbound, error) { + return &Outbound{ + Adapter: outbound.NewAdapter(C.TypePass, tag, []string{N.NetworkTCP, N.NetworkUDP}, nil), + logger: logger, + }, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + return nil, syscall.EPERM +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, syscall.EPERM +} diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go index 521bb55146..599434f4a9 100644 --- a/protocol/tailscale/dns_transport.go +++ b/protocol/tailscale/dns_transport.go @@ -169,13 +169,13 @@ func (t *DNSTransport) createResolver(directDialer func() N.Dialer, resolver *dn tlsConfig := common.Must1(tls.NewClient(t.ctx, t.logger, serverAddr.AddrString(), option.OutboundTLSOptions{ ALPN: []string{http2.NextProtoTLS, "http/1.1"}, })) - return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.Header{}, serverAddr, tlsConfig), nil + return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.MethodPost, http.Header{}, serverAddr, tlsConfig), nil case "http": serverAddr = M.ParseSocksaddrHostPortStr(serverURL.Hostname(), serverURL.Port()) if serverAddr.Port == 0 { serverAddr.Port = 80 } - return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.Header{}, serverAddr, nil), nil + return transport.NewHTTPSRaw(t.TransportAdapter, t.logger, myDialer, serverURL, http.MethodPost, http.Header{}, serverAddr, nil), nil // case "tls": default: return nil, E.New("unknown resolver scheme: ", serverURL.Scheme) diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index ff82ef86e4..12236bbafa 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -29,7 +29,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/ping" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" @@ -106,6 +106,7 @@ type Endpoint struct { systemInterface bool systemInterfaceName string + systemInterfaceGSO bool systemInterfaceMTU uint32 systemTun tun.Tun fallbackTCPCloser func() @@ -180,6 +181,10 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } else { udpTimeout = C.UDPTimeout } + gso := options.SystemInterface + if options.SystemInterfaceGSO != nil { + gso = *options.SystemInterfaceGSO + } var remoteIsDomain bool if options.ControlURL != "" { controlURL, err := url.Parse(options.ControlURL) @@ -252,6 +257,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL relayServerStaticEndpoints: options.RelayServerStaticEndpoints, udpTimeout: udpTimeout, systemInterface: options.SystemInterface, + systemInterfaceGSO: gso, systemInterfaceName: options.SystemInterfaceName, systemInterfaceMTU: options.SystemInterfaceMTU, }, nil @@ -301,7 +307,7 @@ func (t *Endpoint) Start(stage adapter.StartStage) error { tunOptions := tun.Options{ Name: tunName, MTU: mtu, - GSO: true, + GSO: t.systemInterfaceGSO, InterfaceScope: true, InterfaceMonitor: t.network.InterfaceMonitor(), InterfaceFinder: t.network.InterfaceFinder(), diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index df9344b817..70445be2f7 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -17,7 +17,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badoption" @@ -156,6 +156,22 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if nfQueue == 0 { nfQueue = tun.DefaultAutoRedirectNFQueue } + var includeMACAddress []net.HardwareAddr + for i, macString := range options.IncludeMACAddress { + mac, macErr := net.ParseMAC(macString) + if macErr != nil { + return nil, E.Cause(macErr, "parse include_mac_address[", i, "]") + } + includeMACAddress = append(includeMACAddress, mac) + } + var excludeMACAddress []net.HardwareAddr + for i, macString := range options.ExcludeMACAddress { + mac, macErr := net.ParseMAC(macString) + if macErr != nil { + return nil, E.Cause(macErr, "parse exclude_mac_address[", i, "]") + } + excludeMACAddress = append(excludeMACAddress, mac) + } networkManager := service.FromContext[adapter.NetworkManager](ctx) multiPendingPackets := C.IsDarwin && ((options.Stack == "gvisor" && tunMTU < 32768) || (options.Stack != "gvisor" && options.MTU <= 9000)) inbound := &Inbound{ @@ -193,6 +209,8 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo IncludeAndroidUser: options.IncludeAndroidUser, IncludePackage: options.IncludePackage, ExcludePackage: options.ExcludePackage, + IncludeMACAddress: includeMACAddress, + ExcludeMACAddress: excludeMACAddress, InterfaceMonitor: networkManager.InterfaceMonitor(), EXP_MultiPendingPackets: multiPendingPackets, }, @@ -235,7 +253,10 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if err != nil { return nil, E.Cause(err, "initialize auto-redirect") } - if !C.IsAndroid { + if options.AutoRedirectDisableMarkMode && (len(inbound.routeRuleSet) > 0 || len(inbound.routeExcludeRuleSet) > 0) { + return nil, E.New("`auto_redirect` mark mode cannot be disabled with `route_address_set` or `route_exclude_address_set`") + } + if !C.IsAndroid && !options.AutoRedirectDisableMarkMode { inbound.tunOptions.AutoRedirectMarkMode = true err = networkManager.RegisterAutoRedirectOutputMark(inbound.tunOptions.AutoRedirectOutputMark) if err != nil { @@ -523,7 +544,7 @@ func (t *autoRedirectHandler) NewConnectionEx(ctx context.Context, conn net.Conn ctx = log.ContextWithNewID(ctx) var metadata adapter.InboundContext metadata.Inbound = t.tag - metadata.InboundType = C.TypeTun + metadata.InboundType = C.TypeRedirect metadata.Source = source metadata.Destination = destination diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index bcf2078eec..984b941d7b 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -14,7 +14,7 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/route/rule" "github.com/sagernet/sing-box/transport/wireguard" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" E "github.com/sagernet/sing/common/exceptions" @@ -72,10 +72,15 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } else { udpTimeout = C.UDPTimeout } + gso := options.System + if options.GSO != nil { + gso = *options.GSO + } wgEndpoint, err := wireguard.NewEndpoint(wireguard.EndpointOptions{ Context: ctx, Logger: logger, System: options.System, + GSO: gso, Handler: ep, UDPTimeout: udpTimeout, Dialer: outboundDialer, diff --git a/provider/local/lcoal.go b/provider/local/lcoal.go new file mode 100644 index 0000000000..d94d78410b --- /dev/null +++ b/provider/local/lcoal.go @@ -0,0 +1,138 @@ +package provider + +import ( + "context" + "os" + "path/filepath" + "time" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/provider" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/provider/parser" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" +) + +func RegisterProviderLocal(registry *provider.Registry) { + provider.Register[option.ProviderLocalOptions](registry, C.ProviderTypeLocal, NewProviderLocal) +} + +func RegisterProviderInline(registry *provider.Registry) { + provider.Register[option.ProviderInlineOptions](registry, C.ProviderTypeInline, NewProviderInline) +} + +var _ adapter.Provider = (*ProviderLocal)(nil) + +type ProviderLocal struct { + provider.Adapter + ctx context.Context + logger log.ContextLogger + provider adapter.ProviderManager + path string + lastOutOpts []option.Outbound + lastUpdated time.Time + watcher *fswatch.Watcher + + overrideDialer *option.OverrideDialerOptions + overrideTLS *option.OverrideTLSOptions +} + +func NewProviderInline(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderInlineOptions) (adapter.Provider, error) { + var ( + outbound = service.FromContext[adapter.OutboundManager](ctx) + logger = logFactory.NewLogger(F.ToString("provider/inline", "[", tag, "]")) + ) + provider := &ProviderLocal{ + Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeInline, options.HealthCheck), + ctx: ctx, + logger: logger, + } + provider.RewriteDetourForProvider(options.Outbounds) + provider.UpdateOutbounds(nil, options.Outbounds) + return provider, nil +} + +func NewProviderLocal(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderLocalOptions) (adapter.Provider, error) { + if options.Path == "" { + return nil, E.New("provider path is required") + } + var ( + outbound = service.FromContext[adapter.OutboundManager](ctx) + logger = logFactory.NewLogger(F.ToString("provider/local", "[", tag, "]")) + ) + provider := &ProviderLocal{ + Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeLocal, options.HealthCheck), + ctx: ctx, + logger: logger, + provider: service.FromContext[adapter.ProviderManager](ctx), + + overrideDialer: options.OverrideDialer, + overrideTLS: options.OverrideTLS, + } + filePath := filemanager.BasePath(ctx, options.Path) + provider.path, _ = filepath.Abs(filePath) + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: []string{filePath}, + Callback: func(path string) { + uErr := provider.reloadFile(path) + if uErr != nil { + logger.Error(E.Cause(uErr, "reload provider ", tag)) + } + provider.UpdateGroups() + }, + }) + if err != nil { + return nil, err + } + provider.watcher = watcher + return provider, nil +} + +func (s *ProviderLocal) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error { + if s.path != "" { + err := s.reloadFile(s.path) + if err != nil { + return err + } + s.UpdateGroups() + if s.watcher != nil { + err := s.watcher.Start() + if err != nil { + s.logger.Error(E.Cause(err, "watch provider file")) + } + } + } + return s.Adapter.Start() +} + +func (s *ProviderLocal) UpdatedAt() time.Time { + return s.lastUpdated +} + +func (s *ProviderLocal) reloadFile(path string) error { + if fileInfo, err := os.Stat(path); err == nil { + s.lastUpdated = fileInfo.ModTime() + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + outboundOpts, err := parser.ParseSubscription(s.ctx, string(content), s.overrideDialer, s.overrideTLS, s.Tag()) + if err != nil { + return err + } + s.UpdateOutbounds(s.lastOutOpts, outboundOpts) + s.lastOutOpts = outboundOpts + return nil +} + +func (s *ProviderLocal) Close() error { + return common.Close(&s.Adapter, common.PtrOrNil(s.watcher)) +} diff --git a/provider/parser/clash.go b/provider/parser/clash.go new file mode 100644 index 0000000000..e547f6a134 --- /dev/null +++ b/provider/parser/clash.go @@ -0,0 +1,849 @@ +package parser + +import ( + "context" + "encoding/base64" + "strconv" + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json/badoption" + N "github.com/sagernet/sing/common/network" + + "gopkg.in/yaml.v3" +) + +type ClashConfig struct { + Proxies []ClashProxy `yaml:"proxies"` +} + +type _ClashProxy struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Options Proxy `yaml:"-"` + + SingType string `yaml:"-"` +} +type ClashProxy _ClashProxy + +type Proxy interface { + Build() any +} + +func (c *ClashProxy) UnmarshalYAML(value *yaml.Node) error { + err := value.Decode((*_ClashProxy)(c)) + if err != nil { + return err + } + var options Proxy + switch c.Type { + case "ss": + c.SingType = C.TypeShadowsocks + options = &ShadowSocksOption{} + case "tuic": + c.SingType = C.TypeTUIC + options = &TuicOption{} + case "vmess": + c.SingType = C.TypeVMess + options = &VmessOption{} + case "vless": + c.SingType = C.TypeVLESS + options = &VlessOption{} + case "socks5": + c.SingType = C.TypeSOCKS + options = &Socks5Option{} + case "http": + c.SingType = C.TypeHTTP + options = &HttpOption{} + case "trojan": + c.SingType = C.TypeTrojan + options = &TrojanOption{} + case "hysteria": + c.SingType = C.TypeHysteria + options = &HysteriaOption{} + case "hysteria2": + c.SingType = C.TypeHysteria2 + options = &Hysteria2Option{} + case "ssh": + c.SingType = C.TypeSSH + options = &SSHOption{} + case "anytls": + c.SingType = C.TypeAnyTLS + options = &AnyTLSOption{} + default: + return nil + } + err = value.Decode(options) + if err != nil { + return err + } + c.Options = options + return nil +} + +func (c *ClashProxy) Build() option.Outbound { + outbound := option.Outbound{ + Tag: c.Name, + Type: c.SingType, + } + if c.Options != nil { + outbound.Options = c.Options.Build() + } + return outbound +} + +func ParseClashSubscription(_ context.Context, content string) ([]option.Outbound, error) { + config := &ClashConfig{} + err := yaml.Unmarshal([]byte(content), &config) + if err != nil { + return nil, E.Cause(err, "parse clash config") + } + outbounds := common.FilterIsInstance(config.Proxies, func(proxy ClashProxy) (option.Outbound, bool) { + if proxy.SingType == "" { + return option.Outbound{}, false + } + return proxy.Build(), true + }) + return outbounds, nil +} + +type ShadowSocksOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + Password string `yaml:"password"` + Cipher string `yaml:"cipher"` + UDP bool `yaml:"udp,omitempty"` + Plugin string `yaml:"plugin,omitempty"` + PluginOpts map[string]any `yaml:"plugin-opts,omitempty"` + UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"` + UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (s *ShadowSocksOption) Build() any { + return &option.ShadowsocksOutboundOptions{ + DialerOptions: s.DialerOptions.Build(), + ServerOptions: s.ServerOptions.Build(), + Password: s.Password, + Method: clashShadowsocksCipher(s.Cipher), + Plugin: clashPluginName(s.Plugin), + PluginOptions: clashPluginOptions(s.Plugin, s.PluginOpts), + Network: clashNetworks(s.UDP), + UDPOverTCP: &option.UDPOverTCPOptions{ + Enabled: s.UDPOverTCP, + Version: uint8(s.UDPOverTCPVersion), + }, + Multiplex: s.MuxOpts.Build(), + } +} + +type TuicOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + UUID string `yaml:"uuid,omitempty"` + Password string `yaml:"password,omitempty"` + Ip string `yaml:"ip,omitempty"` + HeartbeatInterval int `yaml:"heartbeat-interval,omitempty"` + DisableSni bool `yaml:"disable-sni,omitempty"` + ReduceRtt bool `yaml:"reduce-rtt,omitempty"` + UdpRelayMode string `yaml:"udp-relay-mode,omitempty"` + CongestionController string `yaml:"congestion-controller,omitempty"` + FastOpen bool `yaml:"fast-open,omitempty"` + DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"` + UDPOverStream bool `yaml:"udp-over-stream,omitempty"` +} + +func (t *TuicOption) Build() any { + t.TLS = true + t.TFO = t.FastOpen + options := &option.TUICOutboundOptions{ + DialerOptions: t.DialerOptions.Build(), + ServerOptions: t.ServerOptions.Build(), + UUID: t.UUID, + Password: t.Password, + CongestionControl: t.CongestionController, + UDPRelayMode: t.UdpRelayMode, + UDPOverStream: t.UDPOverStream, + ZeroRTTHandshake: t.ReduceRtt, + Heartbeat: badoption.Duration(t.HeartbeatInterval), + OutboundTLSOptionsContainer: clashTLSOptions(t.Server, &t.TLSOptions), + } + if t.Ip != "" { + options.Server = t.Ip + } + if t.DisableSni { + options.TLS.DisableSNI = true + } + return options +} + +type VmessOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + *TLSOptions `yaml:",inline"` + UUID string `yaml:"uuid"` + AlterID int `yaml:"alterId"` + Cipher string `yaml:"cipher"` + UDP bool `yaml:"udp,omitempty"` + Network string `yaml:"network,omitempty"` + ServerName string `yaml:"servername,omitempty"` + HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"` + HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"` + GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` + WSOpts WSOptions `yaml:"ws-opts,omitempty"` + PacketAddr bool `yaml:"packet-addr,omitempty"` + XUDP bool `yaml:"xudp,omitempty"` + PacketEncoding string `yaml:"packet-encoding,omitempty"` + GlobalPadding bool `yaml:"global-padding,omitempty"` + AuthenticatedLength bool `yaml:"authenticated-length,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (v *VmessOption) Build() any { + if v.TLSOptions != nil { + v.SNI = v.ServerName + } + switch v.PacketEncoding { + case "": + if v.XUDP { + v.PacketEncoding = "xudp" + } else if v.PacketAddr { + v.PacketEncoding = "packetaddr" + } + case "packet": + v.PacketEncoding = "packetaddr" + } + return &option.VMessOutboundOptions{ + DialerOptions: v.DialerOptions.Build(), + ServerOptions: v.ServerOptions.Build(), + UUID: v.UUID, + Security: v.Cipher, + AlterId: v.AlterID, + GlobalPadding: v.GlobalPadding, + AuthenticatedLength: v.AuthenticatedLength, + Network: clashNetworks(v.UDP), + OutboundTLSOptionsContainer: clashTLSOptions(v.Server, v.TLSOptions), + PacketEncoding: v.PacketEncoding, + Multiplex: v.MuxOpts.Build(), + Transport: clashTransport(v.Network, v.HTTPOpts, v.HTTP2Opts, v.GrpcOpts, v.WSOpts), + } +} + +type VlessOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + *TLSOptions `yaml:",inline"` + UUID string `yaml:"uuid"` + Flow string `yaml:"flow,omitempty"` + UDP bool `yaml:"udp,omitempty"` + PacketAddr bool `yaml:"packet-addr,omitempty"` + XUDP bool `yaml:"xudp,omitempty"` + PacketEncoding string `yaml:"packet-encoding,omitempty"` + Network string `yaml:"network,omitempty"` + ServerName string `yaml:"servername,omitempty"` + HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"` + HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"` + GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` + WSOpts WSOptions `yaml:"ws-opts,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (v *VlessOption) Build() any { + if v.TLSOptions != nil { + v.SNI = v.ServerName + } + switch v.PacketEncoding { + case "": + if v.PacketAddr { + v.PacketEncoding = "packetaddr" + } else { + v.PacketEncoding = "xudp" + } + case "packet": + v.PacketEncoding = "packetaddr" + } + return &option.VLESSOutboundOptions{ + DialerOptions: v.DialerOptions.Build(), + ServerOptions: v.ServerOptions.Build(), + UUID: v.UUID, + Flow: v.Flow, + Network: clashNetworks(v.UDP), + OutboundTLSOptionsContainer: clashTLSOptions(v.Server, v.TLSOptions), + Multiplex: v.MuxOpts.Build(), + Transport: clashTransport(v.Network, v.HTTPOpts, v.HTTP2Opts, v.GrpcOpts, v.WSOpts), + PacketEncoding: &v.PacketEncoding, + } +} + +type Socks5Option struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + UserName string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + UDP bool `yaml:"udp,omitempty"` +} + +func (s *Socks5Option) Build() any { + return &option.SOCKSOutboundOptions{ + DialerOptions: s.DialerOptions.Build(), + ServerOptions: s.ServerOptions.Build(), + Username: s.UserName, + Password: s.Password, + Network: clashNetworks(s.UDP), + } +} + +type HttpOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + *TLSOptions `yaml:",inline"` + UserName string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` +} + +func (h *HttpOption) Build() any { + return &option.HTTPOutboundOptions{ + DialerOptions: h.DialerOptions.Build(), + ServerOptions: h.ServerOptions.Build(), + Username: h.UserName, + Password: h.Password, + OutboundTLSOptionsContainer: clashTLSOptions(h.Server, h.TLSOptions), + Headers: clashHeaders(h.Headers), + } +} + +type TrojanOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Password string `yaml:"password"` + UDP bool `yaml:"udp,omitempty"` + Network string `yaml:"network,omitempty"` + GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"` + WSOpts WSOptions `yaml:"ws-opts,omitempty"` + MuxOpts *MuxOptions `yaml:"smux,omitempty"` +} + +func (t *TrojanOption) Build() any { + t.TLS = true + return &option.TrojanOutboundOptions{ + DialerOptions: t.DialerOptions.Build(), + ServerOptions: t.ServerOptions.Build(), + Password: t.Password, + Network: clashNetworks(t.UDP), + OutboundTLSOptionsContainer: clashTLSOptions(t.Server, &t.TLSOptions), + Multiplex: t.MuxOpts.Build(), + Transport: clashTransport(t.Network, HTTPOptions{}, HTTP2Options{}, t.GrpcOpts, t.WSOpts), + } +} + +type HysteriaOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Ports string `yaml:"ports,omitempty"` + Up string `yaml:"up"` + UpSpeed int `yaml:"up-speed,omitempty"` // compatible with Stash + Down string `yaml:"down"` + DownSpeed int `yaml:"down-speed,omitempty"` // compatible with Stash + Auth string `yaml:"auth,omitempty"` + AuthString string `yaml:"auth-str,omitempty"` + Obfs string `yaml:"obfs,omitempty"` + ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"` + ReceiveWindow int `yaml:"recv-window,omitempty"` + DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"` + FastOpen bool `yaml:"fast-open,omitempty"` + HopInterval int `yaml:"hop-interval,omitempty"` +} + +func (h *HysteriaOption) Build() any { + h.TLS = true + h.TFO = h.FastOpen + return &option.HysteriaOutboundOptions{ + DialerOptions: h.DialerOptions.Build(), + ServerOptions: h.ServerOptions.Build(), + ServerPorts: clashPorts(h.Ports), + HopInterval: badoption.Duration(h.HopInterval), + Up: clashSpeedToNetworkBytes(h.Up), + UpMbps: h.UpSpeed, + Down: clashSpeedToNetworkBytes(h.Down), + DownMbps: h.DownSpeed, + Obfs: h.Obfs, + Auth: []byte(h.Auth), + AuthString: h.AuthString, + ReceiveWindowConn: uint64(h.ReceiveWindowConn), + ReceiveWindow: uint64(h.ReceiveWindow), + DisableMTUDiscovery: h.DisableMTUDiscovery, + OutboundTLSOptionsContainer: clashTLSOptions(h.Server, &h.TLSOptions), + } +} + +type Hysteria2Option struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Ports string `yaml:"ports,omitempty"` + HopInterval int `yaml:"hop-interval,omitempty"` + Up string `yaml:"up,omitempty"` + Down string `yaml:"down,omitempty"` + Password string `yaml:"password,omitempty"` + Obfs string `yaml:"obfs,omitempty"` + ObfsPassword string `yaml:"obfs-password,omitempty"` +} + +func (h *Hysteria2Option) Build() any { + h.TLS = true + return &option.Hysteria2OutboundOptions{ + DialerOptions: h.DialerOptions.Build(), + ServerOptions: h.ServerOptions.Build(), + ServerPorts: clashPorts(h.Ports), + HopInterval: badoption.Duration(h.HopInterval), + UpMbps: clashSpeedToIntMbps(h.Up), + DownMbps: clashSpeedToIntMbps(h.Down), + Obfs: clashHysteria2Obfs(h.Obfs, h.ObfsPassword), + Password: h.Password, + OutboundTLSOptionsContainer: clashTLSOptions(h.Server, &h.TLSOptions), + } +} + +type SSHOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + UserName string `yaml:"username"` + Password string `yaml:"password,omitempty"` + PrivateKey string `yaml:"private-key,omitempty"` + PrivateKeyPassphrase string `yaml:"private-key-passphrase,omitempty"` + HostKey []string `yaml:"host-key,omitempty"` + HostKeyAlgorithms []string `yaml:"host-key-algorithms,omitempty"` +} + +func (s *SSHOption) Build() any { + options := &option.SSHOutboundOptions{ + DialerOptions: s.DialerOptions.Build(), + ServerOptions: s.ServerOptions.Build(), + User: s.UserName, + Password: s.Password, + PrivateKeyPassphrase: s.PrivateKeyPassphrase, + HostKey: s.HostKey, + HostKeyAlgorithms: s.HostKeyAlgorithms, + } + if strings.Contains(s.PrivateKey, "PRIVATE KEY") { + options.PrivateKey = trimStringArray(strings.Split(s.PrivateKey, "\n")) + } else { + options.PrivateKeyPath = s.PrivateKey + } + return options +} + +type AnyTLSOption struct { + DialerOptions `yaml:",inline"` + ServerOptions `yaml:",inline"` + TLSOptions `yaml:",inline"` + Password string `yaml:"password"` + UDP bool `yaml:"udp,omitempty"` + IdleSessionCheckInterval int `yaml:"idle-session-check-interval,omitempty"` + IdleSessionTimeout int `yaml:"idle-session-timeout,omitempty"` + MinIdleSession int `yaml:"min-idle-session,omitempty"` +} + +func (a *AnyTLSOption) Build() any { + a.TLS = true + return &option.AnyTLSOutboundOptions{ + DialerOptions: a.DialerOptions.Build(), + ServerOptions: a.ServerOptions.Build(), + OutboundTLSOptionsContainer: clashTLSOptions(a.Server, &a.TLSOptions), + Password: a.Password, + IdleSessionCheckInterval: badoption.Duration(a.IdleSessionCheckInterval), + IdleSessionTimeout: badoption.Duration(a.IdleSessionTimeout), + MinIdleSession: a.MinIdleSession, + } +} + +type HTTPOptions struct { + Method string `yaml:"method,omitempty"` + Path []string `yaml:"path,omitempty"` + Headers badoption.HTTPHeader `yaml:"headers,omitempty"` +} + +type HTTP2Options struct { + Host []string `yaml:"host,omitempty"` + Path string `yaml:"path,omitempty"` +} + +type GrpcOptions struct { + GrpcServiceName string `yaml:"grpc-service-name,omitempty"` +} + +type WSOptions struct { + Path string `yaml:"path,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + MaxEarlyData int `yaml:"max-early-data,omitempty"` + EarlyDataHeaderName string `yaml:"early-data-header-name,omitempty"` + V2rayHttpUpgrade bool `yaml:"v2ray-http-upgrade,omitempty"` +} + +type MuxOptions struct { + Enabled bool `yaml:"enabled,omitempty"` + Protocol string `yaml:"protocol,omitempty"` + MaxConnections int `yaml:"max-connections,omitempty"` + MinStreams int `yaml:"min-streams,omitempty"` + MaxStreams int `yaml:"max-streams,omitempty"` + Padding bool `yaml:"padding,omitempty"` + BrutalOpts *BrutalOptions `yaml:"brutal-opts,omitempty"` +} + +func (s *MuxOptions) Build() *option.OutboundMultiplexOptions { + if s == nil { + return nil + } + return &option.OutboundMultiplexOptions{ + Enabled: s.Enabled, + Protocol: s.Protocol, + MaxConnections: s.MaxConnections, + MinStreams: s.MinStreams, + MaxStreams: s.MaxStreams, + Padding: s.Padding, + Brutal: s.BrutalOpts.Build(), + } +} + +type BrutalOptions struct { + Enabled bool `yaml:"enabled,omitempty"` + Up string `yaml:"up,omitempty"` + Down string `yaml:"down,omitempty"` +} + +func (b *BrutalOptions) Build() *option.BrutalOptions { + if b == nil { + return nil + } + return &option.BrutalOptions{ + Enabled: b.Enabled, + UpMbps: clashSpeedToIntMbps(b.Up), + DownMbps: clashSpeedToIntMbps(b.Down), + } +} + +type RealityOptions struct { + PublicKey string `yaml:"public-key"` + ShortID string `yaml:"short-id"` +} + +func (r *RealityOptions) Build() *option.OutboundRealityOptions { + if r == nil { + return nil + } + return &option.OutboundRealityOptions{ + Enabled: true, + PublicKey: r.PublicKey, + ShortID: r.ShortID, + } +} + +type ECHOptions struct { + Enable bool `yaml:"enable,omitempty"` + Config string `yaml:"config,omitempty"` +} + +func (e *ECHOptions) Build() *option.OutboundECHOptions { + if e == nil { + return nil + } + list, err := base64.StdEncoding.DecodeString(e.Config) + if err != nil { + return nil + } + return &option.OutboundECHOptions{ + Enabled: e.Enable, + Config: trimStringArray(strings.Split(string(list), "\n")), + } +} + +type TLSOptions struct { + TLS bool `yaml:"tls,omitempty"` + SNI string `yaml:"sni,omitempty"` + SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"` + Fingerprint string `yaml:"fingerprint,omitempty"` + ALPN []string `yaml:"alpn,omitempty"` + ClientFingerprint string `yaml:"client-fingerprint,omitempty"` + CustomCA string `yaml:"ca,omitempty"` + CustomCAString string `yaml:"ca-str,omitempty"` + Certificate string `yaml:"certificate,omitempty"` + PrivateKey string `yaml:"private-key,omitempty"` + ECHOpts *ECHOptions `yaml:"ech-opts,omitempty"` + RealityOpts *RealityOptions `yaml:"reality-opts,omitempty"` + KernelTx bool `yaml:"kernel-tx,omitempty"` + KernelRx bool `yaml:"kernel-rx,omitempty"` +} + +func (t *TLSOptions) Build() *option.OutboundTLSOptions { + if t == nil || !t.TLS { + return nil + } + options := &option.OutboundTLSOptions{ + Enabled: t.TLS, + ServerName: t.SNI, + Insecure: t.SkipCertVerify, + CertificatePinSHA256: t.Fingerprint, + ALPN: t.ALPN, + UTLS: clashClientFingerprint(t.ClientFingerprint), + Certificate: trimStringArray(strings.Split(t.CustomCAString, "\n")), + CertificatePath: t.CustomCA, + ECH: t.ECHOpts.Build(), + Reality: t.RealityOpts.Build(), + KernelTx: t.KernelTx, + KernelRx: t.KernelRx, + } + if strings.HasPrefix(t.Certificate, "-----BEGIN ") { + options.ClientCertificate = trimStringArray(strings.Split(t.Certificate, "\n")) + } else { + options.ClientCertificatePath = t.Certificate + } + if strings.HasPrefix(t.PrivateKey, "-----BEGIN ") { + options.ClientKey = trimStringArray(strings.Split(t.PrivateKey, "\n")) + } else { + options.ClientKeyPath = t.PrivateKey + } + return options +} + +type DialerOptions struct { + TFO bool `yaml:"tfo,omitempty"` + MPTCP bool `yaml:"mptcp,omitempty"` + Interface string `yaml:"interface-name,omitempty"` + RoutingMark int `yaml:"routing-mark,omitempty"` + DialerProxy string `yaml:"dialer-proxy,omitempty"` +} + +func (b *DialerOptions) Build() option.DialerOptions { + return option.DialerOptions{ + Detour: b.DialerProxy, + BindInterface: b.Interface, + TCPFastOpen: b.TFO, + TCPMultiPath: b.MPTCP, + RoutingMark: option.FwMark(b.RoutingMark), + } +} + +type ServerOptions struct { + Server string `yaml:"server"` + Port int `yaml:"port"` +} + +func (s *ServerOptions) Build() option.ServerOptions { + return option.ServerOptions{ + Server: s.Server, + ServerPort: uint16(s.Port), + } +} + +type shadowsocksPluginOptionsBuilder map[string]any + +func (o shadowsocksPluginOptionsBuilder) Build() string { + var opts []string + for key, value := range o { + if value == nil { + continue + } + opts = append(opts, F.ToString(key, "=", value)) + } + return strings.Join(opts, ";") +} + +func clashClientFingerprint(clientFingerprint string) *option.OutboundUTLSOptions { + if clientFingerprint == "" { + return nil + } + return &option.OutboundUTLSOptions{ + Enabled: true, + Fingerprint: clientFingerprint, + } +} + +func clashHeaders(headers map[string]string) map[string]badoption.Listable[string] { + if headers == nil { + return nil + } + result := make(map[string]badoption.Listable[string]) + for key, value := range headers { + result[key] = []string{value} + } + return result +} + +func clashHysteria2Obfs(obfs string, password string) *option.Hysteria2Obfs { + if obfs == "" { + return nil + } + return &option.Hysteria2Obfs{ + Type: obfs, + Password: password, + } +} + +func clashNetworks(udpEnabled bool) option.NetworkList { + if !udpEnabled { + return N.NetworkTCP + } + return "" +} + +func clashPluginName(plugin string) string { + switch plugin { + case "obfs": + return "obfs-local" + } + return plugin +} + +func clashPluginOptions(plugin string, opts map[string]any) string { + options := make(shadowsocksPluginOptionsBuilder) + switch plugin { + case "obfs": + options["obfs"] = opts["mode"] + options["obfs-host"] = opts["host"] + case "v2ray-plugin": + options["mode"] = opts["mode"] + options["tls"] = opts["tls"] + options["host"] = opts["host"] + options["path"] = opts["path"] + } + return options.Build() +} + +func clashPorts(ports string) badoption.Listable[string] { + if ports == "" { + return nil + } + serverPorts := badoption.Listable[string]{} + ports = strings.ReplaceAll(ports, "/", ",") + for _, port := range strings.Split(ports, ",") { + if port == "" { + continue + } + port = strings.Replace(port, "-", ":", 1) + serverPorts = append(serverPorts, port) + } + return serverPorts +} + +func clashShadowsocksCipher(cipher string) string { + switch cipher { + case "dummy": + return "none" + } + return cipher +} + +func clashStringList(list []string) string { + if len(list) > 0 { + return list[0] + } + return "" +} + +func clashSpeedToIntMbps(speed string) int { + if speed == "" { + return 0 + } + if num, err := strconv.Atoi(speed); err == nil { + return num + } + networkBytes := byteformats.NetworkBytesCompat{} + if err := networkBytes.UnmarshalJSON([]byte(speed)); err != nil { + return 0 + } + return int(networkBytes.Value() / byteformats.MByte * 8) +} + +func clashSpeedToNetworkBytes(speed string) *byteformats.NetworkBytesCompat { + if speed == "" { + return nil + } + networkBytes := &byteformats.NetworkBytesCompat{} + if num, err := strconv.Atoi(speed); err == nil { + speed = F.ToString(num, "Mbps") + } + if err := networkBytes.UnmarshalJSON([]byte(speed)); err != nil { + return nil + } + return networkBytes +} + +func clashTransport(network string, httpOpts HTTPOptions, h2Opts HTTP2Options, grpcOpts GrpcOptions, wsOpts WSOptions) *option.V2RayTransportOptions { + switch network { + case "http": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Method: httpOpts.Method, + Path: clashStringList(httpOpts.Path), + Headers: httpOpts.Headers, + }, + } + case "h2": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Path: h2Opts.Path, + Host: h2Opts.Host, + }, + } + case "grpc": + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: grpcOpts.GrpcServiceName, + }, + } + case "ws": + headers := clashHeaders(wsOpts.Headers) + if wsOpts.V2rayHttpUpgrade { + var host string + if headers != nil && headers["Host"] != nil { + host = headers["Host"][0] + } + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTPUpgrade, + HTTPUpgradeOptions: option.V2RayHTTPUpgradeOptions{ + Host: host, + Path: wsOpts.Path, + Headers: headers, + }, + } + } + return &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeWebsocket, + WebsocketOptions: option.V2RayWebsocketOptions{ + Path: wsOpts.Path, + Headers: headers, + MaxEarlyData: uint32(wsOpts.MaxEarlyData), + EarlyDataHeaderName: wsOpts.EarlyDataHeaderName, + }, + } + default: + return nil + } +} + +func clashTLSOptions(server string, tlsOptions *TLSOptions) option.OutboundTLSOptionsContainer { + if tlsOptions != nil && tlsOptions.SNI == "" { + tlsOptions.SNI = server + } + return option.OutboundTLSOptionsContainer{ + TLS: tlsOptions.Build(), + } +} + +func trimStringArray(array []string) []string { + return common.Filter(array, func(it string) bool { + return strings.TrimSpace(it) != "" + }) +} diff --git a/provider/parser/link.go b/provider/parser/link.go new file mode 100644 index 0000000000..3bce895ade --- /dev/null +++ b/provider/parser/link.go @@ -0,0 +1,716 @@ +package parser + +import ( + "net/url" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badoption" +) + +func ParseSubscriptionLink(link string) (option.Outbound, error) { + reg := regexp.MustCompile(`^(.*?)(://)(.*?)([@?#].*)?$`) + result := reg.FindStringSubmatch(link) + if result == nil { + return option.Outbound{}, E.New("invalid link") + } + + scheme := result[1] + switch scheme { + case "tuic": + return parseTuicLink(link) + case "trojan": + return parseTrojanLink(link) + case "vless": + return parseVLESSLink(link) + case "hysteria": + return parseHysteriaLink(link) + case "hy2", "hysteria2": + return parseHysteria2Link(link) + case "anytls": + return parseAnyTLSLink(link) + } + result[3], _ = DecodeBase64URLSafe(result[3]) + link = strings.Join(result[1:], "") + switch scheme { + case "ss": + return parseShadowsocksLink(link) + case "vmess": + return parseVMessLink(link) + default: + return option.Outbound{}, E.New("unsupported scheme: ", scheme) + } +} + +func StringToType[T any](str string) T { + var value T + v := reflect.ValueOf(&value).Elem() + switch any(value).(type) { + case badoption.Duration: + d, err := time.ParseDuration(str) + if err != nil { + v.SetInt(StringToType[int64](str)) + } else { + v.Set(reflect.ValueOf(d)) + } + return value + case badoption.HTTPHeader: + headers := badoption.HTTPHeader{} + reg := regexp.MustCompile(`^[ \t]*?(\S+?):[ \t]*?(\S+?)[ \t]*?$`) + for _, header := range strings.Split(str, "\n") { + result := reg.FindStringSubmatch(header) + if result != nil { + key := result[1] + headers[key] = strings.Split(result[2], ",") + } + } + v.Set(reflect.ValueOf(headers)) + return value + } + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + i, _ := strconv.ParseInt(str, 10, 64) + v.SetInt(i) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + i, _ := strconv.ParseUint(str, 10, 64) + v.SetUint(i) + case reflect.Float32, reflect.Float64: + f, _ := strconv.ParseFloat(str, 64) + v.SetFloat(f) + case reflect.Bool: + b, _ := strconv.ParseBool(str) + v.SetBool(b) + default: + panic("unsupported type") + } + return value +} + +func shadowsocksPluginName(plugin string) string { + if index := strings.Index(plugin, ";"); index != -1 { + return plugin[:index] + } + return plugin +} + +func shadowsocksPluginOptions(plugin string) string { + if index := strings.Index(plugin, ";"); index != -1 { + return plugin[index+1:] + } + return "" +} + +func v2rayTransportWsPath(WebsocketOptions *option.V2RayWebsocketOptions, path string) { + reg := regexp.MustCompile(`^(.*?)(?:\?ed=(\d*))?$`) + result := reg.FindStringSubmatch(path) + WebsocketOptions.Path = result[1] + if result[2] != "" { + WebsocketOptions.EarlyDataHeaderName = "Sec-WebSocket-Protocol" + WebsocketOptions.MaxEarlyData = StringToType[uint32](result[2]) + } +} + +func v2rayTransportWs(host string, path string) option.V2RayWebsocketOptions { + var WebsocketOptions option.V2RayWebsocketOptions + if host != "" { + WebsocketOptions.Headers = StringToType[badoption.HTTPHeader](F.ToString("Host: ", host)) + } + if path != "" { + v2rayTransportWsPath(&WebsocketOptions, path) + } + return WebsocketOptions +} + +func parseShadowsocksLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing user info") + } + var options option.ShadowsocksOutboundOptions + options.ServerOptions.Server = linkURL.Hostname() + options.ServerOptions.ServerPort = StringToType[uint16](linkURL.Port()) + password, _ := linkURL.User.Password() + if password == "" { + return option.Outbound{}, E.New("bad user info") + } + options.Method = linkURL.User.Username() + options.Password = password + plugin := linkURL.Query().Get("plugin") + options.Plugin = shadowsocksPluginName(plugin) + options.PluginOptions = shadowsocksPluginOptions(plugin) + + outbound := option.Outbound{ + Type: C.TypeShadowsocks, + Tag: linkURL.Fragment, + } + outbound.Options = &options + return outbound, nil +} + +func parseTuicLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing uuid") + } + var options option.TUICOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.UUID = linkURL.User.Username() + options.Password, _ = linkURL.User.Password() + options.ServerOptions.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerOptions.ServerPort = StringToType[uint16](linkURL.Port()) + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "congestion_control": + if value != "cubic" { + options.CongestionControl = value + } + case "udp_relay_mode": + options.UDPRelayMode = value + case "udp_over_stream": + if value == "true" || value == "1" { + options.UDPOverStream = true + } + case "zero_rtt_handshake", "reduce_rtt": + if value == "true" || value == "1" { + options.ZeroRTTHandshake = true + } + case "heartbeat_interval": + options.Heartbeat = StringToType[badoption.Duration](value) + case "sni": + TLSOptions.ServerName = value + case "insecure", "skip-cert-verify", "allow_insecure": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "disable_sni": + if value == "1" || value == "true" { + TLSOptions.DisableSNI = true + } + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + } + } + if options.UDPOverStream { + options.UDPRelayMode = "" + } + outbound := option.Outbound{ + Type: C.TypeTUIC, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} + +func parseVMessLink(link string) (option.Outbound, error) { + var proxy map[string]string + reg := regexp.MustCompile(`(\"[^:,]+?\"[ \t]*:[ \t]*)(\d+|true|false)`) + s := reg.ReplaceAllString(link, `$1"$2"`) + err := json.Unmarshal([]byte(s[8:]), &proxy) + if err != nil { + proxy = make(map[string]string) + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing uuid") + } + proxy["id"] = linkURL.User.Username() + proxy["add"] = linkURL.Hostname() + proxy["port"] = linkURL.Port() + proxy["ps"] = linkURL.Fragment + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "type": + if value == "http" { + proxy["net"] = "tcp" + proxy["type"] = "http" + } + case "encryption": + proxy["scy"] = value + case "alterId": + proxy["aid"] = value + case "key", "alpn", "seed", "path", "host": + proxy[key] = value + default: + proxy[key] = value + } + } + } + outbound := option.Outbound{ + Type: C.TypeVMess, + } + options := option.VMessOutboundOptions{ + Security: "auto", + } + TLSOptions := option.OutboundTLSOptions{ + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + for key, value := range proxy { + switch key { + case "ps": + outbound.Tag = value + case "add": + options.Server = value + TLSOptions.ServerName = value + case "port": + options.ServerPort = StringToType[uint16](value) + case "id": + options.UUID = value + case "scy": + options.Security = value + case "aid": + options.AlterId, _ = strconv.Atoi(value) + case "packet_encoding": + options.PacketEncoding = value + case "xudp": + if value == "1" || value == "true" { + options.PacketEncoding = "xudp" + } + case "tls": + if value == "1" || value == "true" || value == "tls" { + TLSOptions.Enabled = true + } + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "net": + Transport := option.V2RayTransportOptions{ + Type: "", + WebsocketOptions: option.V2RayWebsocketOptions{ + Headers: badoption.HTTPHeader{}, + }, + HTTPOptions: option.V2RayHTTPOptions{ + Host: badoption.Listable[string]{}, + Headers: map[string]badoption.Listable[string]{}, + }, + GRPCOptions: option.V2RayGRPCOptions{}, + } + switch value { + case "ws": + Transport.Type = C.V2RayTransportTypeWebsocket + Transport.WebsocketOptions = v2rayTransportWs(proxy["host"], proxy["path"]) + case "h2": + Transport.Type = C.V2RayTransportTypeHTTP + TLSOptions.Enabled = true + if host, exists := proxy["host"]; exists && host != "" { + Transport.HTTPOptions.Host = []string{host} + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.HTTPOptions.Path = path + } + case "tcp": + if tType, exists := proxy["type"]; exists { + if tType != "http" { + continue + } + Transport.Type = C.V2RayTransportTypeHTTP + if method, exists := proxy["method"]; exists { + Transport.HTTPOptions.Method = method + } + if host, exists := proxy["host"]; exists && host != "" { + Transport.HTTPOptions.Host = []string{host} + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.HTTPOptions.Path = path + } + if headers, exists := proxy["headers"]; exists { + Transport.HTTPOptions.Headers = StringToType[badoption.HTTPHeader](headers) + } + } + case "grpc": + Transport.Type = C.V2RayTransportTypeGRPC + if host, exists := proxy["host"]; exists && host != "" { + Transport.GRPCOptions.ServiceName = host + } + default: + continue + } + options.Transport = &Transport + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + if TLSOptions.Enabled { + options.TLS = &TLSOptions + } + outbound.Options = &options + return outbound, nil +} + +func parseVLESSLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing uuid") + } + var options option.VLESSOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.UUID = linkURL.User.Username() + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = StringToType[uint16](linkURL.Port()) + proxy := map[string]string{} + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "key", "alpn", "seed", "path", "host": + proxy[key] = value + default: + proxy[key] = value + } + } + for key, value := range proxy { + switch key { + case "type": + Transport := option.V2RayTransportOptions{ + HTTPOptions: option.V2RayHTTPOptions{ + Host: badoption.Listable[string]{}, + Headers: badoption.HTTPHeader{}, + }, + GRPCOptions: option.V2RayGRPCOptions{}, + } + switch value { + case "ws": + Transport.Type = C.V2RayTransportTypeWebsocket + Transport.WebsocketOptions = v2rayTransportWs(proxy["host"], proxy["path"]) + case "http": + Transport.Type = C.V2RayTransportTypeHTTP + if host, exists := proxy["host"]; exists && host != "" { + Transport.HTTPOptions.Host = strings.Split(host, ",") + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.HTTPOptions.Path = path + } + case "grpc": + Transport.Type = C.V2RayTransportTypeGRPC + if serviceName, exists := proxy["serviceName"]; exists && serviceName != "" { + Transport.GRPCOptions.ServiceName = serviceName + } + default: + continue + } + options.Transport = &Transport + case "security": + if value == "tls" { + TLSOptions.Enabled = true + } else if value == "reality" { + TLSOptions.Enabled = true + TLSOptions.Reality.Enabled = true + } + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "serviceName", "sni", "peer": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "flow": + if value == "xtls-rprx-vision" { + options.Flow = "xtls-rprx-vision" + } + case "pbk": + TLSOptions.Reality.PublicKey = value + case "sid": + TLSOptions.Reality.ShortID = value + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeVLESS, + Tag: linkURL.Fragment, + } + if TLSOptions.Enabled { + options.TLS = &TLSOptions + } + outbound.Options = &options + return outbound, nil +} + +func parseTrojanLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing password") + } + var options option.TrojanOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = StringToType[uint16](linkURL.Port()) + options.Password = linkURL.User.Username() + proxy := map[string]string{} + for key, values := range linkURL.Query() { + value := values[0] + proxy[key] = value + } + for key, value := range proxy { + switch key { + case "insecure", "allowInsecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "serviceName", "sni", "peer": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "type": + Transport := option.V2RayTransportOptions{ + Type: "", + WebsocketOptions: option.V2RayWebsocketOptions{ + Headers: map[string]badoption.Listable[string]{}, + }, + HTTPOptions: option.V2RayHTTPOptions{ + Host: badoption.Listable[string]{}, + Headers: map[string]badoption.Listable[string]{}, + }, + GRPCOptions: option.V2RayGRPCOptions{}, + } + switch value { + case "ws": + Transport.Type = C.V2RayTransportTypeWebsocket + Transport.WebsocketOptions = v2rayTransportWs(proxy["host"], proxy["path"]) + case "grpc": + Transport.Type = C.V2RayTransportTypeGRPC + if serviceName, exists := proxy["grpc-service-name"]; exists && serviceName != "" { + Transport.GRPCOptions.ServiceName = serviceName + } + default: + continue + } + options.Transport = &Transport + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeTrojan, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} + +func parseHysteriaLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + var options option.HysteriaOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = StringToType[uint16](linkURL.Port()) + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "auth": + options.AuthString = value + case "peer", "sni": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "ca": + TLSOptions.CertificatePath = value + case "ca_str": + TLSOptions.Certificate = strings.Split(value, "\n") + case "up": + options.Up = &byteformats.NetworkBytesCompat{} + options.Up.UnmarshalJSON([]byte(value)) + case "up_mbps": + options.UpMbps, _ = strconv.Atoi(value) + case "down": + options.Down = &byteformats.NetworkBytesCompat{} + options.Down.UnmarshalJSON([]byte(value)) + case "down_mbps": + options.DownMbps, _ = strconv.Atoi(value) + case "obfs", "obfsParam": + options.Obfs = value + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeHysteria, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} + +func parseHysteria2Link(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + var options option.Hysteria2OutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + Obfs := &option.Hysteria2Obfs{} + options.ServerPort = uint16(443) + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + if linkURL.User != nil { + options.Password = linkURL.User.Username() + } + if linkURL.Port() != "" { + options.ServerPort = StringToType[uint16](linkURL.Port()) + } + for key, values := range linkURL.Query() { + value := values[0] + switch key { + case "up": + options.UpMbps, _ = strconv.Atoi(value) + case "down": + options.DownMbps, _ = strconv.Atoi(value) + case "obfs": + if value == "salamander" { + Obfs.Type = "salamander" + options.Obfs = Obfs + } + case "obfs-password": + Obfs.Password = value + case "insecure", "skip-cert-verify": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeHysteria2, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} + +func parseAnyTLSLink(link string) (option.Outbound, error) { + linkURL, err := url.Parse(link) + if err != nil { + return option.Outbound{}, err + } + if linkURL.User == nil || linkURL.User.Username() == "" { + return option.Outbound{}, E.New("missing password") + } + var options option.AnyTLSOutboundOptions + TLSOptions := option.OutboundTLSOptions{ + Enabled: true, + ECH: &option.OutboundECHOptions{}, + UTLS: &option.OutboundUTLSOptions{}, + Reality: &option.OutboundRealityOptions{}, + } + options.Server = linkURL.Hostname() + TLSOptions.ServerName = linkURL.Hostname() + options.ServerPort = StringToType[uint16](linkURL.Port()) + options.Password = linkURL.User.Username() + proxy := map[string]string{} + for key, values := range linkURL.Query() { + value := values[0] + proxy[key] = value + } + for key, value := range proxy { + switch key { + case "insecure": + if value == "1" || value == "true" { + TLSOptions.Insecure = true + } + case "sni": + TLSOptions.ServerName = value + case "alpn": + TLSOptions.ALPN = strings.Split(value, ",") + case "fp": + TLSOptions.UTLS.Enabled = true + TLSOptions.UTLS.Fingerprint = value + case "tfo", "tcp-fast-open", "tcp_fast_open": + if value == "1" || value == "true" { + options.TCPFastOpen = true + } + } + } + outbound := option.Outbound{ + Type: C.TypeAnyTLS, + Tag: linkURL.Fragment, + } + options.TLS = &TLSOptions + outbound.Options = &options + return outbound, nil +} diff --git a/provider/parser/parser.go b/provider/parser/parser.go new file mode 100644 index 0000000000..5345ec00f8 --- /dev/null +++ b/provider/parser/parser.go @@ -0,0 +1,213 @@ +package parser + +import ( + "context" + "reflect" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" +) + +var subscriptionParsers = []func(ctx context.Context, content string) ([]option.Outbound, error){ + ParseBoxSubscription, + ParseClashSubscription, + ParseSIP008Subscription, + ParseRawSubscription, +} + +func ParseSubscription(ctx context.Context, content string, overrideDialerOptions *option.OverrideDialerOptions, overrideTLSOptions *option.OverrideTLSOptions, providerTag string) ([]option.Outbound, error) { + var pErr error + for _, parser := range subscriptionParsers { + servers, err := parser(ctx, content) + if len(servers) > 0 { + return overrideOutbounds(servers, overrideDialerOptions, overrideTLSOptions, providerTag), nil + } + pErr = E.Errors(pErr, err) + } + return nil, E.Cause(pErr, "no servers found") +} + +func overrideOutbounds(outbounds []option.Outbound, overrideDialerOptions *option.OverrideDialerOptions, overrideTLSOptions *option.OverrideTLSOptions, providerTag string) []option.Outbound { + var tags []string + for _, outbound := range outbounds { + tags = append(tags, outbound.Tag) + } + var parsedOutbounds []option.Outbound + for _, outbound := range outbounds { + switch outbound.Type { + case C.TypeHTTP: + options := outbound.Options.(*option.HTTPOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) + outbound.Options = options + case C.TypeSOCKS: + options := outbound.Options.(*option.SOCKSOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + outbound.Options = options + case C.TypeTUIC: + options := outbound.Options.(*option.TUICOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) + outbound.Options = options + case C.TypeVMess: + options := outbound.Options.(*option.VMessOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) + outbound.Options = options + case C.TypeVLESS: + options := outbound.Options.(*option.VLESSOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) + outbound.Options = options + case C.TypeTrojan: + options := outbound.Options.(*option.TrojanOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) + outbound.Options = options + case C.TypeHysteria: + options := outbound.Options.(*option.HysteriaOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) + outbound.Options = options + case C.TypeShadowTLS: + options := outbound.Options.(*option.ShadowTLSOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) + outbound.Options = options + case C.TypeHysteria2: + options := outbound.Options.(*option.Hysteria2OutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) + outbound.Options = options + case C.TypeAnyTLS: + options := outbound.Options.(*option.AnyTLSOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + options.OutboundTLSOptionsContainer.TLS = overrideTLSOption(options.OutboundTLSOptionsContainer.TLS, overrideTLSOptions) + outbound.Options = options + case C.TypeShadowsocks: + options := outbound.Options.(*option.ShadowsocksOutboundOptions) + options.DialerOptions = overrideDialerOption(options.DialerOptions, overrideDialerOptions, tags, providerTag) + outbound.Options = options + } + parsedOutbounds = append(parsedOutbounds, outbound) + } + return parsedOutbounds +} + +func overrideDialerOption(options option.DialerOptions, overrideDialerOptions *option.OverrideDialerOptions, tags []string, providerTag string) option.DialerOptions { + if options.Detour != "" { + if common.Any(tags, func(tag string) bool { + return options.Detour == tag + }) { + if providerTag != "" { + options.Detour = providerTag + "/" + options.Detour + } + } else { + options.Detour = "" + } + } + var defaultOptions option.OverrideDialerOptions + if overrideDialerOptions == nil || reflect.DeepEqual(*overrideDialerOptions, defaultOptions) { + return options + } + if overrideDialerOptions.Detour != nil && options.Detour == "" { + options.Detour = *overrideDialerOptions.Detour + } + if overrideDialerOptions.BindInterface != nil { + options.BindInterface = *overrideDialerOptions.BindInterface + } + if overrideDialerOptions.Inet4BindAddress != nil { + options.Inet4BindAddress = overrideDialerOptions.Inet4BindAddress + } + if overrideDialerOptions.Inet6BindAddress != nil { + options.Inet6BindAddress = overrideDialerOptions.Inet6BindAddress + } + if overrideDialerOptions.ProtectPath != nil { + options.ProtectPath = *overrideDialerOptions.ProtectPath + } + if overrideDialerOptions.RoutingMark != nil { + options.RoutingMark = *overrideDialerOptions.RoutingMark + } + if overrideDialerOptions.ReuseAddr != nil { + options.ReuseAddr = *overrideDialerOptions.ReuseAddr + } + if overrideDialerOptions.ConnectTimeout != nil { + options.ConnectTimeout = *overrideDialerOptions.ConnectTimeout + } + if overrideDialerOptions.TCPFastOpen != nil { + options.TCPFastOpen = *overrideDialerOptions.TCPFastOpen + } + if overrideDialerOptions.TCPMultiPath != nil { + options.TCPMultiPath = *overrideDialerOptions.TCPMultiPath + } + if overrideDialerOptions.TCPKeepAlive != nil { + options.TCPKeepAlive = *overrideDialerOptions.TCPKeepAlive + } + if overrideDialerOptions.TCPKeepAliveInterval != nil { + options.TCPKeepAliveInterval = *overrideDialerOptions.TCPKeepAliveInterval + } + if overrideDialerOptions.UDPFragment != nil { + options.UDPFragment = overrideDialerOptions.UDPFragment + } + if overrideDialerOptions.DomainResolver != nil { + options.DomainResolver = overrideDialerOptions.DomainResolver + } + if overrideDialerOptions.NetworkStrategy != nil { + options.NetworkStrategy = overrideDialerOptions.NetworkStrategy + } + if overrideDialerOptions.NetworkType != nil { + options.NetworkType = *overrideDialerOptions.NetworkType + } + if overrideDialerOptions.FallbackNetworkType != nil { + options.FallbackNetworkType = *overrideDialerOptions.FallbackNetworkType + } + if overrideDialerOptions.FallbackDelay != nil { + options.FallbackDelay = *overrideDialerOptions.FallbackDelay + } + if overrideDialerOptions.TCPKeepAliveCount != nil { + options.TCPKeepAliveCount = *overrideDialerOptions.TCPKeepAliveCount + } + if overrideDialerOptions.DisableTCPKeepAlive != nil { + options.DisableTCPKeepAlive = *overrideDialerOptions.DisableTCPKeepAlive + } + + //nolint:staticcheck + if overrideDialerOptions.DomainStrategy != nil { + options.DomainStrategy = *overrideDialerOptions.DomainStrategy + } + return options +} + +func overrideTLSOption(options *option.OutboundTLSOptions, overrideTLSOptions *option.OverrideTLSOptions) *option.OutboundTLSOptions { + if options == nil { + return options + } + var defaultOptions option.OutboundTLSOptions + if overrideTLSOptions == nil || reflect.DeepEqual(*overrideTLSOptions, defaultOptions) { + return options + } + if overrideTLSOptions.Enabled != nil && !*overrideTLSOptions.Enabled { + return &defaultOptions + } + // if override.OverrideTLSOptions.Enabled != nil { + // options.Enabled = *override.OverrideTLSOptions.Enabled + // } + if overrideTLSOptions.DisableSNI != nil { + options.DisableSNI = *overrideTLSOptions.DisableSNI + } + if overrideTLSOptions.ServerName != nil { + options.ServerName = *overrideTLSOptions.ServerName + } + if overrideTLSOptions.Insecure != nil { + options.Insecure = *overrideTLSOptions.Insecure + } + if overrideTLSOptions.KernelTx != nil { + options.KernelTx = *overrideTLSOptions.KernelTx + } + if overrideTLSOptions.KernelRx != nil { + options.KernelRx = *overrideTLSOptions.KernelRx + } + return options +} diff --git a/provider/parser/raw.go b/provider/parser/raw.go new file mode 100644 index 0000000000..459de9d268 --- /dev/null +++ b/provider/parser/raw.go @@ -0,0 +1,49 @@ +package parser + +import ( + "context" + "encoding/base64" + "strings" + + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func ParseRawSubscription(ctx context.Context, content string) ([]option.Outbound, error) { + if base64Content, err := DecodeBase64URLSafe(content); err == nil { + servers, _ := parseRawSubscription(base64Content) + if len(servers) > 0 { + return servers, err + } + } + return parseRawSubscription(content) +} + +func parseRawSubscription(content string) ([]option.Outbound, error) { + var servers []option.Outbound + content = strings.ReplaceAll(content, "\r\n", "\n") + linkList := strings.Split(content, "\n") + for _, linkLine := range linkList { + server, err := ParseSubscriptionLink(linkLine) + if err != nil { + continue + } + servers = append(servers, server) + } + if len(servers) == 0 { + return nil, E.New("no servers found") + } + return servers, nil +} + +func DecodeBase64URLSafe(content string) (string, error) { + s := strings.ReplaceAll(content, " ", "-") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "+", "-") + s = strings.ReplaceAll(s, "=", "") + result, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return content, nil + } + return string(result), nil +} diff --git a/provider/parser/sing_box.go b/provider/parser/sing_box.go new file mode 100644 index 0000000000..c891f3f9d7 --- /dev/null +++ b/provider/parser/sing_box.go @@ -0,0 +1,58 @@ +package parser + +import ( + "context" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" +) + +type _SingBoxDocument struct { + Outbounds []option.Outbound `json:"outbounds"` +} +type SingBoxDocument _SingBoxDocument + +func (o *SingBoxDocument) UnmarshalJSONContext(ctx context.Context, inputContent []byte) error { + var content badjson.JSONObject + err := content.UnmarshalJSONContext(ctx, inputContent) + if err != nil { + return err + } + outbounds, ok := content.Get("outbounds") + if !ok { + return E.New("missing outbounds in sing-box configuration") + } + var outs badjson.JSONArray + for i, outbound := range outbounds.(badjson.JSONArray) { + typeVal, loaded := outbound.(*badjson.JSONObject).Get("type") + if !loaded { + return E.New("missing type in outbound[", i, "]") + } + switch typeVal.(string) { + case C.TypeDirect, C.TypeBlock, C.TypeDNS, C.TypeSelector, C.TypeURLTest, C.TypePass: + continue + default: + outs = append(outs, outbound) + } + } + content.Put("outbounds", outs) + inputContent, err = content.MarshalJSONContext(ctx) + if err != nil { + return err + } + return json.UnmarshalContext(ctx, inputContent, (*_SingBoxDocument)(o)) +} + +func ParseBoxSubscription(ctx context.Context, content string) ([]option.Outbound, error) { + options, err := json.UnmarshalExtendedContext[SingBoxDocument](ctx, []byte(content)) + if err != nil { + return nil, err + } + if len(options.Outbounds) == 0 { + return nil, E.New("no servers found") + } + return options.Outbounds, nil +} diff --git a/provider/parser/sip008.go b/provider/parser/sip008.go new file mode 100644 index 0000000000..9d07cd1b5d --- /dev/null +++ b/provider/parser/sip008.go @@ -0,0 +1,53 @@ +package parser + +import ( + "context" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" +) + +type ShadowsocksDocument struct { + Version int `json:"version"` + Servers []ShadowsocksServerDocument `json:"servers"` +} + +type ShadowsocksServerDocument struct { + ID string `json:"id"` + Remarks string `json:"remarks"` + Server string `json:"server"` + ServerPort int `json:"server_port"` + Password string `json:"password"` + Method string `json:"method"` + Plugin string `json:"plugin"` + PluginOpts string `json:"plugin_opts"` +} + +func ParseSIP008Subscription(_ context.Context, content string) ([]option.Outbound, error) { + var document ShadowsocksDocument + err := json.Unmarshal([]byte(content), &document) + if err != nil { + return nil, E.Cause(err, "parse SIP008 document") + } + + var servers []option.Outbound + for _, server := range document.Servers { + servers = append(servers, option.Outbound{ + Type: C.TypeShadowsocks, + Tag: server.Remarks, + Options: &option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: server.Server, + ServerPort: uint16(server.ServerPort), + }, + Password: server.Password, + Method: server.Method, + Plugin: server.Plugin, + PluginOptions: server.PluginOpts, + }, + }) + } + return servers, nil +} diff --git a/provider/remote/remote.go b/provider/remote/remote.go new file mode 100644 index 0000000000..010adf826f --- /dev/null +++ b/provider/remote/remote.go @@ -0,0 +1,470 @@ +package remote + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/provider" + "github.com/sagernet/sing-box/common/hash" + "github.com/sagernet/sing-box/common/interrupt" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/provider/parser" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/common/rw" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" +) + +func RegisterProvider(registry *provider.Registry) { + provider.Register[option.ProviderRemoteOptions](registry, C.ProviderTypeRemote, NewProviderRemote) +} + +var _ adapter.Provider = (*ProviderRemote)(nil) + +type ProviderRemote struct { + provider.Adapter + ctx context.Context + cancel context.CancelFunc + logger log.ContextLogger + outbound adapter.OutboundManager + provider adapter.ProviderManager + cacheFile adapter.CacheFile + dialer N.Dialer + hash hash.HashType + lastEtag string + lastOutOpts []option.Outbound + lastUpdated time.Time + subscriptionInfo adapter.SubscriptionInfo + ticker *time.Ticker + updating atomic.Bool + + url string + path string + userAgent string + downloadDetour string + updateInterval time.Duration + exclude *regexp.Regexp + include *regexp.Regexp + + overrideDialer *option.OverrideDialerOptions + overrideTLS *option.OverrideTLSOptions +} + +func NewProviderRemote(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.ProviderRemoteOptions) (adapter.Provider, error) { + if options.URL == "" { + return nil, E.New("provider URL is required") + } + var path string + if options.Path != "" { + path = filemanager.BasePath(ctx, options.Path) + path, _ = filepath.Abs(path) + } + if rw.IsDir(path) { + return nil, E.New("provider path is a directory: ", path) + } + updateInterval := time.Duration(options.UpdateInterval) + if updateInterval <= 0 { + updateInterval = 24 * time.Hour + } + if updateInterval < time.Hour { + updateInterval = time.Hour + } + var userAgent string + if options.UserAgent == "" { + userAgent = "sing-box " + C.Version + } else { + userAgent = options.UserAgent + } + ctx, cancel := context.WithCancel(ctx) + outbound := service.FromContext[adapter.OutboundManager](ctx) + logger := logFactory.NewLogger(F.ToString("provider/remote", "[", tag, "]")) + updateChan := make(chan struct{}) + close(updateChan) + return &ProviderRemote{ + Adapter: provider.NewAdapter(ctx, router, outbound, logFactory, logger, tag, C.ProviderTypeRemote, options.HealthCheck), + ctx: ctx, + cancel: cancel, + logger: logger, + outbound: outbound, + provider: service.FromContext[adapter.ProviderManager](ctx), + + url: options.URL, + path: path, + userAgent: userAgent, + downloadDetour: options.DownloadDetour, + updateInterval: updateInterval, + exclude: (*regexp.Regexp)(options.Exclude), + include: (*regexp.Regexp)(options.Include), + + overrideDialer: options.OverrideDialer, + overrideTLS: options.OverrideTLS, + }, nil +} + +func (s *ProviderRemote) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error { + s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx) + err := s.loadCacheFile() + if err != nil { + return E.Cause(err, "restore cached outbound provider") + } + if s.downloadDetour != "" { + outbound, loaded := s.outbound.Outbound(s.downloadDetour) + if !loaded { + return E.New("detour outbound not found: ", s.downloadDetour) + } + s.dialer = outbound + } else { + s.dialer = s.outbound.Default() + } + if s.lastUpdated.IsZero() { + ctx = interrupt.ContextWithIsProviderConnection(ctx) + err := s.fetch(ctx, startContext) + if err != nil { + return E.Cause(err, "initial outbound provider: ", s.Tag()) + } + } + go s.loopUpdate() + return s.Adapter.Start() +} + +func (s *ProviderRemote) Update() error { + if s.ticker != nil { + s.ticker.Reset(s.updateInterval) + } + ctx := interrupt.ContextWithIsProviderConnection(s.ctx) + return s.fetch(ctx, nil) +} + +func (s *ProviderRemote) UpdatedAt() time.Time { + return s.lastUpdated +} + +func (s *ProviderRemote) SubscriptionInfo() adapter.SubscriptionInfo { + return s.subscriptionInfo +} + +func (s *ProviderRemote) Close() error { + s.cancel() + if s.ticker != nil { + s.ticker.Stop() + } + return common.Close(&s.Adapter) +} + +func (s *ProviderRemote) updateOnce() { + ctx := interrupt.ContextWithIsProviderConnection(s.ctx) + if err := s.fetch(ctx, nil); err != nil { + s.logger.Error("update outbound provider: ", err) + } +} + +func (s *ProviderRemote) fetch(ctx context.Context, startContext *adapter.HTTPStartContext) error { + if s.updating.Swap(true) { + return E.New("provider is updating") + } + defer s.updating.Store(false) + s.logger.Debug("updating outbound provider ", s.Tag(), " from URL: ", s.url) + var client *http.Client + if startContext != nil { + client = startContext.HTTPClient(s.downloadDetour, s.dialer) + } else { + client = &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: C.TCPTimeout, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return s.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + TLSClientConfig: &tls.Config{ + Time: ntp.TimeFuncFromContext(ctx), + RootCAs: adapter.RootPoolFromContext(ctx), + }, + }, + } + } + req, err := http.NewRequest(http.MethodGet, s.url, nil) + if err != nil { + return err + } + if s.lastEtag != "" { + req.Header.Set("If-None-Match", s.lastEtag) + } + req.Header.Set("User-Agent", s.userAgent) + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return err + } + infoStr := resp.Header.Get("subscription-userinfo") + info, hasInfo := parseInfo(infoStr) + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotModified: + s.subscriptionInfo = info + s.lastUpdated = time.Now() + if s.cacheFile != nil { + saveSub := s.cacheFile.LoadSubscription(s.Tag()) + if saveSub != nil { + if s.path != "" { + saveSub.Hash = s.hash + } else if hasInfo { + index := bytes.IndexByte(saveSub.Content, '\n') + if index != -1 { + saveSub.Content = append([]byte(infoStr+"\n"), saveSub.Content[index+1:]...) + } + } + saveSub.LastUpdated = s.lastUpdated + if err := s.cacheFile.SaveSubscription(s.Tag(), saveSub); err != nil { + s.logger.Error("save outbound provider cache file: ", err) + } + } + } + if s.path != "" { + content, _ := json.Marshal(option.Options{ + Outbounds: s.lastOutOpts, + }) + s.saveCacheFile(hasInfo, info, content) + } + s.logger.Info("update outbound provider ", s.Tag(), ": not modified") + return nil + default: + return E.New("unexpected status: ", resp.Status) + } + defer resp.Body.Close() + contentRaw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + eTagHeader := resp.Header.Get("Etag") + if eTagHeader != "" { + s.lastEtag = eTagHeader + } + content, _ := parser.DecodeBase64URLSafe(string(contentRaw)) + if !hasInfo { + firstLine, others := getFirstLine(content) + if info, hasInfo = parseInfo(firstLine); hasInfo { + infoStr = firstLine + content, _ = parser.DecodeBase64URLSafe(others) + } + } + if err := s.updateProviderFromContent(content); err != nil { + return err + } + s.UpdateGroups() + s.subscriptionInfo = info + s.lastUpdated = time.Now() + if s.path != "" || s.cacheFile != nil { + content, _ := json.Marshal(option.Options{ + Outbounds: s.lastOutOpts, + }) + if s.path != "" { + s.saveCacheFile(hasInfo, info, content) + } else if hasInfo { + content = append([]byte(infoStr+"\n"), content...) + } + if s.cacheFile != nil { + saveSub := &adapter.SavedBinary{ + LastUpdated: s.lastUpdated, + LastEtag: s.lastEtag, + } + if s.path != "" { + saveSub.Hash = s.hash + } else { + saveSub.Content = content + } + if err = s.cacheFile.SaveSubscription(s.Tag(), saveSub); err != nil { + s.logger.Error("save outbound provider cache file: ", err) + } + } + } + s.logger.Info("updated outbound provider ", s.Tag()) + return nil +} + +func (s *ProviderRemote) loadCacheFile() error { + var content []byte + var lastUpdated time.Time + var lastEtag string + var saveSub *adapter.SavedBinary + if s.cacheFile != nil { + if saveSub = s.cacheFile.LoadSubscription(s.Tag()); saveSub != nil { + s.hash = saveSub.Hash + } + } + if s.path != "" { + exists, err := pathExists(s.path) + if err != nil { + return err + } + if !exists { + return nil + } + file, _ := os.Open(s.path) + content, err = io.ReadAll(file) + if err != nil { + return err + } + if saveSub != nil { + if !s.hash.Equal(hash.MakeHash(content)) { + s.logger.Error("load outbound provider cache file failed: validation failed") + return nil + } + lastUpdated = saveSub.LastUpdated + lastEtag = saveSub.LastEtag + } else { + fs, _ := file.Stat() + lastUpdated = fs.ModTime() + } + } else if saveSub != nil && saveSub.Content != nil { + content = saveSub.Content + lastUpdated = saveSub.LastUpdated + lastEtag = saveSub.LastEtag + } else { + return nil + } + if err := s.loadFromContent(content); err != nil { + return err + } + s.UpdateGroups() + s.lastUpdated, s.lastEtag = lastUpdated, lastEtag + return nil +} + +func (s *ProviderRemote) loadFromContent(contentRaw []byte) error { + content, _ := parser.DecodeBase64URLSafe(string(contentRaw)) + firstLine, others := getFirstLine(content) + if info, ok := parseInfo(firstLine); ok { + s.subscriptionInfo = info + content, _ = parser.DecodeBase64URLSafe(others) + } + outboundOpts, err := parser.ParseBoxSubscription(s.ctx, content) + if err != nil { + return err + } + s.UpdateOutbounds(s.lastOutOpts, outboundOpts) + s.lastOutOpts = outboundOpts + return nil +} + +func pathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func (s *ProviderRemote) loopUpdate() { + if time.Since(s.lastUpdated) < s.updateInterval { + select { + case <-s.ctx.Done(): + return + case <-time.After(time.Until(s.lastUpdated.Add(s.updateInterval))): + s.updateOnce() + } + } else { + s.updateOnce() + } + s.ticker = time.NewTicker(s.updateInterval) + for { + runtime.GC() + select { + case <-s.ctx.Done(): + return + case <-s.ticker.C: + s.updateOnce() + } + } +} + +func (s *ProviderRemote) saveCacheFile(hasInfo bool, info adapter.SubscriptionInfo, contentRaw []byte) { + content := contentRaw + if hasInfo { + infoStr := fmt.Sprint( + "# upload=", info.Upload, + "; download=", info.Download, + "; total=", info.Total, + "; expire=", info.Expire, + ";") + content = append([]byte(infoStr+"\n"), content...) + } + s.hash = hash.MakeHash(content) + dir := filepath.Dir(s.path) + if _, err := os.Stat(dir); os.IsNotExist(err) { + filemanager.MkdirAll(s.ctx, dir, 0o755) + } + filemanager.WriteFile(s.ctx, s.path, []byte(content), 0o666) +} + +func (s *ProviderRemote) updateProviderFromContent(content string) error { + outboundOpts, err := parser.ParseSubscription(s.ctx, content, s.overrideDialer, s.overrideTLS, s.Tag()) + if err != nil { + return err + } + outboundOpts = common.Filter(outboundOpts, func(it option.Outbound) bool { + return (s.exclude == nil || !s.exclude.MatchString(it.Tag)) && (s.include == nil || s.include.MatchString(it.Tag)) + }) + s.UpdateOutbounds(s.lastOutOpts, outboundOpts) + s.lastOutOpts = outboundOpts + return nil +} + +func getFirstLine(content string) (string, string) { + lines := strings.Split(content, "\n") + if len(lines) == 1 { + return lines[0], "" + } + others := strings.Join(lines[1:], "\n") + return lines[0], others +} + +func parseInfo(infoStr string) (adapter.SubscriptionInfo, bool) { + info := adapter.SubscriptionInfo{} + if infoStr == "" { + return info, false + } + reg := regexp.MustCompile(`(upload|download|total|expire)[\s\t]*=[\s\t]*(-?\d*);?`) + matches := reg.FindAllStringSubmatch(infoStr, 4) + if len(matches) == 0 { + return info, false + } + for _, match := range matches { + key, value := match[1], match[2] + switch key { + case "upload": + info.Upload = parser.StringToType[int64](value) + case "download": + info.Download = parser.StringToType[int64](value) + case "total": + info.Total = parser.StringToType[int64](value) + case "expire": + info.Expire = parser.StringToType[int64](value) + default: + return info, false + } + } + return info, true +} diff --git a/route/conn.go b/route/conn.go index 59afe5394c..e833f79c16 100644 --- a/route/conn.go +++ b/route/conn.go @@ -13,7 +13,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" - "github.com/sagernet/sing-box/common/tlsfragment" + tf "github.com/sagernet/sing-box/common/tlsfragment" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" @@ -111,6 +111,9 @@ func (m *ConnectionManager) NewConnection(ctx context.Context, this N.Dialer, co var dialerString string if outbound, isOutbound := this.(adapter.Outbound); isOutbound { dialerString = " using outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + if outbound.Type() == C.TypeLoadBalance { + dialerString += "[" + strings.Join(metadata.GetRealOutboundChain(), " -> ") + "]" + } } err = E.Cause(err, "open connection to ", remoteString, dialerString) N.CloseOnHandshakeFailure(conn, onClose, err) @@ -174,6 +177,9 @@ func (m *ConnectionManager) NewPacketConnection(ctx context.Context, this N.Dial var dialerString string if outbound, isOutbound := this.(adapter.Outbound); isOutbound { dialerString = " using outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + if outbound.Type() == C.TypeLoadBalance { + dialerString += "[" + strings.Join(metadata.GetRealOutboundChain(), " -> ") + "]" + } } err = E.Cause(err, "open packet connection to ", remoteString, dialerString) N.CloseOnHandshakeFailure(conn, onClose, err) @@ -197,6 +203,9 @@ func (m *ConnectionManager) NewPacketConnection(ctx context.Context, this N.Dial var dialerString string if outbound, isOutbound := this.(adapter.Outbound); isOutbound { dialerString = " using outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" + if outbound.Type() == C.TypeLoadBalance { + dialerString += "[" + strings.Join(metadata.GetRealOutboundChain(), " -> ") + "]" + } } err = E.Cause(err, "listen packet connection using ", dialerString) N.CloseOnHandshakeFailure(conn, onClose, err) diff --git a/route/neighbor_resolver_darwin.go b/route/neighbor_resolver_darwin.go new file mode 100644 index 0000000000..a8884ae628 --- /dev/null +++ b/route/neighbor_resolver_darwin.go @@ -0,0 +1,239 @@ +//go:build darwin + +package route + +import ( + "net" + "net/netip" + "os" + "sync" + "time" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "golang.org/x/net/route" + "golang.org/x/sys/unix" +) + +var defaultLeaseFiles = []string{ + "/var/db/dhcpd_leases", + "/tmp/dhcp.leases", +} + +type neighborResolver struct { + logger logger.ContextLogger + leaseFiles []string + access sync.RWMutex + neighborIPToMAC map[netip.Addr]net.HardwareAddr + leaseIPToMAC map[netip.Addr]net.HardwareAddr + ipToHostname map[netip.Addr]string + macToHostname map[string]string + watcher *fswatch.Watcher + done chan struct{} +} + +func newNeighborResolver(resolverLogger logger.ContextLogger, leaseFiles []string) (adapter.NeighborResolver, error) { + if len(leaseFiles) == 0 { + for _, path := range defaultLeaseFiles { + info, err := os.Stat(path) + if err == nil && info.Size() > 0 { + leaseFiles = append(leaseFiles, path) + } + } + } + return &neighborResolver{ + logger: resolverLogger, + leaseFiles: leaseFiles, + neighborIPToMAC: make(map[netip.Addr]net.HardwareAddr), + leaseIPToMAC: make(map[netip.Addr]net.HardwareAddr), + ipToHostname: make(map[netip.Addr]string), + macToHostname: make(map[string]string), + done: make(chan struct{}), + }, nil +} + +func (r *neighborResolver) Start() error { + err := r.loadNeighborTable() + if err != nil { + r.logger.Warn(E.Cause(err, "load neighbor table")) + } + r.doReloadLeaseFiles() + go r.subscribeNeighborUpdates() + if len(r.leaseFiles) > 0 { + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: r.leaseFiles, + Logger: r.logger, + Callback: func(_ string) { + r.doReloadLeaseFiles() + }, + }) + if err != nil { + r.logger.Warn(E.Cause(err, "create lease file watcher")) + } else { + r.watcher = watcher + err = watcher.Start() + if err != nil { + r.logger.Warn(E.Cause(err, "start lease file watcher")) + } + } + } + return nil +} + +func (r *neighborResolver) Close() error { + close(r.done) + if r.watcher != nil { + return r.watcher.Close() + } + return nil +} + +func (r *neighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) { + r.access.RLock() + defer r.access.RUnlock() + mac, found := r.neighborIPToMAC[address] + if found { + return mac, true + } + mac, found = r.leaseIPToMAC[address] + if found { + return mac, true + } + mac, found = extractMACFromEUI64(address) + if found { + return mac, true + } + return nil, false +} + +func (r *neighborResolver) LookupHostname(address netip.Addr) (string, bool) { + r.access.RLock() + defer r.access.RUnlock() + hostname, found := r.ipToHostname[address] + if found { + return hostname, true + } + mac, macFound := r.neighborIPToMAC[address] + if !macFound { + mac, macFound = r.leaseIPToMAC[address] + } + if !macFound { + mac, macFound = extractMACFromEUI64(address) + } + if macFound { + hostname, found = r.macToHostname[mac.String()] + if found { + return hostname, true + } + } + return "", false +} + +func (r *neighborResolver) loadNeighborTable() error { + entries, err := ReadNeighborEntries() + if err != nil { + return err + } + r.access.Lock() + defer r.access.Unlock() + for _, entry := range entries { + r.neighborIPToMAC[entry.Address] = entry.MACAddress + } + return nil +} + +func (r *neighborResolver) subscribeNeighborUpdates() { + routeSocket, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0) + if err != nil { + r.logger.Warn(E.Cause(err, "subscribe neighbor updates")) + return + } + err = unix.SetNonblock(routeSocket, true) + if err != nil { + unix.Close(routeSocket) + r.logger.Warn(E.Cause(err, "set route socket nonblock")) + return + } + routeSocketFile := os.NewFile(uintptr(routeSocket), "route") + defer routeSocketFile.Close() + buffer := buf.NewPacket() + defer buffer.Release() + for { + select { + case <-r.done: + return + default: + } + err = setReadDeadline(routeSocketFile, 3*time.Second) + if err != nil { + r.logger.Warn(E.Cause(err, "set route socket read deadline")) + return + } + n, err := routeSocketFile.Read(buffer.FreeBytes()) + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-r.done: + return + default: + } + r.logger.Warn(E.Cause(err, "receive neighbor update")) + continue + } + messages, err := route.ParseRIB(route.RIBTypeRoute, buffer.FreeBytes()[:n]) + if err != nil { + continue + } + for _, message := range messages { + routeMessage, isRouteMessage := message.(*route.RouteMessage) + if !isRouteMessage { + continue + } + if routeMessage.Flags&unix.RTF_LLINFO == 0 { + continue + } + address, mac, isDelete, ok := ParseRouteNeighborMessage(routeMessage) + if !ok { + continue + } + r.access.Lock() + if isDelete { + delete(r.neighborIPToMAC, address) + } else { + r.neighborIPToMAC[address] = mac + } + r.access.Unlock() + } + } +} + +func (r *neighborResolver) doReloadLeaseFiles() { + leaseIPToMAC, ipToHostname, macToHostname := ReloadLeaseFiles(r.leaseFiles) + r.access.Lock() + r.leaseIPToMAC = leaseIPToMAC + r.ipToHostname = ipToHostname + r.macToHostname = macToHostname + r.access.Unlock() +} + +func setReadDeadline(file *os.File, timeout time.Duration) error { + rawConn, err := file.SyscallConn() + if err != nil { + return err + } + var controlErr error + err = rawConn.Control(func(fd uintptr) { + tv := unix.NsecToTimeval(int64(timeout)) + controlErr = unix.SetsockoptTimeval(int(fd), unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv) + }) + if err != nil { + return err + } + return controlErr +} diff --git a/route/neighbor_resolver_lease.go b/route/neighbor_resolver_lease.go new file mode 100644 index 0000000000..e3f9c0b464 --- /dev/null +++ b/route/neighbor_resolver_lease.go @@ -0,0 +1,386 @@ +package route + +import ( + "bufio" + "encoding/hex" + "net" + "net/netip" + "os" + "strconv" + "strings" + "time" +) + +func parseLeaseFile(path string, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + file, err := os.Open(path) + if err != nil { + return + } + defer file.Close() + if strings.HasSuffix(path, "dhcpd_leases") { + parseBootpdLeases(file, ipToMAC, ipToHostname, macToHostname) + return + } + if strings.HasSuffix(path, "kea-leases4.csv") { + parseKeaCSV4(file, ipToMAC, ipToHostname, macToHostname) + return + } + if strings.HasSuffix(path, "kea-leases6.csv") { + parseKeaCSV6(file, ipToMAC, ipToHostname, macToHostname) + return + } + if strings.HasSuffix(path, "dhcpd.leases") { + parseISCDhcpd(file, ipToMAC, ipToHostname, macToHostname) + return + } + parseDnsmasqOdhcpd(file, ipToMAC, ipToHostname, macToHostname) +} + +func ReloadLeaseFiles(leaseFiles []string) (leaseIPToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + leaseIPToMAC = make(map[netip.Addr]net.HardwareAddr) + ipToHostname = make(map[netip.Addr]string) + macToHostname = make(map[string]string) + for _, path := range leaseFiles { + parseLeaseFile(path, leaseIPToMAC, ipToHostname, macToHostname) + } + return +} + +func parseDnsmasqOdhcpd(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + now := time.Now().Unix() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "duid ") { + continue + } + if strings.HasPrefix(line, "# ") { + parseOdhcpdLine(line[2:], ipToMAC, ipToHostname, macToHostname) + continue + } + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + expiry, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + continue + } + if expiry != 0 && expiry < now { + continue + } + if strings.Contains(fields[1], ":") { + mac, macErr := net.ParseMAC(fields[1]) + if macErr != nil { + continue + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[2])) + if !addrOK { + continue + } + address = address.Unmap() + ipToMAC[address] = mac + hostname := fields[3] + if hostname != "*" { + ipToHostname[address] = hostname + macToHostname[mac.String()] = hostname + } + } else { + var mac net.HardwareAddr + if len(fields) >= 5 { + duid, duidErr := parseDUID(fields[4]) + if duidErr == nil { + mac, _ = extractMACFromDUID(duid) + } + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[2])) + if !addrOK { + continue + } + address = address.Unmap() + if mac != nil { + ipToMAC[address] = mac + } + hostname := fields[3] + if hostname != "*" { + ipToHostname[address] = hostname + if mac != nil { + macToHostname[mac.String()] = hostname + } + } + } + } +} + +func parseOdhcpdLine(line string, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + fields := strings.Fields(line) + if len(fields) < 5 { + return + } + validTime, err := strconv.ParseInt(fields[4], 10, 64) + if err != nil { + return + } + if validTime == 0 { + return + } + if validTime > 0 && validTime < time.Now().Unix() { + return + } + hostname := fields[3] + if hostname == "-" || strings.HasPrefix(hostname, `broken\x20`) { + hostname = "" + } + if len(fields) >= 8 && fields[2] == "ipv4" { + mac, macErr := net.ParseMAC(fields[1]) + if macErr != nil { + return + } + addressField := fields[7] + slashIndex := strings.IndexByte(addressField, '/') + if slashIndex >= 0 { + addressField = addressField[:slashIndex] + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(addressField)) + if !addrOK { + return + } + address = address.Unmap() + ipToMAC[address] = mac + if hostname != "" { + ipToHostname[address] = hostname + macToHostname[mac.String()] = hostname + } + return + } + var mac net.HardwareAddr + duidHex := fields[1] + duidBytes, hexErr := hex.DecodeString(duidHex) + if hexErr == nil { + mac, _ = extractMACFromDUID(duidBytes) + } + for i := 7; i < len(fields); i++ { + addressField := fields[i] + slashIndex := strings.IndexByte(addressField, '/') + if slashIndex >= 0 { + addressField = addressField[:slashIndex] + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(addressField)) + if !addrOK { + continue + } + address = address.Unmap() + if mac != nil { + ipToMAC[address] = mac + } + if hostname != "" { + ipToHostname[address] = hostname + if mac != nil { + macToHostname[mac.String()] = hostname + } + } + } +} + +func parseISCDhcpd(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + scanner := bufio.NewScanner(file) + var currentIP netip.Addr + var currentMAC net.HardwareAddr + var currentHostname string + var currentActive bool + var inLease bool + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "lease ") && strings.HasSuffix(line, "{") { + ipString := strings.TrimSuffix(strings.TrimPrefix(line, "lease "), " {") + parsed, addrOK := netip.AddrFromSlice(net.ParseIP(ipString)) + if addrOK { + currentIP = parsed.Unmap() + inLease = true + currentMAC = nil + currentHostname = "" + currentActive = false + } + continue + } + if line == "}" && inLease { + if currentActive && currentMAC != nil { + ipToMAC[currentIP] = currentMAC + if currentHostname != "" { + ipToHostname[currentIP] = currentHostname + macToHostname[currentMAC.String()] = currentHostname + } + } else { + delete(ipToMAC, currentIP) + delete(ipToHostname, currentIP) + } + inLease = false + continue + } + if !inLease { + continue + } + if strings.HasPrefix(line, "hardware ethernet ") { + macString := strings.TrimSuffix(strings.TrimPrefix(line, "hardware ethernet "), ";") + parsed, macErr := net.ParseMAC(macString) + if macErr == nil { + currentMAC = parsed + } + } else if strings.HasPrefix(line, "client-hostname ") { + hostname := strings.TrimSuffix(strings.TrimPrefix(line, "client-hostname "), ";") + hostname = strings.Trim(hostname, "\"") + if hostname != "" { + currentHostname = hostname + } + } else if strings.HasPrefix(line, "binding state ") { + state := strings.TrimSuffix(strings.TrimPrefix(line, "binding state "), ";") + currentActive = state == "active" + } + } +} + +func parseKeaCSV4(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + scanner := bufio.NewScanner(file) + firstLine := true + for scanner.Scan() { + if firstLine { + firstLine = false + continue + } + fields := strings.Split(scanner.Text(), ",") + if len(fields) < 10 { + continue + } + if fields[9] != "0" { + continue + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[0])) + if !addrOK { + continue + } + address = address.Unmap() + mac, macErr := net.ParseMAC(fields[1]) + if macErr != nil { + continue + } + ipToMAC[address] = mac + hostname := "" + if len(fields) > 8 { + hostname = fields[8] + } + if hostname != "" { + ipToHostname[address] = hostname + macToHostname[mac.String()] = hostname + } + } +} + +func parseKeaCSV6(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + scanner := bufio.NewScanner(file) + firstLine := true + for scanner.Scan() { + if firstLine { + firstLine = false + continue + } + fields := strings.Split(scanner.Text(), ",") + if len(fields) < 14 { + continue + } + if fields[13] != "0" { + continue + } + address, addrOK := netip.AddrFromSlice(net.ParseIP(fields[0])) + if !addrOK { + continue + } + address = address.Unmap() + var mac net.HardwareAddr + if fields[12] != "" { + mac, _ = net.ParseMAC(fields[12]) + } + if mac == nil { + duid, duidErr := hex.DecodeString(strings.ReplaceAll(fields[1], ":", "")) + if duidErr == nil { + mac, _ = extractMACFromDUID(duid) + } + } + hostname := "" + if len(fields) > 11 { + hostname = fields[11] + } + if mac != nil { + ipToMAC[address] = mac + } + if hostname != "" { + ipToHostname[address] = hostname + if mac != nil { + macToHostname[mac.String()] = hostname + } + } + } +} + +func parseBootpdLeases(file *os.File, ipToMAC map[netip.Addr]net.HardwareAddr, ipToHostname map[netip.Addr]string, macToHostname map[string]string) { + now := time.Now().Unix() + scanner := bufio.NewScanner(file) + var currentName string + var currentIP netip.Addr + var currentMAC net.HardwareAddr + var currentLease int64 + var inBlock bool + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "{" { + inBlock = true + currentName = "" + currentIP = netip.Addr{} + currentMAC = nil + currentLease = 0 + continue + } + if line == "}" && inBlock { + if currentMAC != nil && currentIP.IsValid() { + if currentLease == 0 || currentLease >= now { + ipToMAC[currentIP] = currentMAC + if currentName != "" { + ipToHostname[currentIP] = currentName + macToHostname[currentMAC.String()] = currentName + } + } + } + inBlock = false + continue + } + if !inBlock { + continue + } + key, value, found := strings.Cut(line, "=") + if !found { + continue + } + switch key { + case "name": + currentName = value + case "ip_address": + parsed, addrOK := netip.AddrFromSlice(net.ParseIP(value)) + if addrOK { + currentIP = parsed.Unmap() + } + case "hw_address": + typeAndMAC, hasSep := strings.CutPrefix(value, "1,") + if hasSep { + mac, macErr := net.ParseMAC(typeAndMAC) + if macErr == nil { + currentMAC = mac + } + } + case "lease": + leaseHex := strings.TrimPrefix(value, "0x") + parsed, parseErr := strconv.ParseInt(leaseHex, 16, 64) + if parseErr == nil { + currentLease = parsed + } + } + } +} diff --git a/route/neighbor_resolver_linux.go b/route/neighbor_resolver_linux.go new file mode 100644 index 0000000000..b7991b4c89 --- /dev/null +++ b/route/neighbor_resolver_linux.go @@ -0,0 +1,224 @@ +//go:build linux + +package route + +import ( + "net" + "net/netip" + "os" + "slices" + "sync" + "time" + + "github.com/sagernet/fswatch" + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "github.com/jsimonetti/rtnetlink" + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +var defaultLeaseFiles = []string{ + "/tmp/dhcp.leases", + "/var/lib/dhcp/dhcpd.leases", + "/var/lib/dhcpd/dhcpd.leases", + "/var/lib/kea/kea-leases4.csv", + "/var/lib/kea/kea-leases6.csv", +} + +type neighborResolver struct { + logger logger.ContextLogger + leaseFiles []string + access sync.RWMutex + neighborIPToMAC map[netip.Addr]net.HardwareAddr + leaseIPToMAC map[netip.Addr]net.HardwareAddr + ipToHostname map[netip.Addr]string + macToHostname map[string]string + watcher *fswatch.Watcher + done chan struct{} +} + +func newNeighborResolver(resolverLogger logger.ContextLogger, leaseFiles []string) (adapter.NeighborResolver, error) { + if len(leaseFiles) == 0 { + for _, path := range defaultLeaseFiles { + info, err := os.Stat(path) + if err == nil && info.Size() > 0 { + leaseFiles = append(leaseFiles, path) + } + } + } + return &neighborResolver{ + logger: resolverLogger, + leaseFiles: leaseFiles, + neighborIPToMAC: make(map[netip.Addr]net.HardwareAddr), + leaseIPToMAC: make(map[netip.Addr]net.HardwareAddr), + ipToHostname: make(map[netip.Addr]string), + macToHostname: make(map[string]string), + done: make(chan struct{}), + }, nil +} + +func (r *neighborResolver) Start() error { + err := r.loadNeighborTable() + if err != nil { + r.logger.Warn(E.Cause(err, "load neighbor table")) + } + r.doReloadLeaseFiles() + go r.subscribeNeighborUpdates() + if len(r.leaseFiles) > 0 { + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: r.leaseFiles, + Logger: r.logger, + Callback: func(_ string) { + r.doReloadLeaseFiles() + }, + }) + if err != nil { + r.logger.Warn(E.Cause(err, "create lease file watcher")) + } else { + r.watcher = watcher + err = watcher.Start() + if err != nil { + r.logger.Warn(E.Cause(err, "start lease file watcher")) + } + } + } + return nil +} + +func (r *neighborResolver) Close() error { + close(r.done) + if r.watcher != nil { + return r.watcher.Close() + } + return nil +} + +func (r *neighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) { + r.access.RLock() + defer r.access.RUnlock() + mac, found := r.neighborIPToMAC[address] + if found { + return mac, true + } + mac, found = r.leaseIPToMAC[address] + if found { + return mac, true + } + mac, found = extractMACFromEUI64(address) + if found { + return mac, true + } + return nil, false +} + +func (r *neighborResolver) LookupHostname(address netip.Addr) (string, bool) { + r.access.RLock() + defer r.access.RUnlock() + hostname, found := r.ipToHostname[address] + if found { + return hostname, true + } + mac, macFound := r.neighborIPToMAC[address] + if !macFound { + mac, macFound = r.leaseIPToMAC[address] + } + if !macFound { + mac, macFound = extractMACFromEUI64(address) + } + if macFound { + hostname, found = r.macToHostname[mac.String()] + if found { + return hostname, true + } + } + return "", false +} + +func (r *neighborResolver) loadNeighborTable() error { + connection, err := rtnetlink.Dial(nil) + if err != nil { + return E.Cause(err, "dial rtnetlink") + } + defer connection.Close() + neighbors, err := connection.Neigh.List() + if err != nil { + return E.Cause(err, "list neighbors") + } + r.access.Lock() + defer r.access.Unlock() + for _, neigh := range neighbors { + if neigh.Attributes == nil { + continue + } + if neigh.Attributes.LLAddress == nil || len(neigh.Attributes.Address) == 0 { + continue + } + address, ok := netip.AddrFromSlice(neigh.Attributes.Address) + if !ok { + continue + } + r.neighborIPToMAC[address] = slices.Clone(neigh.Attributes.LLAddress) + } + return nil +} + +func (r *neighborResolver) subscribeNeighborUpdates() { + connection, err := netlink.Dial(unix.NETLINK_ROUTE, &netlink.Config{ + Groups: 1 << (unix.RTNLGRP_NEIGH - 1), + }) + if err != nil { + r.logger.Warn(E.Cause(err, "subscribe neighbor updates")) + return + } + defer connection.Close() + for { + select { + case <-r.done: + return + default: + } + err = connection.SetReadDeadline(time.Now().Add(3 * time.Second)) + if err != nil { + r.logger.Warn(E.Cause(err, "set netlink read deadline")) + return + } + messages, err := connection.Receive() + if err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + continue + } + select { + case <-r.done: + return + default: + } + r.logger.Warn(E.Cause(err, "receive neighbor update")) + continue + } + for _, message := range messages { + address, mac, isDelete, ok := ParseNeighborMessage(message) + if !ok { + continue + } + r.access.Lock() + if isDelete { + delete(r.neighborIPToMAC, address) + } else { + r.neighborIPToMAC[address] = mac + } + r.access.Unlock() + } + } +} + +func (r *neighborResolver) doReloadLeaseFiles() { + leaseIPToMAC, ipToHostname, macToHostname := ReloadLeaseFiles(r.leaseFiles) + r.access.Lock() + r.leaseIPToMAC = leaseIPToMAC + r.ipToHostname = ipToHostname + r.macToHostname = macToHostname + r.access.Unlock() +} diff --git a/route/neighbor_resolver_parse.go b/route/neighbor_resolver_parse.go new file mode 100644 index 0000000000..1979b7eabc --- /dev/null +++ b/route/neighbor_resolver_parse.go @@ -0,0 +1,50 @@ +package route + +import ( + "encoding/binary" + "encoding/hex" + "net" + "net/netip" + "slices" + "strings" +) + +func extractMACFromDUID(duid []byte) (net.HardwareAddr, bool) { + if len(duid) < 4 { + return nil, false + } + duidType := binary.BigEndian.Uint16(duid[0:2]) + hwType := binary.BigEndian.Uint16(duid[2:4]) + if hwType != 1 { + return nil, false + } + switch duidType { + case 1: + if len(duid) < 14 { + return nil, false + } + return net.HardwareAddr(slices.Clone(duid[8:14])), true + case 3: + if len(duid) < 10 { + return nil, false + } + return net.HardwareAddr(slices.Clone(duid[4:10])), true + } + return nil, false +} + +func extractMACFromEUI64(address netip.Addr) (net.HardwareAddr, bool) { + if !address.Is6() { + return nil, false + } + b := address.As16() + if b[11] != 0xff || b[12] != 0xfe { + return nil, false + } + return net.HardwareAddr{b[8] ^ 0x02, b[9], b[10], b[13], b[14], b[15]}, true +} + +func parseDUID(s string) ([]byte, error) { + cleaned := strings.ReplaceAll(s, ":", "") + return hex.DecodeString(cleaned) +} diff --git a/route/neighbor_resolver_platform.go b/route/neighbor_resolver_platform.go new file mode 100644 index 0000000000..ddb9a99592 --- /dev/null +++ b/route/neighbor_resolver_platform.go @@ -0,0 +1,84 @@ +package route + +import ( + "net" + "net/netip" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/logger" +) + +type platformNeighborResolver struct { + logger logger.ContextLogger + platform adapter.PlatformInterface + access sync.RWMutex + ipToMAC map[netip.Addr]net.HardwareAddr + ipToHostname map[netip.Addr]string + macToHostname map[string]string +} + +func newPlatformNeighborResolver(resolverLogger logger.ContextLogger, platform adapter.PlatformInterface) adapter.NeighborResolver { + return &platformNeighborResolver{ + logger: resolverLogger, + platform: platform, + ipToMAC: make(map[netip.Addr]net.HardwareAddr), + ipToHostname: make(map[netip.Addr]string), + macToHostname: make(map[string]string), + } +} + +func (r *platformNeighborResolver) Start() error { + return r.platform.StartNeighborMonitor(r) +} + +func (r *platformNeighborResolver) Close() error { + return r.platform.CloseNeighborMonitor(r) +} + +func (r *platformNeighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool) { + r.access.RLock() + defer r.access.RUnlock() + mac, found := r.ipToMAC[address] + if found { + return mac, true + } + return extractMACFromEUI64(address) +} + +func (r *platformNeighborResolver) LookupHostname(address netip.Addr) (string, bool) { + r.access.RLock() + defer r.access.RUnlock() + hostname, found := r.ipToHostname[address] + if found { + return hostname, true + } + mac, found := r.ipToMAC[address] + if !found { + mac, found = extractMACFromEUI64(address) + } + if !found { + return "", false + } + hostname, found = r.macToHostname[mac.String()] + return hostname, found +} + +func (r *platformNeighborResolver) UpdateNeighborTable(entries []adapter.NeighborEntry) { + ipToMAC := make(map[netip.Addr]net.HardwareAddr) + ipToHostname := make(map[netip.Addr]string) + macToHostname := make(map[string]string) + for _, entry := range entries { + ipToMAC[entry.Address] = entry.MACAddress + if entry.Hostname != "" { + ipToHostname[entry.Address] = entry.Hostname + macToHostname[entry.MACAddress.String()] = entry.Hostname + } + } + r.access.Lock() + r.ipToMAC = ipToMAC + r.ipToHostname = ipToHostname + r.macToHostname = macToHostname + r.access.Unlock() + r.logger.Info("updated neighbor table: ", len(entries), " entries") +} diff --git a/route/neighbor_resolver_stub.go b/route/neighbor_resolver_stub.go new file mode 100644 index 0000000000..177a1fccbc --- /dev/null +++ b/route/neighbor_resolver_stub.go @@ -0,0 +1,14 @@ +//go:build !linux && !darwin + +package route + +import ( + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/logger" +) + +func newNeighborResolver(_ logger.ContextLogger, _ []string) (adapter.NeighborResolver, error) { + return nil, os.ErrInvalid +} diff --git a/route/neighbor_table_darwin.go b/route/neighbor_table_darwin.go new file mode 100644 index 0000000000..8ca2d0f0b7 --- /dev/null +++ b/route/neighbor_table_darwin.go @@ -0,0 +1,104 @@ +//go:build darwin + +package route + +import ( + "net" + "net/netip" + "syscall" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/net/route" + "golang.org/x/sys/unix" +) + +func ReadNeighborEntries() ([]adapter.NeighborEntry, error) { + var entries []adapter.NeighborEntry + ipv4Entries, err := readNeighborEntriesAF(syscall.AF_INET) + if err != nil { + return nil, E.Cause(err, "read IPv4 neighbors") + } + entries = append(entries, ipv4Entries...) + ipv6Entries, err := readNeighborEntriesAF(syscall.AF_INET6) + if err != nil { + return nil, E.Cause(err, "read IPv6 neighbors") + } + entries = append(entries, ipv6Entries...) + return entries, nil +} + +func readNeighborEntriesAF(addressFamily int) ([]adapter.NeighborEntry, error) { + rib, err := route.FetchRIB(addressFamily, route.RIBType(syscall.NET_RT_FLAGS), syscall.RTF_LLINFO) + if err != nil { + return nil, err + } + messages, err := route.ParseRIB(route.RIBType(syscall.NET_RT_FLAGS), rib) + if err != nil { + return nil, err + } + var entries []adapter.NeighborEntry + for _, message := range messages { + routeMessage, isRouteMessage := message.(*route.RouteMessage) + if !isRouteMessage { + continue + } + address, macAddress, ok := parseRouteNeighborEntry(routeMessage) + if !ok { + continue + } + entries = append(entries, adapter.NeighborEntry{ + Address: address, + MACAddress: macAddress, + }) + } + return entries, nil +} + +func parseRouteNeighborEntry(message *route.RouteMessage) (address netip.Addr, macAddress net.HardwareAddr, ok bool) { + if len(message.Addrs) <= unix.RTAX_GATEWAY { + return + } + gateway, isLinkAddr := message.Addrs[unix.RTAX_GATEWAY].(*route.LinkAddr) + if !isLinkAddr || len(gateway.Addr) < 6 { + return + } + switch destination := message.Addrs[unix.RTAX_DST].(type) { + case *route.Inet4Addr: + address = netip.AddrFrom4(destination.IP) + case *route.Inet6Addr: + address = netip.AddrFrom16(destination.IP) + default: + return + } + macAddress = net.HardwareAddr(make([]byte, len(gateway.Addr))) + copy(macAddress, gateway.Addr) + ok = true + return +} + +func ParseRouteNeighborMessage(message *route.RouteMessage) (address netip.Addr, macAddress net.HardwareAddr, isDelete bool, ok bool) { + isDelete = message.Type == unix.RTM_DELETE + if len(message.Addrs) <= unix.RTAX_GATEWAY { + return + } + switch destination := message.Addrs[unix.RTAX_DST].(type) { + case *route.Inet4Addr: + address = netip.AddrFrom4(destination.IP) + case *route.Inet6Addr: + address = netip.AddrFrom16(destination.IP) + default: + return + } + if !isDelete { + gateway, isLinkAddr := message.Addrs[unix.RTAX_GATEWAY].(*route.LinkAddr) + if !isLinkAddr || len(gateway.Addr) < 6 { + return + } + macAddress = net.HardwareAddr(make([]byte, len(gateway.Addr))) + copy(macAddress, gateway.Addr) + } + ok = true + return +} diff --git a/route/neighbor_table_linux.go b/route/neighbor_table_linux.go new file mode 100644 index 0000000000..61a214fd3a --- /dev/null +++ b/route/neighbor_table_linux.go @@ -0,0 +1,68 @@ +//go:build linux + +package route + +import ( + "net" + "net/netip" + "slices" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/jsimonetti/rtnetlink" + "github.com/mdlayher/netlink" + "golang.org/x/sys/unix" +) + +func ReadNeighborEntries() ([]adapter.NeighborEntry, error) { + connection, err := rtnetlink.Dial(nil) + if err != nil { + return nil, E.Cause(err, "dial rtnetlink") + } + defer connection.Close() + neighbors, err := connection.Neigh.List() + if err != nil { + return nil, E.Cause(err, "list neighbors") + } + var entries []adapter.NeighborEntry + for _, neighbor := range neighbors { + if neighbor.Attributes == nil { + continue + } + if neighbor.Attributes.LLAddress == nil || len(neighbor.Attributes.Address) == 0 { + continue + } + address, ok := netip.AddrFromSlice(neighbor.Attributes.Address) + if !ok { + continue + } + entries = append(entries, adapter.NeighborEntry{ + Address: address, + MACAddress: slices.Clone(neighbor.Attributes.LLAddress), + }) + } + return entries, nil +} + +func ParseNeighborMessage(message netlink.Message) (address netip.Addr, macAddress net.HardwareAddr, isDelete bool, ok bool) { + var neighMessage rtnetlink.NeighMessage + err := neighMessage.UnmarshalBinary(message.Data) + if err != nil { + return + } + if neighMessage.Attributes == nil || len(neighMessage.Attributes.Address) == 0 { + return + } + address, ok = netip.AddrFromSlice(neighMessage.Attributes.Address) + if !ok { + return + } + isDelete = message.Header.Type == unix.RTM_DELNEIGH + if !isDelete && neighMessage.Attributes.LLAddress == nil { + ok = false + return + } + macAddress = slices.Clone(neighMessage.Attributes.LLAddress) + return +} diff --git a/route/route.go b/route/route.go index cdd7ba2509..f4f23fea2f 100644 --- a/route/route.go +++ b/route/route.go @@ -13,10 +13,10 @@ import ( "github.com/sagernet/sing-box/common/sniff" C "github.com/sagernet/sing-box/constant" R "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing-mux" - "github.com/sagernet/sing-tun" + mux "github.com/sagernet/sing-mux" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/ping" - "github.com/sagernet/sing-vmess" + vmess "github.com/sagernet/sing-vmess" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/bufio" @@ -150,6 +150,7 @@ func (r *Router) routeConnection(ctx context.Context, conn net.Conn, metadata ad for _, buffer := range buffers { conn = bufio.NewCachedConn(conn, buffer) } + metadata.InitExtended() for _, tracker := range r.trackers { conn = tracker.RoutedConnection(ctx, conn, metadata, selectedRule, selectedOutbound) } @@ -276,10 +277,11 @@ func (r *Router) routePacketConnection(ctx context.Context, conn N.PacketConn, m conn = bufio.NewCachedPacketConn(conn, buffer.Buffer, buffer.Destination) N.PutPacketBuffer(buffer) } + metadata.InitExtended() for _, tracker := range r.trackers { conn = tracker.RoutedPacketConnection(ctx, conn, metadata, selectedRule, selectedOutbound) } - if metadata.FakeIP { + if metadata.FakeIP || metadata.DestOverride { conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, metadata.Destination) } if outboundHandler, isHandler := selectedOutbound.(adapter.PacketConnectionHandlerEx); isHandler { @@ -439,6 +441,23 @@ func (r *Router) matchRule( metadata.ProcessInfo = processInfo } } + if r.neighborResolver != nil && metadata.SourceMACAddress == nil && metadata.Source.Addr.IsValid() { + mac, macFound := r.neighborResolver.LookupMAC(metadata.Source.Addr) + if macFound { + metadata.SourceMACAddress = mac + } + hostname, hostnameFound := r.neighborResolver.LookupHostname(metadata.Source.Addr) + if hostnameFound { + metadata.SourceHostname = hostname + if macFound { + r.logger.InfoContext(ctx, "found neighbor: ", mac, ", hostname: ", hostname) + } else { + r.logger.InfoContext(ctx, "found neighbor hostname: ", hostname) + } + } else if macFound { + r.logger.InfoContext(ctx, "found neighbor: ", mac) + } + } if metadata.Destination.Addr.IsValid() && r.dnsTransport.FakeIP() != nil && r.dnsTransport.FakeIP().Store().Contains(metadata.Destination.Addr) { domain, loaded := r.dnsTransport.FakeIP().Store().Lookup(metadata.Destination.Addr) if !loaded { @@ -469,6 +488,9 @@ func (r *Router) matchRule( match: for currentRuleIndex, currentRule := range r.rules { + if currentRule.Disabled() { + continue + } metadata.ResetRuleCache() if !currentRule.Match(metadata) { continue @@ -494,6 +516,14 @@ match: var routeOptions *R.RuleActionRouteOptions switch action := currentRule.Action().(type) { case *R.RuleActionRoute: + if selectedOutbound, loaded := r.outbound.Outbound(action.Outbound); loaded { + if selectedOutbound.Type() == C.TypeSelector { + selectedOutbound = selectedOutbound.(adapter.SelectorGroup).Selected() + } + if selectedOutbound.Type() == C.TypePass { + continue + } + } routeOptions = &action.RuleActionRouteOptions case *R.RuleActionRouteOptions: routeOptions = action @@ -565,6 +595,10 @@ match: selectedRuleIndex = currentRuleIndex break match } + case *R.RuleActionSniffOverrideDestination: + if metadata.SniffHost != "" { + r.actionSniffOverrideDestination(ctx, metadata, inputConn, inputPacketConn) + } case *R.RuleActionResolve: fatalErr = r.actionResolve(ctx, metadata, action) if fatalErr != nil { @@ -636,17 +670,10 @@ func (r *Router) actionSniff( metadata.SnifferNames = action.SnifferNames metadata.SniffError = err if err == nil { - //goland:noinspection GoDeprecation - if action.OverrideDestination && M.IsDomainName(metadata.Domain) { - metadata.Destination = M.Socksaddr{ - Fqdn: metadata.Domain, - Port: metadata.Destination.Port, - } - } - if metadata.Domain != "" && metadata.Client != "" { - r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol, ", domain: ", metadata.Domain, ", client: ", metadata.Client) - } else if metadata.Domain != "" { - r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol, ", domain: ", metadata.Domain) + if metadata.SniffHost != "" && metadata.Client != "" { + r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol, ", domain: ", metadata.SniffHost, ", client: ", metadata.Client) + } else if metadata.SniffHost != "" { + r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol, ", domain: ", metadata.SniffHost) } else { r.logger.DebugContext(ctx, "sniffed protocol: ", metadata.Protocol) } @@ -768,17 +795,10 @@ func (r *Router) actionSniff( } finally: if err == nil { - //goland:noinspection GoDeprecation - if action.OverrideDestination && M.IsDomainName(metadata.Domain) { - metadata.Destination = M.Socksaddr{ - Fqdn: metadata.Domain, - Port: metadata.Destination.Port, - } - } - if metadata.Domain != "" && metadata.Client != "" { - r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.Domain, ", client: ", metadata.Client) - } else if metadata.Domain != "" { - r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.Domain) + if metadata.SniffHost != "" && metadata.Client != "" { + r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.SniffHost, ", client: ", metadata.Client) + } else if metadata.SniffHost != "" { + r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", domain: ", metadata.SniffHost) } else if metadata.Client != "" { r.logger.DebugContext(ctx, "sniffed packet protocol: ", metadata.Protocol, ", client: ", metadata.Client) } else { @@ -789,6 +809,28 @@ func (r *Router) actionSniff( return } +func (r *Router) actionSniffOverrideDestination(ctx context.Context, metadata *adapter.InboundContext, inputConn net.Conn, inputPacketConn N.PacketConn) { + if inputConn != nil { + if !metadata.Destination.IsFqdn() && M.IsDomainName(metadata.SniffHost) { + metadata.Destination = M.Socksaddr{ + Fqdn: metadata.SniffHost, + Port: metadata.Destination.Port, + } + r.logger.DebugContext(ctx, "connection destination is overridden as ", metadata.SniffHost, ":", metadata.Destination.Port) + } + } else if inputPacketConn != nil { + if !metadata.Destination.IsFqdn() && M.IsDomainName(metadata.SniffHost) { + metadata.OriginDestination = metadata.Destination + metadata.Destination = M.Socksaddr{ + Fqdn: metadata.SniffHost, + Port: metadata.Destination.Port, + } + metadata.DestOverride = true + r.logger.DebugContext(ctx, "packet connection destination is overridden as ", metadata.SniffHost, ":", metadata.Destination.Port) + } + } +} + func (r *Router) actionResolve(ctx context.Context, metadata *adapter.InboundContext, action *R.RuleActionResolve) error { if metadata.Destination.IsFqdn() { var transport adapter.DNSTransport @@ -809,8 +851,43 @@ func (r *Router) actionResolve(ctx context.Context, metadata *adapter.InboundCon if err != nil { return err } - metadata.DestinationAddresses = addresses - r.logger.DebugContext(ctx, "resolved [", strings.Join(F.MapToString(metadata.DestinationAddresses), " "), "]") + if action.MatchOnly { + metadata.CacheIPs = addresses + r.logger.DebugContext(ctx, "resolved [", strings.Join(F.MapToString(metadata.CacheIPs), " "), "] for match only") + } else { + metadata.DestinationAddresses = addresses + r.logger.DebugContext(ctx, "resolved [", strings.Join(F.MapToString(metadata.DestinationAddresses), " "), "]") + } + if len(addresses) > 0 { + if isAllIPv4(addresses) { + metadata.IPVersion = 4 + } else if isAllIPv6(addresses) { + metadata.IPVersion = 6 + } + } } return nil } + +func isAllIPv4(addresses []netip.Addr) bool { + for _, addr := range addresses { + if !addr.Is4() { + return false + } + } + return true +} + +func isAllIPv6(addresses []netip.Addr) bool { + for _, addr := range addresses { + if !addr.Is6() { + return false + } + } + return true +} + +func (r *Router) Rule(uuid string) (adapter.Rule, bool) { + rule, exists := r.ruleByUUID[uuid] + return rule, exists +} diff --git a/route/router.go b/route/router.go index 5c73cb1c9f..8ba3b879c1 100644 --- a/route/router.go +++ b/route/router.go @@ -30,17 +30,24 @@ type Router struct { connection adapter.ConnectionManager network adapter.NetworkManager rules []adapter.Rule + ruleByUUID map[string]adapter.Rule needFindProcess bool + needFindNeighbor bool + leaseFiles []string ruleSets []adapter.RuleSet ruleSetMap map[string]adapter.RuleSet processSearcher process.Searcher + neighborResolver adapter.NeighborResolver pauseManager pause.Manager trackers []adapter.ConnectionTracker platformInterface adapter.PlatformInterface started bool + + defaultDomainMatchStrategy C.DomainMatchStrategy + reloadChan chan<- struct{} } -func NewRouter(ctx context.Context, logFactory log.Factory, options option.RouteOptions, dnsOptions option.DNSOptions) *Router { +func NewRouter(ctx context.Context, logFactory log.Factory, options option.RouteOptions, dnsOptions option.DNSOptions, reloadChan chan<- struct{}) *Router { return &Router{ ctx: ctx, logger: logFactory.NewLogger("router"), @@ -51,20 +58,31 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.Route connection: service.FromContext[adapter.ConnectionManager](ctx), network: service.FromContext[adapter.NetworkManager](ctx), rules: make([]adapter.Rule, 0, len(options.Rules)), + ruleByUUID: make(map[string]adapter.Rule), ruleSetMap: make(map[string]adapter.RuleSet), needFindProcess: hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess, + needFindNeighbor: hasRule(options.Rules, isNeighborRule) || hasDNSRule(dnsOptions.Rules, isNeighborDNSRule) || options.FindNeighbor, + leaseFiles: options.DHCPLeaseFiles, pauseManager: service.FromContext[pause.Manager](ctx), platformInterface: service.FromContext[adapter.PlatformInterface](ctx), + + defaultDomainMatchStrategy: C.DomainMatchStrategy(options.DefaultDomainMatchStrategy), + reloadChan: reloadChan, } } func (r *Router) Initialize(rules []option.Rule, ruleSets []option.RuleSet) error { + if r.defaultDomainMatchStrategy == C.DomainMatchStrategyFQDNOnly || r.defaultDomainMatchStrategy == C.DomainMatchStrategySniffHostOnly { + return E.New("default_domain_match_strategy cannot be fqdn_only or sniffhost_only") + } for i, options := range rules { rule, err := R.NewRule(r.ctx, r.logger, options, false) if err != nil { return E.Cause(err, "parse rule[", i, "]") } + uuid := rule.UUID() r.rules = append(r.rules, rule) + r.ruleByUUID[uuid] = rule } for i, options := range ruleSets { if _, exists := r.ruleSetMap[options.Tag]; exists { @@ -112,6 +130,7 @@ func (r *Router) Start(stage adapter.StartStage) error { } r.network.Initialize(r.ruleSets) needFindProcess := r.needFindProcess + needFindNeighbor := r.needFindNeighbor for _, ruleSet := range r.ruleSets { metadata := ruleSet.Metadata() if metadata.ContainsProcessRule { @@ -141,6 +160,36 @@ func (r *Router) Start(stage adapter.StartStage) error { } } } + r.needFindNeighbor = needFindNeighbor + if needFindNeighbor { + if r.platformInterface != nil && r.platformInterface.UsePlatformNeighborResolver() { + monitor.Start("initialize neighbor resolver") + resolver := newPlatformNeighborResolver(r.logger, r.platformInterface) + err := resolver.Start() + monitor.Finish() + if err != nil { + r.logger.Error(E.Cause(err, "start neighbor resolver")) + } else { + r.neighborResolver = resolver + } + } else { + monitor.Start("initialize neighbor resolver") + resolver, err := newNeighborResolver(r.logger, r.leaseFiles) + monitor.Finish() + if err != nil { + if err != os.ErrInvalid { + r.logger.Error(E.Cause(err, "create neighbor resolver")) + } + } else { + err = resolver.Start() + if err != nil { + r.logger.Error(E.Cause(err, "start neighbor resolver")) + } else { + r.neighborResolver = resolver + } + } + } + } case adapter.StartStatePostStart: for i, rule := range r.rules { monitor.Start("initialize rule[", i, "]") @@ -172,6 +221,13 @@ func (r *Router) Start(stage adapter.StartStage) error { func (r *Router) Close() error { monitor := taskmonitor.New(r.logger, C.StopTimeout) var err error + if r.neighborResolver != nil { + monitor.Start("close neighbor resolver") + err = E.Append(err, r.neighborResolver.Close(), func(closeErr error) error { + return E.Cause(closeErr, "close neighbor resolver") + }) + monitor.Finish() + } for i, rule := range r.rules { monitor.Start("close rule[", i, "]") err = E.Append(err, rule.Close(), func(err error) error { @@ -189,6 +245,10 @@ func (r *Router) Close() error { return err } +func (r *Router) RuleSets() []adapter.RuleSet { + return r.ruleSets +} + func (r *Router) RuleSet(tag string) (adapter.RuleSet, bool) { ruleSet, loaded := r.ruleSetMap[tag] return ruleSet, loaded @@ -206,7 +266,28 @@ func (r *Router) NeedFindProcess() bool { return r.needFindProcess } +func (r *Router) NeedFindNeighbor() bool { + return r.needFindNeighbor +} + +func (r *Router) NeighborResolver() adapter.NeighborResolver { + return r.neighborResolver +} + func (r *Router) ResetNetwork() { r.network.ResetNetwork() r.dns.ResetNetwork() } + +func (r *Router) DefaultDomainMatchStrategy() C.DomainMatchStrategy { + return r.defaultDomainMatchStrategy +} + +func (r *Router) Reload() { + if r.platformInterface == nil { + select { + case r.reloadChan <- struct{}{}: + default: + } + } +} diff --git a/route/rule/rule_abstract.go b/route/rule/rule_abstract.go index 45d5b8931f..f044304e43 100644 --- a/route/rule/rule_abstract.go +++ b/route/rule/rule_abstract.go @@ -10,7 +10,25 @@ import ( F "github.com/sagernet/sing/common/format" ) +type abstractRule struct { + disabled bool + uuid string +} + +func (r *abstractRule) Disabled() bool { + return r.disabled +} + +func (r *abstractRule) UUID() string { + return r.uuid +} + +func (r *abstractRule) ChangeStatus() { + r.disabled = !r.disabled +} + type abstractDefaultRule struct { + abstractRule items []RuleItem sourceAddressItems []RuleItem sourcePortItems []RuleItem @@ -19,6 +37,8 @@ type abstractDefaultRule struct { destinationPortItems []RuleItem allItems []RuleItem ruleSetItem RuleItem + domainMatchStrategy C.DomainMatchStrategy + ruleCount uint64 invert bool action adapter.RuleAction } @@ -27,6 +47,10 @@ func (r *abstractDefaultRule) Type() string { return C.RuleTypeDefault } +func (r *abstractDefaultRule) RuleCount() uint64 { + return r.ruleCount +} + func (r *abstractDefaultRule) Start() error { for _, item := range r.allItems { if starter, isStarter := item.(interface { @@ -149,16 +173,23 @@ func (r *abstractDefaultRule) String() string { } type abstractLogicalRule struct { - rules []adapter.HeadlessRule - mode string - invert bool - action adapter.RuleAction + abstractRule + rules []adapter.HeadlessRule + mode string + domainMatchStrategy C.DomainMatchStrategy + invert bool + action adapter.RuleAction + ruleCount uint64 } func (r *abstractLogicalRule) Type() string { return C.RuleTypeLogical } +func (r *abstractLogicalRule) RuleCount() uint64 { + return r.ruleCount +} + func (r *abstractLogicalRule) Start() error { for _, rule := range common.FilterIsInstance(r.rules, func(it adapter.HeadlessRule) (interface { Start() error diff --git a/route/rule/rule_abstract_test.go b/route/rule/rule_abstract_test.go index 2d2e8ba861..7b1f70e5ac 100644 --- a/route/rule/rule_abstract_test.go +++ b/route/rule/rule_abstract_test.go @@ -3,6 +3,7 @@ package rule import ( "context" "testing" + "time" "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" @@ -20,6 +21,22 @@ func (f *fakeRuleSet) Name() string { return "fake-rule-set" } +func (f *fakeRuleSet) Type() string { + return "fake" +} + +func (f *fakeRuleSet) Format() string { + return "fake" +} + +func (f *fakeRuleSet) UpdatedTime() time.Time { + return time.Time{} +} + +func (f *fakeRuleSet) Update(context.Context) error { + return nil +} + func (f *fakeRuleSet) StartContext(context.Context, *adapter.HTTPStartContext) error { return nil } @@ -56,6 +73,10 @@ func (f *fakeRuleSet) Match(*adapter.InboundContext) bool { return f.matched } +func (f *fakeRuleSet) RuleCount() uint64 { + return 1 +} + func (f *fakeRuleSet) String() string { return "fake-rule-set" } diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index cac814e765..e96ab071ea 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -13,7 +13,7 @@ import ( "github.com/sagernet/sing-box/common/sniff" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" @@ -105,6 +105,8 @@ func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action opti Timeout: time.Duration(action.SniffOptions.Timeout), } return sniffAction, sniffAction.build() + case C.RuleActionTypeSniffOverrideDestination: + return &RuleActionSniffOverrideDestination{}, nil case C.RuleActionTypeResolve: return &RuleActionResolve{ Server: action.ResolveOptions.Server, @@ -112,6 +114,7 @@ func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action opti DisableCache: action.ResolveOptions.DisableCache, RewriteTTL: action.ResolveOptions.RewriteTTL, ClientSubnet: action.ResolveOptions.ClientSubnet.Build(netip.Prefix{}), + MatchOnly: action.ResolveOptions.MatchOnly, }, nil default: panic(F.ToString("unknown rule action: ", action.Action)) @@ -130,6 +133,7 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) DisableCache: action.RouteOptions.DisableCache, RewriteTTL: action.RouteOptions.RewriteTTL, ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.RouteOptions.ClientSubnet)), + LazyCacheTTL: action.RouteOptions.LazyCacheTTL, }, } case C.RuleActionTypeRouteOptions: @@ -138,11 +142,13 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) DisableCache: action.RouteOptionsOptions.DisableCache, RewriteTTL: action.RouteOptionsOptions.RewriteTTL, ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.RouteOptionsOptions.ClientSubnet)), + LazyCacheTTL: action.RouteOptionsOptions.LazyCacheTTL, } case C.RuleActionTypeReject: return &RuleActionReject{ - Method: action.RejectOptions.Method, - NoDrop: action.RejectOptions.NoDrop, + Rcode: action.DNSRejectOptions.Rcode.Build(), + Method: action.DNSRejectOptions.Method, + NoDrop: action.DNSRejectOptions.NoDrop, logger: logger, } case C.RuleActionTypePredefined: @@ -285,6 +291,7 @@ type RuleActionDNSRouteOptions struct { DisableCache bool RewriteTTL *uint32 ClientSubnet netip.Prefix + LazyCacheTTL *uint32 } func (r *RuleActionDNSRouteOptions) Type() string { @@ -353,6 +360,7 @@ func IsBypassed(err error) bool { } type RuleActionReject struct { + Rcode int Method string NoDrop bool logger logger.ContextLogger @@ -417,8 +425,6 @@ type RuleActionSniff struct { StreamSniffers []sniff.StreamSniffer PacketSniffers []sniff.PacketSniffer Timeout time.Duration - // Deprecated - OverrideDestination bool } func (r *RuleActionSniff) Type() string { @@ -470,12 +476,23 @@ func (r *RuleActionSniff) String() string { } } +type RuleActionSniffOverrideDestination struct{} + +func (r *RuleActionSniffOverrideDestination) Type() string { + return C.RuleActionTypeSniffOverrideDestination +} + +func (r *RuleActionSniffOverrideDestination) String() string { + return "sniff-override-destination" +} + type RuleActionResolve struct { Server string Strategy C.DomainStrategy DisableCache bool RewriteTTL *uint32 ClientSubnet netip.Prefix + MatchOnly bool } func (r *RuleActionResolve) Type() string { @@ -499,6 +516,9 @@ func (r *RuleActionResolve) String() string { if r.ClientSubnet.IsValid() { options = append(options, F.ToString("client_subnet=", r.ClientSubnet)) } + if r.MatchOnly { + options = append(options, "match_only") + } if len(options) == 0 { return "resolve" } else { diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 202fb3b36d..d7af79ec5a 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -10,6 +10,8 @@ import ( "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service" + + "github.com/gofrs/uuid/v5" ) func NewRule(ctx context.Context, logger log.ContextLogger, options option.Rule, checkOutbound bool) (adapter.Rule, error) { @@ -57,14 +59,22 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio if err != nil { return nil, E.Cause(err, "action") } + id, _ := uuid.NewV4() rule := &DefaultRule{ abstractDefaultRule{ + domainMatchStrategy: C.DomainMatchStrategy(options.DomainMatchStrategy), + abstractRule: abstractRule{ + uuid: id.String(), + }, invert: options.Invert, action: action, }, } router := service.FromContext[adapter.Router](ctx) networkManager := service.FromContext[adapter.NetworkManager](ctx) + if rule.domainMatchStrategy == C.DomainMatchStrategyAsIS { + rule.domainMatchStrategy = router.DefaultDomainMatchStrategy() + } if len(options.Inbound) > 0 { item := NewInboundRule(options.Inbound) rule.items = append(rule.items, item) @@ -101,7 +111,7 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio rule.allItems = append(rule.allItems, item) } if len(options.Domain) > 0 || len(options.DomainSuffix) > 0 { - item, err := NewDomainItem(options.Domain, options.DomainSuffix) + item, err := NewDomainItem(options.Domain, options.DomainSuffix, rule.domainMatchStrategy) if err != nil { return nil, err } @@ -109,12 +119,12 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio rule.allItems = append(rule.allItems, item) } if len(options.DomainKeyword) > 0 { - item := NewDomainKeywordItem(options.DomainKeyword) + item := NewDomainKeywordItem(options.DomainKeyword, rule.domainMatchStrategy) rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } if len(options.DomainRegex) > 0 { - item, err := NewDomainRegexItem(options.DomainRegex) + item, err := NewDomainRegexItem(options.DomainRegex, rule.domainMatchStrategy) if err != nil { return nil, err } @@ -215,7 +225,7 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } - if options.ClashMode != "" { + if len(options.ClashMode) > 0 { item := NewClashModeItem(ctx, options.ClashMode) rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) @@ -260,8 +270,18 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } + if len(options.SourceMACAddress) > 0 { + item := NewSourceMACAddressItem(options.SourceMACAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourceHostname) > 0 { + item := NewSourceHostnameItem(options.SourceHostname) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } if len(options.PreferredBy) > 0 { - item := NewPreferredByItem(ctx, options.PreferredBy) + item := NewPreferredByItem(ctx, options.PreferredBy, rule.domainMatchStrategy) rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } @@ -292,13 +312,22 @@ func NewLogicalRule(ctx context.Context, logger log.ContextLogger, options optio if err != nil { return nil, E.Cause(err, "action") } + id, _ := uuid.NewV4() rule := &LogicalRule{ abstractLogicalRule{ - rules: make([]adapter.HeadlessRule, len(options.Rules)), - invert: options.Invert, - action: action, + abstractRule: abstractRule{ + uuid: id.String(), + }, + rules: make([]adapter.HeadlessRule, len(options.Rules)), + domainMatchStrategy: C.DomainMatchStrategy(options.DomainMatchStrategy), + invert: options.Invert, + action: action, }, } + router := service.FromContext[adapter.Router](ctx) + if rule.domainMatchStrategy == C.DomainMatchStrategyAsIS { + rule.domainMatchStrategy = router.DefaultDomainMatchStrategy() + } switch options.Mode { case C.LogicalTypeAnd: rule.mode = C.LogicalTypeAnd diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 9235dd6fd9..6da8815f97 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -10,6 +10,8 @@ import ( "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service" + + "github.com/gofrs/uuid/v5" ) func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DNSRule, checkServer bool) (adapter.DNSRule, error) { @@ -48,8 +50,12 @@ type DefaultDNSRule struct { } func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options option.DefaultDNSRule) (*DefaultDNSRule, error) { + id, _ := uuid.NewV4() rule := &DefaultDNSRule{ abstractDefaultRule: abstractDefaultRule{ + abstractRule: abstractRule{ + uuid: id.String(), + }, invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), }, @@ -92,7 +98,7 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.allItems = append(rule.allItems, item) } if len(options.Domain) > 0 || len(options.DomainSuffix) > 0 { - item, err := NewDomainItem(options.Domain, options.DomainSuffix) + item, err := NewDomainItem(options.Domain, options.DomainSuffix, C.DomainMatchStrategyAsIS) if err != nil { return nil, err } @@ -100,12 +106,12 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.allItems = append(rule.allItems, item) } if len(options.DomainKeyword) > 0 { - item := NewDomainKeywordItem(options.DomainKeyword) + item := NewDomainKeywordItem(options.DomainKeyword, C.DomainMatchStrategyAsIS) rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } if len(options.DomainRegex) > 0 { - item, err := NewDomainRegexItem(options.DomainRegex) + item, err := NewDomainRegexItem(options.DomainRegex, C.DomainMatchStrategyAsIS) if err != nil { return nil, E.Cause(err, "domain_regex") } @@ -216,7 +222,7 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } - if options.ClashMode != "" { + if len(options.ClashMode) > 0 { item := NewClashModeItem(ctx, options.ClashMode) rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) @@ -261,6 +267,16 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } + if len(options.SourceMACAddress) > 0 { + item := NewSourceMACAddressItem(options.SourceMACAddress) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.SourceHostname) > 0 { + item := NewSourceHostnameItem(options.SourceHostname) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } if len(options.RuleSet) > 0 { //nolint:staticcheck if options.Deprecated_RulesetIPCIDRMatchSource { @@ -316,8 +332,12 @@ type LogicalDNSRule struct { } func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options option.LogicalDNSRule) (*LogicalDNSRule, error) { + id, _ := uuid.NewV4() r := &LogicalDNSRule{ abstractLogicalRule: abstractLogicalRule{ + abstractRule: abstractRule{ + uuid: id.String(), + }, rules: make([]adapter.HeadlessRule, len(options.Rules)), invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), diff --git a/route/rule/rule_headless.go b/route/rule/rule_headless.go index 689e6e3ecc..5d2a7a25c2 100644 --- a/route/rule/rule_headless.go +++ b/route/rule/rule_headless.go @@ -38,33 +38,38 @@ func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessR networkManager := service.FromContext[adapter.NetworkManager](ctx) rule := &DefaultHeadlessRule{ abstractDefaultRule{ - invert: options.Invert, + domainMatchStrategy: C.DomainMatchStrategy(options.DomainMatchStrategy), + invert: options.Invert, }, } + router := service.FromContext[adapter.Router](ctx) + if rule.domainMatchStrategy == C.DomainMatchStrategyAsIS { + rule.domainMatchStrategy = router.DefaultDomainMatchStrategy() + } if len(options.Network) > 0 { item := NewNetworkItem(options.Network) rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } if len(options.Domain) > 0 || len(options.DomainSuffix) > 0 { - item, err := NewDomainItem(options.Domain, options.DomainSuffix) + item, err := NewDomainItem(options.Domain, options.DomainSuffix, rule.domainMatchStrategy) if err != nil { return nil, err } rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } else if options.DomainMatcher != nil { - item := NewRawDomainItem(options.DomainMatcher) + item := NewRawDomainItem(options.DomainMatcher, rule.domainMatchStrategy) rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } if len(options.DomainKeyword) > 0 { - item := NewDomainKeywordItem(options.DomainKeyword) + item := NewDomainKeywordItem(options.DomainKeyword, rule.domainMatchStrategy) rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } if len(options.DomainRegex) > 0 { - item, err := NewDomainRegexItem(options.DomainRegex) + item, err := NewDomainRegexItem(options.DomainRegex, rule.domainMatchStrategy) if err != nil { return nil, E.Cause(err, "domain_regex") } @@ -182,14 +187,26 @@ func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessR } } if len(options.AdGuardDomain) > 0 { - item := NewAdGuardDomainItem(options.AdGuardDomain) + item := NewAdGuardDomainItem(options.AdGuardDomain, rule.domainMatchStrategy) rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } else if options.AdGuardDomainMatcher != nil { - item := NewRawAdGuardDomainItem(options.AdGuardDomainMatcher) + item := NewRawAdGuardDomainItem(options.AdGuardDomainMatcher, rule.domainMatchStrategy) rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } + switch true { + case len(rule.allItems) == len(rule.destinationAddressItems)+len(rule.destinationIPCIDRItems): + rule.ruleCount = uint64(len(rule.destinationAddressItems) + len(rule.destinationIPCIDRItems)) + case len(rule.allItems) == len(rule.sourceAddressItems): + rule.ruleCount = uint64(len(rule.sourceAddressItems)) + case len(rule.allItems) == len(rule.sourcePortItems): + rule.ruleCount = uint64(len(rule.sourcePortItems)) + case len(rule.allItems) == len(rule.destinationPortItems): + rule.ruleCount = uint64(len(rule.destinationPortItems)) + default: + rule.ruleCount = 1 + } return rule, nil } @@ -202,10 +219,15 @@ type LogicalHeadlessRule struct { func NewLogicalHeadlessRule(ctx context.Context, options option.LogicalHeadlessRule) (*LogicalHeadlessRule, error) { r := &LogicalHeadlessRule{ abstractLogicalRule{ - rules: make([]adapter.HeadlessRule, len(options.Rules)), - invert: options.Invert, + rules: make([]adapter.HeadlessRule, len(options.Rules)), + domainMatchStrategy: C.DomainMatchStrategy(options.DomainMatchStrategy), + invert: options.Invert, }, } + router := service.FromContext[adapter.Router](ctx) + if r.domainMatchStrategy == C.DomainMatchStrategyAsIS { + r.domainMatchStrategy = router.DefaultDomainMatchStrategy() + } switch options.Mode { case C.LogicalTypeAnd: r.mode = C.LogicalTypeAnd @@ -221,5 +243,6 @@ func NewLogicalHeadlessRule(ctx context.Context, options option.LogicalHeadlessR } r.rules[i] = rule } + r.ruleCount = 1 return r, nil } diff --git a/route/rule/rule_item_adguard.go b/route/rule/rule_item_adguard.go index 84252e606d..f74eb58561 100644 --- a/route/rule/rule_item_adguard.go +++ b/route/rule/rule_item_adguard.go @@ -4,33 +4,58 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common/domain" ) var _ RuleItem = (*AdGuardDomainItem)(nil) type AdGuardDomainItem struct { - matcher *domain.AdGuardMatcher + matcher *domain.AdGuardMatcher + domainMatchStrategy C.DomainMatchStrategy } -func NewAdGuardDomainItem(ruleLines []string) *AdGuardDomainItem { +func NewAdGuardDomainItem(ruleLines []string, domainMatchStrategy C.DomainMatchStrategy) *AdGuardDomainItem { return &AdGuardDomainItem{ domain.NewAdGuardMatcher(ruleLines), + domainMatchStrategy, } } -func NewRawAdGuardDomainItem(matcher *domain.AdGuardMatcher) *AdGuardDomainItem { +func NewRawAdGuardDomainItem(matcher *domain.AdGuardMatcher, domainMatchStrategy C.DomainMatchStrategy) *AdGuardDomainItem { return &AdGuardDomainItem{ matcher, + domainMatchStrategy, } } func (r *AdGuardDomainItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.Domain != "" { - domainHost = metadata.Domain - } else { - domainHost = metadata.Destination.Fqdn + switch r.domainMatchStrategy { + case C.DomainMatchStrategyPreferFQDN: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else { + domainHost = metadata.Domain + } + case C.DomainMatchStrategyFQDNOnly: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } + case C.DomainMatchStrategySniffHostOnly: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } + default: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain + } } if domainHost == "" { return false diff --git a/route/rule/rule_item_cidr.go b/route/rule/rule_item_cidr.go index c823dcf30a..f4d70775f8 100644 --- a/route/rule/rule_item_cidr.go +++ b/route/rule/rule_item_cidr.go @@ -79,12 +79,19 @@ func (r *IPCIDRItem) Match(metadata *adapter.InboundContext) bool { if metadata.Destination.IsIP() { return r.ipSet.Contains(metadata.Destination.Addr) } - if len(metadata.DestinationAddresses) > 0 { + if len(metadata.DestinationAddresses) > 0 || len(metadata.CacheIPs) > 0 { for _, address := range metadata.DestinationAddresses { if r.ipSet.Contains(address) { return true } } + if len(metadata.CacheIPs) > 0 { + for _, address := range metadata.CacheIPs { + if r.ipSet.Contains(address) { + return true + } + } + } return false } return metadata.IPCIDRAcceptEmpty diff --git a/route/rule/rule_item_clash_mode.go b/route/rule/rule_item_clash_mode.go index fe2347a06f..98eed6b5c6 100644 --- a/route/rule/rule_item_clash_mode.go +++ b/route/rule/rule_item_clash_mode.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common" "github.com/sagernet/sing/service" ) @@ -13,13 +14,13 @@ var _ RuleItem = (*ClashModeItem)(nil) type ClashModeItem struct { ctx context.Context clashServer adapter.ClashServer - mode string + modes []string } -func NewClashModeItem(ctx context.Context, mode string) *ClashModeItem { +func NewClashModeItem(ctx context.Context, modes []string) *ClashModeItem { return &ClashModeItem{ - ctx: ctx, - mode: mode, + ctx: ctx, + modes: modes, } } @@ -32,9 +33,15 @@ func (r *ClashModeItem) Match(metadata *adapter.InboundContext) bool { if r.clashServer == nil { return false } - return strings.EqualFold(r.clashServer.Mode(), r.mode) + return common.Any(r.modes, func(mode string) bool { + return strings.EqualFold(r.clashServer.Mode(), mode) + }) } func (r *ClashModeItem) String() string { - return "clash_mode=" + r.mode + modeStr := r.modes[0] + if len(r.modes) > 1 { + modeStr = "[" + strings.Join(r.modes, ", ") + "]" + } + return "clash_mode=" + modeStr } diff --git a/route/rule/rule_item_domain.go b/route/rule/rule_item_domain.go index af790aa385..762d9e4b30 100644 --- a/route/rule/rule_item_domain.go +++ b/route/rule/rule_item_domain.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common/domain" E "github.com/sagernet/sing/common/exceptions" ) @@ -11,11 +12,12 @@ import ( var _ RuleItem = (*DomainItem)(nil) type DomainItem struct { - matcher *domain.Matcher - description string + matcher *domain.Matcher + description string + domainMatchStrategy C.DomainMatchStrategy } -func NewDomainItem(domains []string, domainSuffixes []string) (*DomainItem, error) { +func NewDomainItem(domains []string, domainSuffixes []string, domainMatchStrategy C.DomainMatchStrategy) (*DomainItem, error) { for _, domainItem := range domains { if domainItem == "" { return nil, E.New("domain: empty item is not allowed") @@ -51,22 +53,45 @@ func NewDomainItem(domains []string, domainSuffixes []string) (*DomainItem, erro return &DomainItem{ domain.NewMatcher(domains, domainSuffixes, false), description, + domainMatchStrategy, }, nil } -func NewRawDomainItem(matcher *domain.Matcher) *DomainItem { +func NewRawDomainItem(matcher *domain.Matcher, domainMatchStrategy C.DomainMatchStrategy) *DomainItem { return &DomainItem{ matcher, "domain/domain_suffix=", + domainMatchStrategy, } } func (r *DomainItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.Domain != "" { - domainHost = metadata.Domain - } else { - domainHost = metadata.Destination.Fqdn + switch r.domainMatchStrategy { + case C.DomainMatchStrategyPreferFQDN: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else { + domainHost = metadata.Domain + } + case C.DomainMatchStrategyFQDNOnly: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } + case C.DomainMatchStrategySniffHostOnly: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } + default: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain + } } if domainHost == "" { return false diff --git a/route/rule/rule_item_domain_keyword.go b/route/rule/rule_item_domain_keyword.go index 6e19a10ccd..5ca429ea1b 100644 --- a/route/rule/rule_item_domain_keyword.go +++ b/route/rule/rule_item_domain_keyword.go @@ -4,24 +4,47 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" ) var _ RuleItem = (*DomainKeywordItem)(nil) type DomainKeywordItem struct { - keywords []string + keywords []string + domainMatchStrategy C.DomainMatchStrategy } -func NewDomainKeywordItem(keywords []string) *DomainKeywordItem { - return &DomainKeywordItem{keywords} +func NewDomainKeywordItem(keywords []string, domainMatchStrategy C.DomainMatchStrategy) *DomainKeywordItem { + return &DomainKeywordItem{keywords, domainMatchStrategy} } func (r *DomainKeywordItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.Domain != "" { - domainHost = metadata.Domain - } else { - domainHost = metadata.Destination.Fqdn + switch r.domainMatchStrategy { + case C.DomainMatchStrategyPreferFQDN: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else { + domainHost = metadata.Domain + } + case C.DomainMatchStrategyFQDNOnly: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } + case C.DomainMatchStrategySniffHostOnly: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } + default: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain + } } if domainHost == "" { return false diff --git a/route/rule/rule_item_domain_regex.go b/route/rule/rule_item_domain_regex.go index b9752a45ad..6c842986ae 100644 --- a/route/rule/rule_item_domain_regex.go +++ b/route/rule/rule_item_domain_regex.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" ) @@ -12,11 +13,12 @@ import ( var _ RuleItem = (*DomainRegexItem)(nil) type DomainRegexItem struct { - matchers []*regexp.Regexp - description string + matchers []*regexp.Regexp + description string + domainMatchStrategy C.DomainMatchStrategy } -func NewDomainRegexItem(expressions []string) (*DomainRegexItem, error) { +func NewDomainRegexItem(expressions []string, domainMatchStrategy C.DomainMatchStrategy) (*DomainRegexItem, error) { matchers := make([]*regexp.Regexp, 0, len(expressions)) for i, regex := range expressions { matcher, err := regexp.Compile(regex) @@ -34,15 +36,36 @@ func NewDomainRegexItem(expressions []string) (*DomainRegexItem, error) { } else { description += F.ToString("[", strings.Join(expressions, " "), "]") } - return &DomainRegexItem{matchers, description}, nil + return &DomainRegexItem{matchers, description, domainMatchStrategy}, nil } func (r *DomainRegexItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.Domain != "" { - domainHost = metadata.Domain - } else { - domainHost = metadata.Destination.Fqdn + switch r.domainMatchStrategy { + case C.DomainMatchStrategyPreferFQDN: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else { + domainHost = metadata.Domain + } + case C.DomainMatchStrategyFQDNOnly: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } + case C.DomainMatchStrategySniffHostOnly: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } + default: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain + } } if domainHost == "" { return false diff --git a/route/rule/rule_item_ip_is_private.go b/route/rule/rule_item_ip_is_private.go index e185db1db4..17b30602ff 100644 --- a/route/rule/rule_item_ip_is_private.go +++ b/route/rule/rule_item_ip_is_private.go @@ -33,6 +33,13 @@ func (r *IPIsPrivateItem) Match(metadata *adapter.InboundContext) bool { return true } } + if len(metadata.CacheIPs) > 0 { + for _, destinationAddress := range metadata.CacheIPs { + if !N.IsPublicAddr(destinationAddress) { + return true + } + } + } } return false } diff --git a/route/rule/rule_item_preferred_by.go b/route/rule/rule_item_preferred_by.go index 42c8a62786..18633cd726 100644 --- a/route/rule/rule_item_preferred_by.go +++ b/route/rule/rule_item_preferred_by.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/service" @@ -13,15 +14,17 @@ import ( var _ RuleItem = (*PreferredByItem)(nil) type PreferredByItem struct { - ctx context.Context - outboundTags []string - outbounds []adapter.OutboundWithPreferredRoutes + ctx context.Context + outboundTags []string + outbounds []adapter.OutboundWithPreferredRoutes + domainMatchStrategy C.DomainMatchStrategy } -func NewPreferredByItem(ctx context.Context, outboundTags []string) *PreferredByItem { +func NewPreferredByItem(ctx context.Context, outboundTags []string, domainMatchStrategy C.DomainMatchStrategy) *PreferredByItem { return &PreferredByItem{ - ctx: ctx, - outboundTags: outboundTags, + ctx: ctx, + outboundTags: outboundTags, + domainMatchStrategy: domainMatchStrategy, } } @@ -43,10 +46,31 @@ func (r *PreferredByItem) Start() error { func (r *PreferredByItem) Match(metadata *adapter.InboundContext) bool { var domainHost string - if metadata.Domain != "" { - domainHost = metadata.Domain - } else { - domainHost = metadata.Destination.Fqdn + switch r.domainMatchStrategy { + case C.DomainMatchStrategyPreferFQDN: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else { + domainHost = metadata.Domain + } + case C.DomainMatchStrategyFQDNOnly: + if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } + case C.DomainMatchStrategySniffHostOnly: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } + default: + if metadata.SniffHost != "" { + domainHost = metadata.SniffHost + } else if metadata.Destination.IsFqdn() { + domainHost = metadata.Destination.Fqdn + } else { + domainHost = metadata.Domain + } } if domainHost != "" { for _, outbound := range r.outbounds { @@ -62,7 +86,7 @@ func (r *PreferredByItem) Match(metadata *adapter.InboundContext) bool { } } } - if len(metadata.DestinationAddresses) > 0 { + if len(metadata.DestinationAddresses) > 0 || len(metadata.CacheIPs) > 0 { for _, address := range metadata.DestinationAddresses { for _, outbound := range r.outbounds { if outbound.PreferredAddress(address) { @@ -70,6 +94,15 @@ func (r *PreferredByItem) Match(metadata *adapter.InboundContext) bool { } } } + if len(metadata.CacheIPs) > 0 { + for _, address := range metadata.CacheIPs { + for _, outbound := range r.outbounds { + if outbound.PreferredAddress(address) { + return true + } + } + } + } } return false } diff --git a/route/rule/rule_item_source_hostname.go b/route/rule/rule_item_source_hostname.go new file mode 100644 index 0000000000..0df11c8c8a --- /dev/null +++ b/route/rule/rule_item_source_hostname.go @@ -0,0 +1,42 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*SourceHostnameItem)(nil) + +type SourceHostnameItem struct { + hostnames []string + hostnameMap map[string]bool +} + +func NewSourceHostnameItem(hostnameList []string) *SourceHostnameItem { + rule := &SourceHostnameItem{ + hostnames: hostnameList, + hostnameMap: make(map[string]bool), + } + for _, hostname := range hostnameList { + rule.hostnameMap[hostname] = true + } + return rule +} + +func (r *SourceHostnameItem) Match(metadata *adapter.InboundContext) bool { + if metadata.SourceHostname == "" { + return false + } + return r.hostnameMap[metadata.SourceHostname] +} + +func (r *SourceHostnameItem) String() string { + var description string + if len(r.hostnames) == 1 { + description = "source_hostname=" + r.hostnames[0] + } else { + description = "source_hostname=[" + strings.Join(r.hostnames, " ") + "]" + } + return description +} diff --git a/route/rule/rule_item_source_mac_address.go b/route/rule/rule_item_source_mac_address.go new file mode 100644 index 0000000000..feeadb1dbf --- /dev/null +++ b/route/rule/rule_item_source_mac_address.go @@ -0,0 +1,48 @@ +package rule + +import ( + "net" + "strings" + + "github.com/sagernet/sing-box/adapter" +) + +var _ RuleItem = (*SourceMACAddressItem)(nil) + +type SourceMACAddressItem struct { + addresses []string + addressMap map[string]bool +} + +func NewSourceMACAddressItem(addressList []string) *SourceMACAddressItem { + rule := &SourceMACAddressItem{ + addresses: addressList, + addressMap: make(map[string]bool), + } + for _, address := range addressList { + parsed, err := net.ParseMAC(address) + if err == nil { + rule.addressMap[parsed.String()] = true + } else { + rule.addressMap[address] = true + } + } + return rule +} + +func (r *SourceMACAddressItem) Match(metadata *adapter.InboundContext) bool { + if metadata.SourceMACAddress == nil { + return false + } + return r.addressMap[metadata.SourceMACAddress.String()] +} + +func (r *SourceMACAddressItem) String() string { + var description string + if len(r.addresses) == 1 { + description = "source_mac_address=" + r.addresses[0] + } else { + description = "source_mac_address=[" + strings.Join(r.addresses, " ") + "]" + } + return description +} diff --git a/route/rule/rule_set_abstract.go b/route/rule/rule_set_abstract.go new file mode 100644 index 0000000000..5000c8a0c2 --- /dev/null +++ b/route/rule/rule_set_abstract.go @@ -0,0 +1,165 @@ +package rule + +import ( + "bytes" + "context" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/srs" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/x/list" + + "go4.org/netipx" +) + +type abstractRuleSet struct { + ctx context.Context + logger logger.ContextLogger + tag string + access sync.RWMutex + sType string + path string + format string + rules []adapter.HeadlessRule + ruleCount uint64 + metadata adapter.RuleSetMetadata + lastUpdated time.Time + callbacks list.List[adapter.RuleSetUpdateCallback] + refs atomic.Int32 +} + +func (s *abstractRuleSet) Name() string { + return s.tag +} + +func (s *abstractRuleSet) Type() string { + return s.sType +} + +func (s *abstractRuleSet) Format() string { + return s.format +} + +func (s *abstractRuleSet) RuleCount() uint64 { + return s.ruleCount +} + +func (s *abstractRuleSet) UpdatedTime() time.Time { + return s.lastUpdated +} + +func (s *abstractRuleSet) String() string { + return strings.Join(F.MapToString(s.rules), " ") +} + +func (s *abstractRuleSet) Metadata() adapter.RuleSetMetadata { + s.access.RLock() + defer s.access.RUnlock() + return s.metadata +} + +func (s *abstractRuleSet) ExtractIPSet() []*netipx.IPSet { + s.access.RLock() + defer s.access.RUnlock() + return common.FlatMap(s.rules, extractIPSetFromRule) +} + +func (s *abstractRuleSet) IncRef() { + s.refs.Add(1) +} + +func (s *abstractRuleSet) DecRef() { + if s.refs.Add(-1) < 0 { + panic("rule-set: negative refs") + } +} + +func (s *abstractRuleSet) Cleanup() { + if s.refs.Load() == 0 { + s.rules = nil + } +} + +func (s *abstractRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { + s.access.Lock() + defer s.access.Unlock() + return s.callbacks.PushBack(callback) +} + +func (s *abstractRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) { + s.access.Lock() + defer s.access.Unlock() + s.callbacks.Remove(element) +} + +func (s *abstractRuleSet) loadBytes(content []byte, ruleset adapter.RuleSet) error { + var ( + ruleSet option.PlainRuleSetCompat + err error + ) + switch s.format { + case C.RuleSetFormatSource: + ruleSet, err = json.UnmarshalExtended[option.PlainRuleSetCompat](content) + if err != nil { + return err + } + case C.RuleSetFormatBinary: + ruleSet, err = srs.Read(bytes.NewReader(content), false) + if err != nil { + return err + } + default: + return E.New("unknown rule-set format: ", s.format) + } + plainRuleSet, err := ruleSet.Upgrade() + if err != nil { + return err + } + return s.reloadRules(plainRuleSet.Rules, ruleset) +} + +func (s *abstractRuleSet) reloadRules(headlessRules []option.HeadlessRule, ruleSet adapter.RuleSet) error { + rules := make([]adapter.HeadlessRule, len(headlessRules)) + var ruleCount uint64 + for i, ruleOptions := range headlessRules { + rule, err := NewHeadlessRule(s.ctx, ruleOptions) + if err != nil { + return E.Cause(err, "parse rule_set.rules.[", i, "]") + } + rules[i] = rule + ruleCount += rule.RuleCount() + } + var metadata adapter.RuleSetMetadata + metadata.ContainsProcessRule = HasHeadlessRule(headlessRules, isProcessHeadlessRule) + metadata.ContainsWIFIRule = HasHeadlessRule(headlessRules, isWIFIHeadlessRule) + metadata.ContainsIPCIDRRule = HasHeadlessRule(headlessRules, isIPCIDRHeadlessRule) + s.access.Lock() + s.rules = rules + s.ruleCount = ruleCount + s.metadata = metadata + callbacks := s.callbacks.Array() + s.access.Unlock() + for _, callback := range callbacks { + callback(ruleSet) + } + return nil +} + +func (s *abstractRuleSet) Match(metadata *adapter.InboundContext) bool { + for _, rule := range s.rules { + if rule.Match(metadata) { + return true + } + } + return false +} diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index b09915ed2f..d8bb0d0b30 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -2,71 +2,63 @@ package rule import ( "context" + "io" "os" "path/filepath" - "strings" - "sync" - "sync/atomic" "github.com/sagernet/fswatch" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/srs" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" - F "github.com/sagernet/sing/common/format" - "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/logger" - "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/common/rw" "github.com/sagernet/sing/service/filemanager" - - "go4.org/netipx" ) var _ adapter.RuleSet = (*LocalRuleSet)(nil) type LocalRuleSet struct { - ctx context.Context - logger logger.Logger - tag string - access sync.RWMutex - rules []adapter.HeadlessRule - metadata adapter.RuleSetMetadata - fileFormat string - watcher *fswatch.Watcher - callbacks list.List[adapter.RuleSetUpdateCallback] - refs atomic.Int32 + abstractRuleSet + watcher *fswatch.Watcher } -func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.RuleSet) (*LocalRuleSet, error) { +func NewLocalRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) (*LocalRuleSet, error) { ruleSet := &LocalRuleSet{ - ctx: ctx, - logger: logger, - tag: options.Tag, - fileFormat: options.Format, + abstractRuleSet: abstractRuleSet{ + ctx: ctx, + logger: logger, + tag: options.Tag, + sType: options.Type, + format: options.Format, + }, } if options.Type == C.RuleSetTypeInline { if len(options.InlineOptions.Rules) == 0 { return nil, E.New("empty inline rule-set") } - err := ruleSet.reloadRules(options.InlineOptions.Rules) + err := ruleSet.reloadRules(options.InlineOptions.Rules, ruleSet) if err != nil { return nil, err } } else { - filePath := filemanager.BasePath(ctx, options.LocalOptions.Path) - filePath, _ = filepath.Abs(filePath) - err := ruleSet.reloadFile(filePath) + path, err := ruleSet.getPath(ctx, options.Path) + if err != nil { + return nil, err + } + ruleSet.path = path + err = ruleSet.reloadFile(path) if err != nil { return nil, err } watcher, err := fswatch.NewWatcher(fswatch.Options{ - Path: []string{filePath}, + Path: []string{path}, Callback: func(path string) { uErr := ruleSet.reloadFile(path) if uErr != nil { - logger.Error(E.Cause(uErr, "reload rule-set ", options.Tag)) + logger.ErrorContext(log.ContextWithNewID(context.Background()), E.Cause(uErr, "reload rule-set ", options.Tag)) } }, }) @@ -78,14 +70,6 @@ func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.R return ruleSet, nil } -func (s *LocalRuleSet) Name() string { - return s.tag -} - -func (s *LocalRuleSet) String() string { - return strings.Join(F.MapToString(s.rules), " ") -} - func (s *LocalRuleSet) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error { if s.watcher != nil { err := s.watcher.Start() @@ -97,115 +81,50 @@ func (s *LocalRuleSet) StartContext(ctx context.Context, startContext *adapter.H } func (s *LocalRuleSet) reloadFile(path string) error { - var ruleSet option.PlainRuleSetCompat - switch s.fileFormat { - case C.RuleSetFormatSource, "": - content, err := os.ReadFile(path) - if err != nil { - return err - } - ruleSet, err = json.UnmarshalExtended[option.PlainRuleSetCompat](content) - if err != nil { - return err - } - - case C.RuleSetFormatBinary: - setFile, err := os.Open(path) - if err != nil { - return err - } - ruleSet, err = srs.Read(setFile, false) - if err != nil { - return err - } - default: - return E.New("unknown rule-set format: ", s.fileFormat) + file, err := os.Open(path) + if err != nil { + return err + } + content, err := io.ReadAll(file) + if err != nil { + return err } - plainRuleSet, err := ruleSet.Upgrade() + err = s.loadBytes(content, s) if err != nil { return err } - return s.reloadRules(plainRuleSet.Rules) + fs, _ := file.Stat() + s.lastUpdated = fs.ModTime() + return nil } -func (s *LocalRuleSet) reloadRules(headlessRules []option.HeadlessRule) error { - rules := make([]adapter.HeadlessRule, len(headlessRules)) - var err error - for i, ruleOptions := range headlessRules { - rules[i], err = NewHeadlessRule(s.ctx, ruleOptions) - if err != nil { - return E.Cause(err, "parse rule_set.rules.[", i, "]") +func (s *LocalRuleSet) getPath(ctx context.Context, path string) (string, error) { + if path == "" { + path = s.tag + switch s.format { + case C.RuleSetFormatSource, "": + path += ".json" + case C.RuleSetFormatBinary: + path += ".srs" } } - var metadata adapter.RuleSetMetadata - metadata.ContainsProcessRule = HasHeadlessRule(headlessRules, isProcessHeadlessRule) - metadata.ContainsWIFIRule = HasHeadlessRule(headlessRules, isWIFIHeadlessRule) - metadata.ContainsIPCIDRRule = HasHeadlessRule(headlessRules, isIPCIDRHeadlessRule) - s.access.Lock() - s.rules = rules - s.metadata = metadata - callbacks := s.callbacks.Array() - s.access.Unlock() - for _, callback := range callbacks { - callback(s) + path = filemanager.BasePath(ctx, path) + path, _ = filepath.Abs(path) + if rw.IsDir(path) { + return "", E.New("rule_set path is a directory: ", path) } - return nil + return path, nil } func (s *LocalRuleSet) PostStart() error { return nil } -func (s *LocalRuleSet) Metadata() adapter.RuleSetMetadata { - s.access.RLock() - defer s.access.RUnlock() - return s.metadata -} - -func (s *LocalRuleSet) ExtractIPSet() []*netipx.IPSet { - s.access.RLock() - defer s.access.RUnlock() - return common.FlatMap(s.rules, extractIPSetFromRule) -} - -func (s *LocalRuleSet) IncRef() { - s.refs.Add(1) -} - -func (s *LocalRuleSet) DecRef() { - if s.refs.Add(-1) < 0 { - panic("rule-set: negative refs") - } -} - -func (s *LocalRuleSet) Cleanup() { - if s.refs.Load() == 0 { - s.rules = nil - } -} - -func (s *LocalRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { - s.access.Lock() - defer s.access.Unlock() - return s.callbacks.PushBack(callback) -} - -func (s *LocalRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) { - s.access.Lock() - defer s.access.Unlock() - s.callbacks.Remove(element) +func (s *LocalRuleSet) Update(ctx context.Context) error { + return nil } func (s *LocalRuleSet) Close() error { s.rules = nil return common.Close(common.PtrOrNil(s.watcher)) } - -func (s *LocalRuleSet) Match(metadata *adapter.InboundContext) bool { - for _, rule := range s.rules { - if rule.Match(metadata) { - return true - } - } - return false -} diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index 3aba76bab6..d4306a360a 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -1,61 +1,57 @@ package rule import ( - "bytes" "context" "crypto/tls" "io" "net" "net/http" + "os" + "path/filepath" "runtime" "strings" - "sync" - "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/srs" + "github.com/sagernet/sing-box/common/hash" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" - "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ntp" - "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/common/rw" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" "github.com/sagernet/sing/service/pause" - - "go4.org/netipx" ) var _ adapter.RuleSet = (*RemoteRuleSet)(nil) type RemoteRuleSet struct { - ctx context.Context + abstractRuleSet cancel context.CancelFunc - logger logger.ContextLogger outbound adapter.OutboundManager - options option.RuleSet + options option.RemoteRuleSet updateInterval time.Duration dialer N.Dialer - access sync.RWMutex - rules []adapter.HeadlessRule - metadata adapter.RuleSetMetadata - lastUpdated time.Time + hash hash.HashType lastEtag string updateTicker *time.Ticker cacheFile adapter.CacheFile pauseManager pause.Manager - callbacks list.List[adapter.RuleSetUpdateCallback] - refs atomic.Int32 } func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) *RemoteRuleSet { ctx, cancel := context.WithCancel(ctx) + var path string + if options.Path != "" { + path = filemanager.BasePath(ctx, options.Path) + path, _ = filepath.Abs(path) + } var updateInterval time.Duration if options.RemoteOptions.UpdateInterval > 0 { updateInterval = time.Duration(options.RemoteOptions.UpdateInterval) @@ -63,20 +59,21 @@ func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options updateInterval = 24 * time.Hour } return &RemoteRuleSet{ - ctx: ctx, - cancel: cancel, + abstractRuleSet: abstractRuleSet{ + ctx: ctx, + logger: logger, + tag: options.Tag, + path: path, + format: options.Format, + }, outbound: service.FromContext[adapter.OutboundManager](ctx), - logger: logger, - options: options, + cancel: cancel, + options: options.RemoteOptions, updateInterval: updateInterval, pauseManager: service.FromContext[pause.Manager](ctx), } } -func (s *RemoteRuleSet) Name() string { - return s.options.Tag -} - func (s *RemoteRuleSet) String() string { return strings.Join(F.MapToString(s.rules), " ") } @@ -84,30 +81,24 @@ func (s *RemoteRuleSet) String() string { func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter.HTTPStartContext) error { s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx) var dialer N.Dialer - if s.options.RemoteOptions.DownloadDetour != "" { - outbound, loaded := s.outbound.Outbound(s.options.RemoteOptions.DownloadDetour) + if s.options.DownloadDetour != "" { + outbound, loaded := s.outbound.Outbound(s.options.DownloadDetour) if !loaded { - return E.New("download detour not found: ", s.options.RemoteOptions.DownloadDetour) + return E.New("download detour not found: ", s.options.DownloadDetour) } dialer = outbound } else { dialer = s.outbound.Default() } s.dialer = dialer - if s.cacheFile != nil { - if savedSet := s.cacheFile.LoadRuleSet(s.options.Tag); savedSet != nil { - err := s.loadBytes(savedSet.Content) - if err != nil { - return E.Cause(err, "restore cached rule-set") - } - s.lastUpdated = savedSet.LastUpdated - s.lastEtag = savedSet.LastEtag - } + err := s.loadCacheFile() + if err != nil { + return E.Cause(err, "restore cached rule-set") } if s.lastUpdated.IsZero() { err := s.fetch(ctx, startContext) if err != nil { - return E.Cause(err, "initial rule-set: ", s.options.Tag) + return E.Cause(err, "initial rule-set: ", s.tag) } } s.updateTicker = time.NewTicker(s.updateInterval) @@ -119,97 +110,9 @@ func (s *RemoteRuleSet) PostStart() error { return nil } -func (s *RemoteRuleSet) Metadata() adapter.RuleSetMetadata { - s.access.RLock() - defer s.access.RUnlock() - return s.metadata -} - -func (s *RemoteRuleSet) ExtractIPSet() []*netipx.IPSet { - s.access.RLock() - defer s.access.RUnlock() - return common.FlatMap(s.rules, extractIPSetFromRule) -} - -func (s *RemoteRuleSet) IncRef() { - s.refs.Add(1) -} - -func (s *RemoteRuleSet) DecRef() { - if s.refs.Add(-1) < 0 { - panic("rule-set: negative refs") - } -} - -func (s *RemoteRuleSet) Cleanup() { - if s.refs.Load() == 0 { - s.rules = nil - } -} - -func (s *RemoteRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { - s.access.Lock() - defer s.access.Unlock() - return s.callbacks.PushBack(callback) -} - -func (s *RemoteRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) { - s.access.Lock() - defer s.access.Unlock() - s.callbacks.Remove(element) -} - -func (s *RemoteRuleSet) loadBytes(content []byte) error { - var ( - ruleSet option.PlainRuleSetCompat - err error - ) - switch s.options.Format { - case C.RuleSetFormatSource: - ruleSet, err = json.UnmarshalExtended[option.PlainRuleSetCompat](content) - if err != nil { - return err - } - case C.RuleSetFormatBinary: - ruleSet, err = srs.Read(bytes.NewReader(content), false) - if err != nil { - return err - } - default: - return E.New("unknown rule-set format: ", s.options.Format) - } - plainRuleSet, err := ruleSet.Upgrade() - if err != nil { - return err - } - rules := make([]adapter.HeadlessRule, len(plainRuleSet.Rules)) - for i, ruleOptions := range plainRuleSet.Rules { - rules[i], err = NewHeadlessRule(s.ctx, ruleOptions) - if err != nil { - return E.Cause(err, "parse rule_set.rules.[", i, "]") - } - } - s.access.Lock() - s.metadata.ContainsProcessRule = HasHeadlessRule(plainRuleSet.Rules, isProcessHeadlessRule) - s.metadata.ContainsWIFIRule = HasHeadlessRule(plainRuleSet.Rules, isWIFIHeadlessRule) - s.metadata.ContainsIPCIDRRule = HasHeadlessRule(plainRuleSet.Rules, isIPCIDRHeadlessRule) - s.rules = rules - callbacks := s.callbacks.Array() - s.access.Unlock() - for _, callback := range callbacks { - callback(s) - } - return nil -} - func (s *RemoteRuleSet) loopUpdate() { if time.Since(s.lastUpdated) > s.updateInterval { - err := s.fetch(s.ctx, nil) - if err != nil { - s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err) - } else if s.refs.Load() == 0 { - s.rules = nil - } + s.update() } for { runtime.GC() @@ -217,25 +120,36 @@ func (s *RemoteRuleSet) loopUpdate() { case <-s.ctx.Done(): return case <-s.updateTicker.C: - s.updateOnce() + s.update() } } } -func (s *RemoteRuleSet) updateOnce() { - err := s.fetch(s.ctx, nil) +func (s *RemoteRuleSet) update() { + ctx := log.ContextWithNewID(s.ctx) + err := s.fetch(ctx, nil) + if err != nil { + s.logger.ErrorContext(ctx, "fetch rule-set ", s.tag, ": ", err) + } else if s.refs.Load() == 0 { + s.rules = nil + } +} + +func (s *RemoteRuleSet) Update(ctx context.Context) error { + err := s.fetch(log.ContextWithNewID(ctx), nil) if err != nil { - s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err) + return err } else if s.refs.Load() == 0 { s.rules = nil } + return nil } func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPStartContext) error { - s.logger.Debug("updating rule-set ", s.options.Tag, " from URL: ", s.options.RemoteOptions.URL) + s.logger.DebugContext(ctx, "updating rule-set ", s.tag, " from URL: ", s.options.URL) var httpClient *http.Client if startContext != nil { - httpClient = startContext.HTTPClient(s.options.RemoteOptions.DownloadDetour, s.dialer) + httpClient = startContext.HTTPClient(s.options.DownloadDetour, s.dialer) } else { httpClient = &http.Client{ Transport: &http.Transport{ @@ -251,7 +165,7 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPSta }, } } - request, err := http.NewRequest("GET", s.options.RemoteOptions.URL, nil) + request, err := http.NewRequest("GET", s.options.URL, nil) if err != nil { return err } @@ -266,18 +180,18 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPSta case http.StatusOK: case http.StatusNotModified: s.lastUpdated = time.Now() + if s.path != "" { + os.Chtimes(s.path, s.lastUpdated, s.lastUpdated) + } if s.cacheFile != nil { - savedRuleSet := s.cacheFile.LoadRuleSet(s.options.Tag) - if savedRuleSet != nil { + if savedRuleSet := s.cacheFile.LoadRuleSet(s.tag); savedRuleSet != nil { savedRuleSet.LastUpdated = s.lastUpdated - err = s.cacheFile.SaveRuleSet(s.options.Tag, savedRuleSet) - if err != nil { + if err = s.cacheFile.SaveRuleSet(s.tag, savedRuleSet); err != nil { s.logger.Error("save rule-set updated time: ", err) - return nil } } } - s.logger.Info("update rule-set ", s.options.Tag, ": not modified") + s.logger.InfoContext(ctx, "update rule-set ", s.tag, ": not modified") return nil default: return E.New("unexpected status: ", response.Status) @@ -287,7 +201,7 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPSta response.Body.Close() return err } - err = s.loadBytes(content) + err = s.loadBytes(content, s) if err != nil { response.Body.Close() return err @@ -298,20 +212,98 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, startContext *adapter.HTTPSta s.lastEtag = eTagHeader } s.lastUpdated = time.Now() + if s.path != "" { + s.saveCacheFile(content) + } if s.cacheFile != nil { - err = s.cacheFile.SaveRuleSet(s.options.Tag, &adapter.SavedBinary{ + savedRuleSet := &adapter.SavedBinary{ LastUpdated: s.lastUpdated, - Content: content, LastEtag: s.lastEtag, - }) - if err != nil { + } + if s.path != "" { + savedRuleSet.Hash = s.hash + } else { + savedRuleSet.Content = content + } + if err = s.cacheFile.SaveRuleSet(s.tag, savedRuleSet); err != nil { s.logger.Error("save rule-set cache: ", err) } } - s.logger.Info("updated rule-set ", s.options.Tag) + s.logger.InfoContext(ctx, "updated rule-set ", s.tag) + return nil +} + +func (s *RemoteRuleSet) loadCacheFile() error { + var content []byte + var lastUpdated time.Time + var lastEtag string + var savedSet *adapter.SavedBinary + if s.cacheFile != nil { + if savedSet = s.cacheFile.LoadRuleSet(s.tag); savedSet != nil { + s.hash = savedSet.Hash + } + } + if s.path != "" { + exists, err := pathExists(s.path) + if err != nil { + return err + } + if !exists { + return nil + } + file, _ := os.Open(s.path) + content, err = io.ReadAll(file) + if err != nil { + return err + } + if savedSet != nil { + if !s.hash.Equal(hash.MakeHash(content)) { + s.logger.Error("load rule-set cache file failed: validation failed") + return nil + } + lastUpdated = savedSet.LastUpdated + lastEtag = savedSet.LastEtag + } else { + fs, _ := file.Stat() + lastUpdated = fs.ModTime() + } + } else if savedSet != nil && savedSet.Content != nil { + content = savedSet.Content + lastUpdated = savedSet.LastUpdated + lastEtag = savedSet.LastEtag + } else { + return nil + } + if err := s.loadBytes(content, s); err != nil { + return err + } + s.lastUpdated, s.lastEtag = lastUpdated, lastEtag return nil } +func pathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + if rw.IsDir(path) { + return false, E.New("rule_set path is a directory: ", path) + } + return false, err +} + +func (s *RemoteRuleSet) saveCacheFile(contentRaw []byte) { + s.hash = hash.MakeHash(contentRaw) + dir := filepath.Dir(s.path) + if _, err := os.Stat(dir); os.IsNotExist(err) { + filemanager.MkdirAll(s.ctx, dir, 0o755) + } + filemanager.WriteFile(s.ctx, s.path, []byte(contentRaw), 0o666) +} + func (s *RemoteRuleSet) Close() error { s.rules = nil s.cancel() @@ -320,12 +312,3 @@ func (s *RemoteRuleSet) Close() error { } return nil } - -func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool { - for _, rule := range s.rules { - if rule.Match(metadata) { - return true - } - } - return false -} diff --git a/route/rule_conds.go b/route/rule_conds.go index 55c4a058e2..22ce94fffd 100644 --- a/route/rule_conds.go +++ b/route/rule_conds.go @@ -45,6 +45,14 @@ func isProcessDNSRule(rule option.DefaultDNSRule) bool { return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.ProcessPathRegex) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0 } +func isNeighborRule(rule option.DefaultRule) bool { + return len(rule.SourceMACAddress) > 0 || len(rule.SourceHostname) > 0 +} + +func isNeighborDNSRule(rule option.DefaultDNSRule) bool { + return len(rule.SourceMACAddress) > 0 || len(rule.SourceHostname) > 0 +} + func isWIFIRule(rule option.DefaultRule) bool { return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0 } diff --git a/service/resolved/transport.go b/service/resolved/transport.go index ac20663ae0..275020d5e9 100644 --- a/service/resolved/transport.go +++ b/service/resolved/transport.go @@ -143,8 +143,7 @@ func (t *Transport) updateTransports(link *TransportLink) error { Enabled: true, ServerName: serverAddr.String(), })) - transports = append(transports, transport.NewTLSRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, 53), tlsConfig)) - + transports = append(transports, transport.NewTLSRaw(t.ctx, t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, 53), tlsConfig, false, C.TCPKeepAliveInitial+C.TCPKeepAliveInterval, false, 0)) } else { transports = append(transports, transport.NewUDPRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, 53))) } @@ -165,8 +164,7 @@ func (t *Transport) updateTransports(link *TransportLink) error { Enabled: true, ServerName: serverName, })) - transports = append(transports, transport.NewTLSRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, address.Port), tlsConfig)) - + transports = append(transports, transport.NewTLSRaw(t.ctx, t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, address.Port), tlsConfig, false, C.TCPKeepAliveInitial+C.TCPKeepAliveInterval, false, 0)) } else { transports = append(transports, transport.NewUDPRaw(t.logger, t.TransportAdapter, serverDialer, M.SocksaddrFrom(serverAddr, address.Port))) } diff --git a/transport/wireguard/device.go b/transport/wireguard/device.go index 4dd615c585..a37e9c7cfa 100644 --- a/transport/wireguard/device.go +++ b/transport/wireguard/device.go @@ -6,7 +6,7 @@ import ( "time" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/logger" N "github.com/sagernet/sing/common/network" "github.com/sagernet/wireguard-go/device" @@ -26,6 +26,7 @@ type DeviceOptions struct { Context context.Context Logger logger.ContextLogger System bool + GSO bool Handler tun.Handler UDPTimeout time.Duration CreateDialer func(interfaceName string) N.Dialer diff --git a/transport/wireguard/device_system.go b/transport/wireguard/device_system.go index dcf2959b63..c0a5aee32f 100644 --- a/transport/wireguard/device_system.go +++ b/transport/wireguard/device_system.go @@ -10,7 +10,7 @@ import ( "sync" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -91,7 +91,7 @@ func (w *systemDevice) Start() error { return it.Addr().Is6() }), MTU: w.options.MTU, - GSO: true, + GSO: w.options.GSO, InterfaceScope: true, Inet4RouteAddress: common.Filter(w.options.AllowedAddress, func(it netip.Prefix) bool { return it.Addr().Is4() diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index dac07c859c..83848ec5b5 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -15,7 +15,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" @@ -107,6 +107,7 @@ func NewEndpoint(options EndpointOptions) (*Endpoint, error) { Context: options.Context, Logger: options.Logger, System: options.System, + GSO: options.GSO, Handler: options.Handler, UDPTimeout: options.UDPTimeout, CreateDialer: options.CreateDialer, diff --git a/transport/wireguard/endpoint_options.go b/transport/wireguard/endpoint_options.go index bb9a46e69f..1bf239e6f5 100644 --- a/transport/wireguard/endpoint_options.go +++ b/transport/wireguard/endpoint_options.go @@ -5,7 +5,7 @@ import ( "net/netip" "time" - "github.com/sagernet/sing-tun" + tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -15,6 +15,7 @@ type EndpointOptions struct { Context context.Context Logger logger.ContextLogger System bool + GSO bool Handler tun.Handler UDPTimeout time.Duration Dialer N.Dialer