From cb026e63c1c63cf1a9e6232a44ac43fa4895d253 Mon Sep 17 00:00:00 2001 From: Howard Cheung Date: Mon, 12 Sep 2022 14:02:08 +0800 Subject: [PATCH] feat: add simple proxy provider support. --- adapter/router.go | 1 + box.go | 2 + go.mod | 3 +- go.sum | 2 + option/outbound.go | 1 + option/provider.go | 7 ++ outbound/builder.go | 2 +- outbound/provider.go | 180 +++++++++++++++++++++++++++++++++++++ outbound/provider/clash.go | 111 +++++++++++++++++++++++ outbound/selector.go | 25 +++++- route/router.go | 7 ++ 11 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 option/provider.go create mode 100644 outbound/provider.go create mode 100644 outbound/provider/clash.go diff --git a/adapter/router.go b/adapter/router.go index b26a62d2..0f3f159f 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -19,6 +19,7 @@ type Router interface { Outbounds() []Outbound Outbound(tag string) (Outbound, bool) + AddOutbound(string, Outbound) DefaultOutbound(network string) Outbound RouteConnection(ctx context.Context, conn net.Conn, metadata InboundContext) error diff --git a/box.go b/box.go index fe39b679..cf969884 100644 --- a/box.go +++ b/box.go @@ -14,6 +14,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/outbound" + _ "github.com/sagernet/sing-box/outbound/provider" "github.com/sagernet/sing-box/route" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" @@ -120,6 +121,7 @@ func New(ctx context.Context, options option.Options) (*Box, error) { } inbounds = append(inbounds, in) } + outbounds = append(outbounds, outbound.InitCompatibleProxy(router, logFactory.NewLogger("outbound/provider"), option.Outbound{Type: "direct", Tag: "compatible"}.DirectOptions)) for i, outboundOptions := range options.Outbounds { var out adapter.Outbound var tag string diff --git a/go.mod b/go.mod index 75a18e83..3dd87d01 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/cloudflare/circl v1.2.1-0.20220831060716-4cf0150356fc github.com/cretz/bine v0.2.0 github.com/database64128/tfo-go v1.1.2 + github.com/dlclark/regexp2 v1.7.0 github.com/dustin/go-humanize v1.0.0 github.com/fsnotify/fsnotify v1.5.4 github.com/go-chi/chi/v5 v5.0.7 @@ -40,6 +41,7 @@ require ( golang.zx2c4.com/wireguard v0.0.0-20220904105730-b51010ba13f0 google.golang.org/grpc v1.49.0 google.golang.org/protobuf v1.28.1 + gopkg.in/yaml.v3 v3.0.1 gvisor.dev/gvisor v0.0.0-20220901235040-6ca97ef2ce1c ) @@ -78,6 +80,5 @@ require ( golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect google.golang.org/genproto v0.0.0-20210722135532-667f2b7c528f // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.1.7 // indirect ) diff --git a/go.sum b/go.sum index 89fecc37..07c4c2e7 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/database64128/tfo-go v1.1.2/go.mod h1:jgrSUPyOvTGQyn6irCOpk7L2W/q/0VL github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= diff --git a/option/outbound.go b/option/outbound.go index 0b00d44f..e321ba53 100644 --- a/option/outbound.go +++ b/option/outbound.go @@ -22,6 +22,7 @@ type _Outbound struct { SSHOptions SSHOutboundOptions `json:"-"` ShadowTLSOptions ShadowTLSOutboundOptions `json:"-"` SelectorOptions SelectorOutboundOptions `json:"-"` + ProviderOptions []ProviderOutboundOptions `json:"providers,omitempty"` } type Outbound _Outbound diff --git a/option/provider.go b/option/provider.go new file mode 100644 index 00000000..f02ed111 --- /dev/null +++ b/option/provider.go @@ -0,0 +1,7 @@ +package option + +type ProviderOutboundOptions struct { + Url string `json:"url"` + Filter string `json:"filter"` + Interval int `json:"interval,omitempty"` +} diff --git a/outbound/builder.go b/outbound/builder.go index fc00044f..09f96999 100644 --- a/outbound/builder.go +++ b/outbound/builder.go @@ -42,7 +42,7 @@ func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, o case C.TypeShadowTLS: return NewShadowTLS(ctx, router, logger, options.Tag, options.ShadowTLSOptions) case C.TypeSelector: - return NewSelector(router, logger, options.Tag, options.SelectorOptions) + return NewSelector(ctx, router, logger, options.Tag, options.SelectorOptions, options.ProviderOptions) default: return nil, E.New("unknown outbound type: ", options.Type) } diff --git a/outbound/provider.go b/outbound/provider.go new file mode 100644 index 00000000..fb03cf90 --- /dev/null +++ b/outbound/provider.go @@ -0,0 +1,180 @@ +package outbound + +import ( + "context" + "io" + "net/http" + "sync" + "time" + + "github.com/dlclark/regexp2" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +type Provider struct { + url string + filter *regexp2.Regexp + interval time.Duration + logger log.ContextLogger + ctx context.Context + router adapter.Router +} + +type providerAdapter struct { + providers []Provider +} + +type CachedProvider struct { + Outbounds []adapter.Outbound + LastUpdate time.Time + Lock *sync.Mutex +} + +type ProviderResolver interface { + GetOutbounds([]byte, context.Context, adapter.Router, log.ContextLogger) []adapter.Outbound +} + +var ( + providerResolvers = make(map[string]ProviderResolver) + cachedProviders = make(map[string]*CachedProvider, 0) + compatibleProxy adapter.Outbound +) + +func NewProvider(url, filterStr string, interval int, ctx context.Context, router adapter.Router, + logger log.ContextLogger) (Provider, error) { + if interval == 0 { + interval = 3600 * 24 + } + filter, err := regexp2.Compile(filterStr, 0) + if err != nil { + return Provider{}, E.New("cannot parse provider regex filter") + } + return Provider{ + url: url, + filter: filter, + interval: time.Duration(interval) * time.Second, + ctx: ctx, + router: router, + logger: logger}, + nil +} + +func (p *Provider) GetOutbounds() ([]string, map[string]adapter.Outbound) { + tags := make([]string, 0) + outbounds := make(map[string]adapter.Outbound, 0) + allOutbounds := p.getAllOutbounds() + for _, outbound := range allOutbounds { + if ok, _ := p.filter.MatchString(outbound.Tag()); ok { + p.router.AddOutbound(outbound.Tag(), outbound) + tags = append(tags, outbound.Tag()) + outbounds[outbound.Tag()] = outbound + } + } + return tags, outbounds +} + +func (p *Provider) getAllOutbounds() (res []adapter.Outbound) { + defer func() { + if r := recover(); r != nil { + res = make([]adapter.Outbound, 0) + p.logger.Warn("failed to get provider outbounds: ", r) + } + }() + if _, ok := cachedProviders[p.url]; !ok { + cachedProviders[p.url] = &CachedProvider{ + Outbounds: make([]adapter.Outbound, 0), + LastUpdate: time.Time{}, + Lock: &sync.Mutex{}, + } + } + cachedProviders[p.url].Lock.Lock() + defer cachedProviders[p.url].Lock.Unlock() + if (cachedProviders[p.url].LastUpdate.Add(p.interval)).Before(time.Now()) { + outbounds := make([]adapter.Outbound, 0) + resp, err := http.DefaultClient.Get(p.url) + if err == nil { + body := resp.Body + defer body.Close() + content, _ := io.ReadAll(body) + for _, resolver := range providerResolvers { + if len(outbounds) == 0 { + outbounds = resolver.GetOutbounds(content, p.ctx, p.router, p.logger) + } + } + cachedProviders[p.url].SetOutbounds(outbounds) + } + } + cachedProviders[p.url].RefeshUpdateTime() + return cachedProviders[p.url].Outbounds +} + +func (p *providerAdapter) NewUpdateFunc(tags *[]string, outbounds *map[string]adapter.Outbound, router adapter.Router, funcs []func()) func() { + res := func() { + for _, f := range funcs { + defer f() + } + for _, provider := range p.providers { + _, newOutbounds := provider.GetOutbounds() + for k, v := range newOutbounds { + if _, ok := (*outbounds)[k]; !ok { + *tags = append(*tags, k) + (*outbounds)[k] = v + } + } + } + if len(*tags) == 0 { + p.AddCompatibleProxy(tags, outbounds, router) + } + if len(*tags) > 2 { + if _, ok := (*outbounds)["compatible"]; ok { + delete(*outbounds, "compatible") + for i, tag := range *tags { + if tag == "compatible" { + *tags = append((*tags)[:i], (*tags)[i+1:]...) + break + } + } + } + } + } + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for range ticker.C { + res() + } + }() + return res +} + +func (p *providerAdapter) AddCompatibleProxy(tags *[]string, outbounds *map[string]adapter.Outbound, router adapter.Router) { + if len(*tags) == 0 { + *tags = append(*tags, "compatible") + (*outbounds)["compatible"] = compatibleProxy + router.AddOutbound("compatible", compatibleProxy) + } +} + +func (p *CachedProvider) SetOutbounds(outbounds []adapter.Outbound) { + p.Outbounds = outbounds +} + +func (p *CachedProvider) RefeshUpdateTime() { + p.LastUpdate = time.Now() +} + +func InjectClashProviderResolver(name string, resolver ProviderResolver) { + providerResolvers[name] = resolver +} + +func InitCompatibleProxy(router adapter.Router, logger log.ContextLogger, opts option.DirectOutboundOptions) adapter.Outbound { + var err error + compatibleProxy, err = NewDirect(router, logger, "compatible", opts) + if err != nil { + logger.Panic("cannot create compatible proxy") + } + return compatibleProxy +} diff --git a/outbound/provider/clash.go b/outbound/provider/clash.go new file mode 100644 index 00000000..a1f11027 --- /dev/null +++ b/outbound/provider/clash.go @@ -0,0 +1,111 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/outbound" + "gopkg.in/yaml.v3" +) + +var ( + clashProxyParsers = make(map[string]func(context.Context, adapter.Router, + log.ContextLogger, map[string]interface{}) (adapter.Outbound, error)) +) + +type ClashProviderResolver struct { +} + +func (r *ClashProviderResolver) GetOutbounds(rawData []byte, ctx context.Context, + router adapter.Router, logger log.ContextLogger) []adapter.Outbound { + data := make(map[string]interface{}, 0) + yaml.Unmarshal(rawData, &data) + outbounds := make([]adapter.Outbound, 0) + proxies, ok := data["proxies"] + if !ok { + return outbounds + } + proxyList, ok := proxies.([]interface{}) + if !ok { + return outbounds + } + for _, proxy := range proxyList { + if proxyItem, ok := proxy.(map[string]interface{}); ok { + newOutbound, err := parseClashProxy(ctx, router, logger, proxyItem) + if err == nil { + outbounds = append(outbounds, newOutbound) + } + } + } + return outbounds +} + +func parseClashProxy(ctx context.Context, router adapter.Router, + logger log.ContextLogger, proxyItem map[string]interface{}) (res adapter.Outbound, err error) { + defer func() { + if r := recover(); r != nil { + logger.Warn("cannot parse proxy: ", r) + res = nil + err = fmt.Errorf("cannot parse proxy: %v", r) + } + }() + if proxyType, ok := proxyItem["type"].(string); ok { + if parser, ok := clashProxyParsers[proxyType]; ok { + return parser(ctx, router, logger, proxyItem) + } + } + return nil, fmt.Errorf("unkown proxy type") +} + +func parseClashSsProxy(ctx context.Context, router adapter.Router, + logger log.ContextLogger, proxyItem map[string]interface{}) (res adapter.Outbound, err error) { + options := option.ShadowsocksOutboundOptions{ + DialerOptions: option.DialerOptions{}, + ServerOptions: option.ServerOptions{ + Server: proxyItem["server"].(string), + ServerPort: uint16(proxyItem["port"].(int))}, + Password: proxyItem["password"].(string), + Method: proxyItem["cipher"].(string), + } + if udpEnabled, ok := proxyItem["udp"].(bool); ok && !udpEnabled { + options.Network = "tcp" + } + return outbound.NewShadowsocks( + ctx, router, logger, + proxyItem["name"].(string), + options, + ) +} + +func ParseClashTrojanProxy(ctx context.Context, router adapter.Router, + logger log.ContextLogger, proxyItem map[string]interface{}) (res adapter.Outbound, err error) { + options := option.TrojanOutboundOptions{ + DialerOptions: option.DialerOptions{}, + ServerOptions: option.ServerOptions{ + Server: proxyItem["server"].(string), + ServerPort: uint16(proxyItem["port"].(int))}, + Password: proxyItem["password"].(string), + TLS: &option.OutboundTLSOptions{}, + Multiplex: &option.MultiplexOptions{}, + Transport: &option.V2RayTransportOptions{}, + } + if udpEnabled, ok := proxyItem["udp"].(bool); ok && !udpEnabled { + options.Network = "tcp" + } + if sni, ok := proxyItem["sni"].(string); ok { + options.TLS.ServerName = sni + } + if skipCertVerity, ok := proxyItem["skip-cert-verify"].(bool); ok && skipCertVerity { + options.TLS.Insecure = true + } + return outbound.NewTrojan(ctx, router, logger, proxyItem["name"].(string), options) +} + +func init() { + clashProxyParsers["ss"] = parseClashSsProxy + clashProxyParsers["trojan"] = ParseClashTrojanProxy + outbound.InjectClashProviderResolver("clash", &ClashProviderResolver{}) +} diff --git a/outbound/selector.go b/outbound/selector.go index 84b50aac..a0894cb8 100644 --- a/outbound/selector.go +++ b/outbound/selector.go @@ -20,13 +20,14 @@ var ( type Selector struct { myOutboundAdapter + providerAdapter tags []string defaultTag string outbounds map[string]adapter.Outbound selected adapter.Outbound } -func NewSelector(router adapter.Router, logger log.ContextLogger, tag string, options option.SelectorOutboundOptions) (*Selector, error) { +func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SelectorOutboundOptions, providers []option.ProviderOutboundOptions) (*Selector, error) { outbound := &Selector{ myOutboundAdapter: myOutboundAdapter{ protocol: C.TypeSelector, @@ -38,6 +39,25 @@ func NewSelector(router adapter.Router, logger log.ContextLogger, tag string, op defaultTag: options.Default, outbounds: make(map[string]adapter.Outbound), } + for _, providerOption := range providers { + provider, err := NewProvider(providerOption.Url, providerOption.Filter, providerOption.Interval, ctx, router, logger) + if err != nil { + return nil, err + } + outbound.providers = append(outbound.providers, provider) + go outbound.NewUpdateFunc(&outbound.tags, &outbound.outbounds, router, []func(){ + func() { + if outbound.selected != nil { + if _, ok := outbound.outbounds[outbound.selected.Tag()]; ok { + return + } + } + outbound.InitSelected() + }})() + } + if len(outbound.providers) > 0 { + outbound.AddCompatibleProxy(&outbound.tags, &outbound.outbounds, router) + } if len(outbound.tags) == 0 { return nil, E.New("missing tags") } @@ -59,7 +79,10 @@ func (s *Selector) Start() error { } s.outbounds[tag] = detour } + return s.InitSelected() +} +func (s *Selector) InitSelected() error { if s.tag != "" { if clashServer := s.router.ClashServer(); clashServer != nil && clashServer.StoreSelected() { selected := clashServer.CacheFile().LoadSelected(s.tag) diff --git a/route/router.go b/route/router.go index 012f535f..e11a6e84 100644 --- a/route/router.go +++ b/route/router.go @@ -507,6 +507,13 @@ func (r *Router) Outbound(tag string) (adapter.Outbound, bool) { return outbound, loaded } +func (r *Router) AddOutbound(tag string, outbound adapter.Outbound) { + if _, loaded := r.outboundByTag[tag]; !loaded { + r.outbounds = append(r.outbounds, outbound) + } + r.outboundByTag[tag] = outbound +} + func (r *Router) DefaultOutbound(network string) adapter.Outbound { if network == N.NetworkTCP { return r.defaultOutboundForConnection