diff --git a/box.go b/box.go index 434f0655..d2fee21e 100644 --- a/box.go +++ b/box.go @@ -106,23 +106,23 @@ func New(options Options) (*Box, error) { inbounds = append(inbounds, in) } for i, outboundOptions := range options.Outbounds { - var out adapter.Outbound + var outs []adapter.Outbound var tag string if outboundOptions.Tag != "" { tag = outboundOptions.Tag } else { tag = F.ToString(i) } - out, err = outbound.New( + outs, err = outbound.NewGroup( ctx, router, - logFactory.NewLogger(F.ToString("outbound/", outboundOptions.Type, "[", tag, "]")), + logFactory, tag, outboundOptions) if err != nil { return nil, E.Cause(err, "parse outbound[", i, "]") } - outbounds = append(outbounds, out) + outbounds = append(outbounds, outs...) } err = router.Initialize(inbounds, outbounds, func() adapter.Outbound { out, oErr := outbound.New(ctx, router, logFactory.NewLogger("outbound/direct"), "direct", option.Outbound{Type: "direct", Tag: "default"}) diff --git a/cmd/sing-box/cmd_show_subscribe_peer.go b/cmd/sing-box/cmd_show_subscribe_peer.go new file mode 100644 index 00000000..6376ade0 --- /dev/null +++ b/cmd/sing-box/cmd_show_subscribe_peer.go @@ -0,0 +1,106 @@ +//go:build with_subscribe + +package main + +import ( + "context" + "encoding/json" + "fmt" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/subscribe" + E "github.com/sagernet/sing/common/exceptions" + "github.com/spf13/cobra" + "time" +) + +var commandShowSubscribePeer = &cobra.Command{ + Use: "showsub", + Short: "Show subscribe peer", + Run: func(cmd *cobra.Command, args []string) { + err := showSubscribePeer() + if err != nil { + log.Fatal(err) + } + }, +} + +var showSubscribePeerTags []string + +func init() { + commandShowSubscribePeer.Flags().StringArrayVarP(&showSubscribePeerTags, "tag", "t", nil, "set tag") + mainCommand.AddCommand(commandShowSubscribePeer) +} + +func showSubscribePeer() error { + options, err := readConfigAndMerge() + if err != nil { + return err + } + + if options.Outbounds == nil || len(options.Outbounds) == 0 { + return E.New("no outbound found") + } + + subscribeOptionsMap := make(map[string]option.Outbound) + + all := false + + if showSubscribePeerTags == nil || len(showSubscribePeerTags) == 0 { + all = true + } else { + for _, t := range showSubscribePeerTags { + subscribeOptionsMap[t] = option.Outbound{} + } + } + + for _, o := range options.Outbounds { + if all { + if o.Type == C.TypeSubscribe { + subscribeOptionsMap[o.Tag] = o + } + continue + } + + if _, ok := subscribeOptionsMap[o.Tag]; ok { + if o.Type != C.TypeSubscribe { + return E.New("outbound ", o.Tag, " is not subscribe") + } + subscribeOptionsMap[o.Tag] = o + } + } + + if len(subscribeOptionsMap) > 0 { + for tag, opt := range subscribeOptionsMap { + if opt.Type == "" { + return E.New("outbound ", tag, " not found") + } + } + } + + outs := make([]option.Outbound, 0) + + for _, o := range subscribeOptionsMap { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + peers, err := subscribe.ParsePeer(ctx, o.Tag, o.SubscribeOptions) + cancel() + if err != nil { + return E.Cause(err, "show subscribe '", o.Tag, "' peer fail") + } + outs = append(outs, peers...) + } + + m := map[string]any{ + "outbounds": outs, + } + + content, err := json.MarshalIndent(m, "", " ") + if err != nil { + return E.Cause(err, "show subscribe peer fail") + } + + fmt.Println(string(content)) + + return nil +} diff --git a/cmd/sing-box/cmd_update_subscribe.go b/cmd/sing-box/cmd_update_subscribe.go new file mode 100644 index 00000000..d3125973 --- /dev/null +++ b/cmd/sing-box/cmd_update_subscribe.go @@ -0,0 +1,91 @@ +//go:build with_subscribe + +package main + +import ( + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/subscribe" + E "github.com/sagernet/sing/common/exceptions" + "github.com/spf13/cobra" + "golang.org/x/net/context" + "time" +) + +var commandUpdateSubscribe = &cobra.Command{ + Use: "upsub", + Short: "Update subscribe", + Run: func(cmd *cobra.Command, args []string) { + err := updateSubscribe() + if err != nil { + log.Fatal(err) + } + }, +} + +var updateSubscribeTags []string + +func init() { + commandUpdateSubscribe.Flags().StringArrayVarP(&updateSubscribeTags, "tag", "t", nil, "set tag") + mainCommand.AddCommand(commandUpdateSubscribe) +} + +func updateSubscribe() error { + options, err := readConfigAndMerge() + if err != nil { + return err + } + + if options.Outbounds == nil || len(options.Outbounds) == 0 { + return E.New("no outbound found") + } + + subscribeOptionsMap := make(map[string]option.Outbound) + + all := false + + if updateSubscribeTags == nil || len(updateSubscribeTags) == 0 { + all = true + } else { + for _, t := range updateSubscribeTags { + subscribeOptionsMap[t] = option.Outbound{} + } + } + + for _, o := range options.Outbounds { + if all { + if o.Type == C.TypeSubscribe { + subscribeOptionsMap[o.Tag] = o + } + continue + } + + if _, ok := subscribeOptionsMap[o.Tag]; ok { + if o.Type != C.TypeSubscribe { + return E.New("outbound ", o.Tag, " is not subscribe") + } + subscribeOptionsMap[o.Tag] = o + } + } + + if len(subscribeOptionsMap) > 0 { + for tag, opt := range subscribeOptionsMap { + if opt.Type == "" { + return E.New("outbound ", tag, " not found") + } + } + } + + for _, o := range subscribeOptionsMap { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + err := subscribe.RequestAndCache(ctx, o.SubscribeOptions) + cancel() + if err != nil { + return E.Cause(err, "update subscribe '", o.Tag, "' fail") + } + log.Info("update subscribe '", o.Tag, "' success") + } + + return nil +} diff --git a/common/dialer/default.go b/common/dialer/default.go index b27b4820..ac91cf89 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -147,6 +147,80 @@ func NewDefault(router adapter.Router, options option.DialerOptions) *DefaultDia } } +func NewSimple(options option.DialerOptions) *DefaultDialer { + var dialer net.Dialer + var listener net.ListenConfig + if options.BindInterface != "" { + warnBindInterfaceOnUnsupportedPlatform.Check() + bindFunc := control.BindToInterface(control.DefaultInterfaceFinder(), options.BindInterface, -1) + dialer.Control = control.Append(dialer.Control, bindFunc) + listener.Control = control.Append(listener.Control, bindFunc) + } + if options.RoutingMark != 0 { + warnRoutingMarkOnUnsupportedPlatform.Check() + dialer.Control = control.Append(dialer.Control, control.RoutingMark(options.RoutingMark)) + listener.Control = control.Append(listener.Control, control.RoutingMark(options.RoutingMark)) + } + if options.ReuseAddr { + warnReuseAdderOnUnsupportedPlatform.Check() + listener.Control = control.Append(listener.Control, control.ReuseAddr()) + } + if options.ProtectPath != "" { + warnProtectPathOnNonAndroid.Check() + dialer.Control = control.Append(dialer.Control, control.ProtectPath(options.ProtectPath)) + listener.Control = control.Append(listener.Control, control.ProtectPath(options.ProtectPath)) + } + if options.ConnectTimeout != 0 { + dialer.Timeout = time.Duration(options.ConnectTimeout) + } else { + dialer.Timeout = C.TCPTimeout + } + if options.TCPFastOpen { + warnTFOOnUnsupportedPlatform.Check() + } + var udpFragment bool + if options.UDPFragment != nil { + udpFragment = *options.UDPFragment + } else { + udpFragment = options.UDPFragmentDefault + } + if !udpFragment { + dialer.Control = control.Append(dialer.Control, control.DisableUDPFragment()) + listener.Control = control.Append(listener.Control, control.DisableUDPFragment()) + } + var ( + dialer4 = dialer + udpDialer4 = dialer + udpAddr4 string + ) + if options.Inet4BindAddress != nil { + bindAddr := options.Inet4BindAddress.Build() + dialer4.LocalAddr = &net.TCPAddr{IP: bindAddr.AsSlice()} + udpDialer4.LocalAddr = &net.UDPAddr{IP: bindAddr.AsSlice()} + udpAddr4 = M.SocksaddrFrom(bindAddr, 0).String() + } + var ( + dialer6 = dialer + udpDialer6 = dialer + udpAddr6 string + ) + if options.Inet6BindAddress != nil { + bindAddr := options.Inet6BindAddress.Build() + dialer6.LocalAddr = &net.TCPAddr{IP: bindAddr.AsSlice()} + udpDialer6.LocalAddr = &net.UDPAddr{IP: bindAddr.AsSlice()} + udpAddr6 = M.SocksaddrFrom(bindAddr, 0).String() + } + return &DefaultDialer{ + tfo.Dialer{Dialer: dialer4, DisableTFO: !options.TCPFastOpen}, + tfo.Dialer{Dialer: dialer6, DisableTFO: !options.TCPFastOpen}, + udpDialer4, + udpDialer6, + listener, + udpAddr4, + udpAddr6, + } +} + func (d *DefaultDialer) DialContext(ctx context.Context, network string, address M.Socksaddr) (net.Conn, error) { if !address.IsValid() { return nil, E.New("invalid address") diff --git a/constant/proxy.go b/constant/proxy.go index f875d7e0..e09da74a 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -27,3 +27,5 @@ const ( TypeSelector = "selector" TypeURLTest = "urltest" ) + +const TypeSubscribe = "subscribe" diff --git a/option/outbound.go b/option/outbound.go index abea81a0..5c38d257 100644 --- a/option/outbound.go +++ b/option/outbound.go @@ -25,6 +25,7 @@ type _Outbound struct { VLESSOptions VLESSOutboundOptions `json:"-"` SelectorOptions SelectorOutboundOptions `json:"-"` URLTestOptions URLTestOutboundOptions `json:"-"` + SubscribeOptions SubscribeOutboundOptions `json:"-"` } type Outbound _Outbound @@ -64,6 +65,8 @@ func (h Outbound) MarshalJSON() ([]byte, error) { v = h.SelectorOptions case C.TypeURLTest: v = h.URLTestOptions + case C.TypeSubscribe: + v = h.SubscribeOptions default: return nil, E.New("unknown outbound type: ", h.Type) } @@ -109,6 +112,8 @@ func (h *Outbound) UnmarshalJSON(bytes []byte) error { v = &h.SelectorOptions case C.TypeURLTest: v = &h.URLTestOptions + case C.TypeSubscribe: + v = &h.SubscribeOptions default: return E.New("unknown outbound type: ", h.Type) } diff --git a/option/subscribe.go b/option/subscribe.go new file mode 100644 index 00000000..56469b43 --- /dev/null +++ b/option/subscribe.go @@ -0,0 +1,83 @@ +package option + +import ( + "github.com/sagernet/sing-box/common/json" + E "github.com/sagernet/sing/common/exceptions" + "regexp" +) + +type SubscribeOutboundOptions struct { + Url string `json:"url"` + CacheFile string `json:"cache_file,omitempty"` + ForceUpdateDuration Duration `json:"force_update_duration,omitempty"` + DNS string `json:"dns,omitempty"` + Filter *Filter `json:"filter,omitempty"` + RequestDialerOptions *RequestDialerOptions `json:"request_dialer,omitempty"` + DialerOptions *DialerOptions `json:"dialer,omitempty"` + ProxyGroupOptions + CustomGroup Listable[CustomGroupOptions] `json:"custom_group,omitempty"` +} + +type ProxyGroupOptions struct { + ProxyType string `json:"proxy_type"` + SelectorOptions *SelectorOutboundOptions `json:"selector,omitempty"` + URLTestOptions *URLTestOutboundOptions `json:"urltest,omitempty"` +} + +type CustomGroupOptions struct { + Tag string `json:"tag,omitempty"` + Filter *Filter `json:"filter,omitempty"` + ProxyGroupOptions +} + +type Filter struct { + WhiteMode bool `json:"white_mode,omitempty"` + Rule Listable[*regexp.Regexp] `json:"rule,omitempty"` +} + +type _filter struct { + WhiteMode bool `json:"white_mode,omitempty"` + Rule Listable[string] `json:"rule,omitempty"` +} + +func (f *Filter) UnmarshalJSON(content []byte) error { + var _f _filter + err := json.Unmarshal(content, &_f) + if err != nil { + return err + } + f.WhiteMode = _f.WhiteMode + f.Rule = make(Listable[*regexp.Regexp], 0) + for _, r := range _f.Rule { + reg, err := regexp.Compile(r) + if err != nil { + return E.New("invalid regexp: ", r) + } + f.Rule = append(f.Rule, reg) + } + return nil +} + +func (f Filter) MarshalJSON() ([]byte, error) { + _f := _filter{ + WhiteMode: f.WhiteMode, + Rule: make(Listable[string], 0), + } + for _, r := range f.Rule { + _f.Rule = append(_f.Rule, r.String()) + } + return json.Marshal(_f) +} + +type RequestDialerOptions struct { + BindInterface string `json:"bind_interface,omitempty"` + Inet4BindAddress *ListenAddress `json:"inet4_bind_address,omitempty"` + Inet6BindAddress *ListenAddress `json:"inet6_bind_address,omitempty"` + ProtectPath string `json:"protect_path,omitempty"` + RoutingMark int `json:"routing_mark,omitempty"` + ReuseAddr bool `json:"reuse_addr,omitempty"` + ConnectTimeout Duration `json:"connect_timeout,omitempty"` + TCPFastOpen bool `json:"tcp_fast_open,omitempty"` + UDPFragment *bool `json:"udp_fragment,omitempty"` + UDPFragmentDefault bool `json:"-"` +} diff --git a/option/types.go b/option/types.go index f4dd4b48..da6867be 100644 --- a/option/types.go +++ b/option/types.go @@ -82,7 +82,7 @@ func (v NetworkList) Build() []string { return strings.Split(string(v), "\n") } -type Listable[T comparable] []T +type Listable[T any] []T func (l Listable[T]) MarshalJSON() ([]byte, error) { arrayList := []T(l) diff --git a/outbound/builder.go b/outbound/builder.go index f032d83b..04e8c25a 100644 --- a/outbound/builder.go +++ b/outbound/builder.go @@ -2,6 +2,7 @@ package outbound import ( "context" + F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" @@ -11,6 +12,7 @@ import ( ) func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.Outbound) (adapter.Outbound, error) { + /** var metadata *adapter.InboundContext if tag != "" { ctx, metadata = adapter.AppendContext(ctx) @@ -20,6 +22,7 @@ func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, t return nil, E.New("missing outbound type") } ctx = ContextWithTag(ctx, tag) + */ switch options.Type { case C.TypeDirect: return NewDirect(router, logger, tag, options.DirectOptions) @@ -59,3 +62,25 @@ func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, t return nil, E.New("unknown outbound type: ", options.Type) } } + +func NewGroup(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.Outbound) ([]adapter.Outbound, error) { + var metadata *adapter.InboundContext + if tag != "" { + ctx, metadata = adapter.AppendContext(ctx) + metadata.Outbound = tag + } + if options.Type == "" { + return nil, E.New("missing outbound type") + } + ctx = ContextWithTag(ctx, tag) + switch options.Type { + case C.TypeSubscribe: + return NewSubscribe(ctx, router, logFactory, tag, options.SubscribeOptions) + default: + out, err := New(ctx, router, logFactory.NewLogger(F.ToString("outbound/", options.Type, "[", tag, "]")), tag, options) + if err != nil { + return nil, err + } + return []adapter.Outbound{out}, nil + } +} diff --git a/outbound/subscribe.go b/outbound/subscribe.go new file mode 100644 index 00000000..373c3252 --- /dev/null +++ b/outbound/subscribe.go @@ -0,0 +1,37 @@ +package outbound + +import ( + "context" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/subscribe" + F "github.com/sagernet/sing/common/format" +) + +func NewSubscribe(ctx context.Context, router adapter.Router, logFactory log.Factory, tag string, options option.SubscribeOutboundOptions) ([]adapter.Outbound, error) { + outboundOptions, err := subscribe.ParsePeer(ctx, tag, options) + if err != nil { + return nil, err + } + + outbounds := make([]adapter.Outbound, 0) + + for i, outOptions := range outboundOptions { + var out adapter.Outbound + var outTag string + if outOptions.Tag != "" { + outTag = outOptions.Tag + } else { + outTag = F.ToString(tag, ":", i) + outOptions.Tag = outTag + } + out, err := New(ctx, router, logFactory.NewLogger(F.ToString("outbound/", outOptions.Type, "[", outTag, "]")), outTag, outOptions) + if err != nil { + return nil, err + } + outbounds = append(outbounds, out) + } + + return outbounds, nil +} diff --git a/subscribe/dialer.go b/subscribe/dialer.go new file mode 100644 index 00000000..b0a65b0a --- /dev/null +++ b/subscribe/dialer.go @@ -0,0 +1,23 @@ +package subscribe + +import ( + D "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/option" + N "github.com/sagernet/sing/common/network" +) + +func NewDialer(options option.RequestDialerOptions) N.Dialer { + opt := option.DialerOptions{ + BindInterface: options.BindInterface, + Inet4BindAddress: options.Inet4BindAddress, + Inet6BindAddress: options.Inet6BindAddress, + ProtectPath: options.ProtectPath, + RoutingMark: options.RoutingMark, + ReuseAddr: options.ReuseAddr, + ConnectTimeout: options.ConnectTimeout, + TCPFastOpen: options.TCPFastOpen, + UDPFragment: options.UDPFragment, + UDPFragmentDefault: options.UDPFragmentDefault, + } + return D.NewSimple(opt) +} diff --git a/subscribe/dns.go b/subscribe/dns.go new file mode 100644 index 00000000..d9d34f8b --- /dev/null +++ b/subscribe/dns.go @@ -0,0 +1,320 @@ +package subscribe + +import ( + "context" + "fmt" + mDNS "github.com/miekg/dns" + dns "github.com/sagernet/sing-dns" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "net" + "net/netip" + "net/url" + "strings" + "sync" + "time" +) + +const ( + queryTimeout = 5 * time.Second + DefaultUDPDNS = "223.5.5.5" +) + +type DNS struct { + ctx context.Context + transport dns.Transport + dialer N.Dialer +} + +func NewDNS(ctx context.Context, addr string, dialer N.Dialer) (*DNS, error) { + + switch { + case strings.Index(addr, "tcp://") == 0: + // tcp dns + addr = strings.TrimPrefix(addr, "tcp://") + + // check is ip + ip, err := netip.ParseAddr(addr) + if err == nil { + d := &DNS{} + d.ctx = ctx + d.dialer = dialer + d.transport, err = dns.NewTCPTransport("dns-tcp", ctx, d.dialer, M.ParseSocksaddr(net.JoinHostPort(ip.String(), "53"))) + if err != nil { + return nil, err + } + + return d, nil + } + + // check is ip:port + host, port, err := net.SplitHostPort(addr) + if err == nil { + ip, err := netip.ParseAddr(host) + if err != nil { + return nil, fmt.Errorf("invalid dns address: %s", "tcp://"+addr) + } + d := &DNS{} + d.ctx = ctx + d.dialer = dialer + d.transport, err = dns.NewTCPTransport("dns-tcp", ctx, d.dialer, M.ParseSocksaddr(net.JoinHostPort(ip.String(), port))) + if err != nil { + return nil, err + } + + return d, nil + } + + return nil, fmt.Errorf("invalid dns address: %s", "tcp://"+addr) + case strings.Index(addr, "udp://") == 0: + // udp dns + addr = strings.TrimPrefix(addr, "udp://") + + // check is ip + ip, err := netip.ParseAddr(addr) + if err == nil { + d := &DNS{} + d.ctx = ctx + d.dialer = dialer + d.transport, err = dns.NewUDPTransport("dns-udp", ctx, d.dialer, M.ParseSocksaddr(net.JoinHostPort(ip.String(), "53"))) + if err != nil { + return nil, err + } + + return d, nil + } + + // check is ip:port + host, port, err := net.SplitHostPort(addr) + if err == nil { + ip, err := netip.ParseAddr(host) + if err != nil { + return nil, fmt.Errorf("invalid dns address: %s", "udp://"+addr) + } + + d := &DNS{} + d.ctx = ctx + d.dialer = dialer + d.transport, err = dns.NewUDPTransport("dns-udp", ctx, d.dialer, M.ParseSocksaddr(net.JoinHostPort(ip.String(), port))) + if err != nil { + return nil, err + } + + return d, nil + } + + return nil, fmt.Errorf("invalid dns address: %s", "udp://"+addr) + case strings.Index(addr, "tls://") == 0: + // dot dns + addr = strings.TrimPrefix(addr, "tls://") + + // check is ip + ip, err := netip.ParseAddr(addr) + if err == nil { + d := &DNS{} + d.ctx = ctx + d.dialer = dialer + d.transport, err = dns.NewTLSTransport("dns-dot", ctx, d.dialer, M.ParseSocksaddr(net.JoinHostPort(ip.String(), "853"))) + if err != nil { + return nil, err + } + + return d, nil + } + + // check is ip:port + host, port, err := net.SplitHostPort(addr) + if err == nil { + ip, err := netip.ParseAddr(host) + if err != nil { + return nil, fmt.Errorf("invalid dns address: %s", "tls://"+addr) + } + + d := &DNS{} + d.ctx = ctx + d.dialer = dialer + d.transport, err = dns.NewTLSTransport("dns-dot", ctx, d.dialer, M.ParseSocksaddr(net.JoinHostPort(ip.String(), port))) + if err != nil { + return nil, err + } + + return d, nil + } + + return nil, fmt.Errorf("invalid dns address: %s", "tls://"+addr) + case strings.Index(addr, "https://") == 0: + // doh dns + u, err := url.Parse(addr) + if err != nil { + return nil, fmt.Errorf("invalid dns address: %s, err: %s", addr, err) + } + + if u.Fragment != "" || u.RawFragment != "" || u.RawQuery != "" { + return nil, fmt.Errorf("invalid dns address: %s", addr) + } + + hostAddr := u.Host + + // check is ip + ip, err := netip.ParseAddr(hostAddr) + if err == nil { + d := &DNS{} + d.ctx = ctx + d.dialer = dialer + uNew := *u + uNew.Host = net.JoinHostPort(ip.String(), "443") + d.transport = dns.NewHTTPSTransport("dns-https", d.dialer, uNew.String()) + + return d, nil + } + + // check is ip:port + host, port, err := net.SplitHostPort(hostAddr) + if err == nil { + ip, err := netip.ParseAddr(host) + if err != nil { + return nil, fmt.Errorf("invalid dns address: %s", "https://"+addr) + } + + d := &DNS{} + d.ctx = ctx + d.dialer = dialer + uNew := *u + uNew.Host = net.JoinHostPort(ip.String(), port) + d.transport = dns.NewHTTPSTransport("dns-https", d.dialer, uNew.String()) + + return d, nil + } + + return nil, fmt.Errorf("invalid dns address: %s, domain is not supported", addr) + case addr == "": + ip, err := netip.ParseAddr(DefaultUDPDNS) + if err != nil { + return nil, fmt.Errorf("invalid dns address: %s", DefaultUDPDNS) + } + + d := &DNS{} + d.ctx = ctx + d.dialer = dialer + d.transport, err = dns.NewUDPTransport("dns-udp", ctx, d.dialer, M.ParseSocksaddr(net.JoinHostPort(ip.String(), "53"))) + if err != nil { + return nil, err + } + + return d, nil + default: + // check is udp dns + + // check is ip + ip, err := netip.ParseAddr(addr) + if err == nil { + d := &DNS{} + d.ctx = ctx + d.dialer = dialer + d.transport, err = dns.NewUDPTransport("dns-udp", ctx, d.dialer, M.ParseSocksaddr(net.JoinHostPort(ip.String(), "53"))) + if err != nil { + return nil, err + } + + return d, nil + } + + // check is ip:port + host, port, err := net.SplitHostPort(addr) + if err == nil { + d := &DNS{} + d.ctx = ctx + d.dialer = dialer + d.transport, err = dns.NewUDPTransport("dns-udp", ctx, d.dialer, M.ParseSocksaddr(net.JoinHostPort(host, port))) + if err != nil { + return nil, err + } + + return d, nil + } + + return nil, fmt.Errorf("invalid dns address: %s", addr) + } +} + +func (d *DNS) Query(msg *mDNS.Msg) (*mDNS.Msg, error) { + ctx, cancel := context.WithTimeout(d.ctx, queryTimeout) + defer cancel() + + return d.transport.Exchange(ctx, msg) +} + +func (d *DNS) QueryTypeA(domain string) ([]string, error) { + msg := new(mDNS.Msg) + msg.SetQuestion(mDNS.Fqdn(domain), mDNS.TypeA) + m, err := d.Query(msg) + if err != nil { + return nil, err + } + ips := make([]string, 0) + for _, a := range m.Answer { + if a.Header().Rrtype == mDNS.TypeA { + ips = append(ips, a.(*mDNS.A).A.String()) + } + } + if len(ips) == 0 { + return nil, fmt.Errorf("no A record") + } + return ips, nil +} + +func (d *DNS) QueryTypeAAAA(domain string) ([]string, error) { + msg := new(mDNS.Msg) + msg.SetQuestion(mDNS.Fqdn(domain), mDNS.TypeAAAA) + m, err := d.Query(msg) + if err != nil { + return nil, err + } + ips := make([]string, 0) + for _, a := range m.Answer { + if a.Header().Rrtype == mDNS.TypeAAAA { + ips = append(ips, a.(*mDNS.AAAA).AAAA.String()) + } + } + if len(ips) == 0 { + return nil, fmt.Errorf("no A record") + } + return ips, nil +} + +func (d *DNS) QueryIP(domain string) ([]string, error) { + wg := sync.WaitGroup{} + ch := make(chan []string, 2) + wg.Add(1) + go func() { + defer wg.Done() + msg, err := d.QueryTypeA(domain) + if err != nil { + return + } + ch <- msg + }() + wg.Add(1) + go func() { + defer wg.Done() + msg, err := d.QueryTypeAAAA(domain) + if err != nil { + return + } + ch <- msg + }() + wg.Wait() + ips := make([]string, 0) + for { + select { + case m := <-ch: + ips = append(ips, m...) + default: + close(ch) + if len(ips) == 0 { + return nil, fmt.Errorf("no ip found") + } + return ips, nil + } + } +} diff --git a/subscribe/proxy/http.go b/subscribe/proxy/http.go new file mode 100644 index 00000000..f0e61d77 --- /dev/null +++ b/subscribe/proxy/http.go @@ -0,0 +1,97 @@ +package proxy + +import ( + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "net" + "net/url" + "strconv" +) + +type ProxyHTTP struct { + Tag string // 标签 + Type string // 代理类型 + // + Dialer option.DialerOptions + // + Address string // IP地址或域名 + Port uint16 // 端口 + Username string // 用户名 + Password string // 密码 + TLSEnable bool // 是否启用TLS +} + +func (p *ProxyHTTP) GetTag() string { + return p.Tag +} + +func (p *ProxyHTTP) GetType() string { + return C.TypeHTTP +} + +func (p *ProxyHTTP) ParseLink(link string) error { + u, err := url.Parse(link) + if err != nil { + return err + } + + p.Address = u.Hostname() + if u.Port() == "" { + if u.Scheme == "https" { + p.Port = 443 + } else { + p.Port = 80 + } + } else { + portUint16, err := strconv.ParseUint(u.Port(), 10, 16) + if err != nil { + return err + } + p.Port = uint16(portUint16) + } + p.Username = u.User.Username() + p.Password, _ = u.User.Password() + + p.Tag = u.Fragment + + if p.Tag == "" { + p.Tag = net.JoinHostPort(p.Address, strconv.FormatUint(uint64(p.Port), 10)) + } + + if u.Scheme == "https" { + p.TLSEnable = true + } + + p.Type = C.TypeHTTP + + return nil +} + +func (p *ProxyHTTP) SetDialer(dialer option.DialerOptions) { + p.Dialer = dialer +} + +func (p *ProxyHTTP) GenerateOutboundOptions() (option.Outbound, error) { + out := option.Outbound{ + Tag: p.Tag, + Type: C.TypeHTTP, + HTTPOptions: option.HTTPOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: p.Address, + ServerPort: p.Port, + }, + Username: p.Username, + Password: p.Password, + }, + } + + if p.TLSEnable { + out.HTTPOptions.TLS = &option.OutboundTLSOptions{ + Enabled: true, + } + } + + out.HTTPOptions.DialerOptions = p.Dialer + + return out, nil +} diff --git a/subscribe/proxy/hysteria.go b/subscribe/proxy/hysteria.go new file mode 100644 index 00000000..3328039f --- /dev/null +++ b/subscribe/proxy/hysteria.go @@ -0,0 +1,162 @@ +package proxy + +import ( + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "net" + "net/url" + "strconv" + "strings" +) + +type ProxyHysteria struct { + Tag string // 标签 + Type string // 代理类型 + // + Dialer option.DialerOptions + // + Address string // IP地址或域名 + Port uint16 // 端口 + Protocol string // 协议 udp/wechat-video/faketcp + AuthStr string // 认证字符串 + SNI string // TLS SNI + Insecure bool // TLS Insecure + UpMbps uint64 // 上行速度 + DownMbps uint64 // 下行速度 + ALPN []string // QUIC TLS ALPN + Obfs string // 混淆 + ObfsParam string // 混淆参数 +} + +func (p *ProxyHysteria) GetType() string { + return C.TypeHysteria +} + +func (p *ProxyHysteria) GetTag() string { + return p.Tag +} + +func (p *ProxyHysteria) ParseLink(link string) error { + configStr := strings.TrimPrefix(link, "hysteria://") + u, err := url.Parse("http://" + configStr) + if err != nil { + return E.New("invalid hysteria link") + } + host, port, err := net.SplitHostPort(u.Host) + if err != nil { + return E.New("invalid hysteria link") + } + portUint, err := strconv.ParseUint(port, 10, 16) + if err != nil { + return E.New("invalid hysteria link") + } + query := u.Query() + if query == nil { + return E.New("invalid hysteria link") + } + + p.Address = host + p.Port = uint16(portUint) + + if query.Get("protocol") != "" { + p.Protocol = query.Get("protocol") + } else { + p.Protocol = "udp" + } + + if query.Get("auth") != "" { + p.AuthStr = query.Get("auth") + } + if query.Get("upmbps") != "" { + upMbpsStr := query.Get("upmbps") + upMbps, err := strconv.ParseUint(upMbpsStr, 10, 32) + if err != nil { + return E.New("invalid hysteria link") + } + p.UpMbps = upMbps + } else { + return E.New("invalid hysteria link") + } + if query.Get("downmbps") != "" { + downMbpsStr := query.Get("upmbps") + downMbps, err := strconv.ParseUint(downMbpsStr, 10, 32) + if err != nil { + return E.New("invalid hysteria link") + } + p.DownMbps = downMbps + } else { + return E.New("invalid hysteria link") + } + + if query.Get("obfs") != "" { + p.Obfs = query.Get("obfs") + if query.Get("obfsParam") != "" { + p.ObfsParam = query.Get("obfsParam") + } + } + + if query.Get("peer") != "" { + p.SNI = query.Get("peer") + } + + if query.Get("insecure") == "1" { + p.Insecure = true + } + + if query.Get("alpn") != "" { + p.ALPN = []string{query.Get("alpn")} + } + + if u.Fragment != "" { + p.Tag = u.Fragment + } else { + p.Tag = net.JoinHostPort(host, port) + } + + p.Type = C.TypeHysteria + + return nil +} + +func (p *ProxyHysteria) SetDialer(dialer option.DialerOptions) { + p.Dialer = dialer +} + +func (p *ProxyHysteria) GenerateOutboundOptions() (option.Outbound, error) { + + if p.Protocol != "udp" { + return option.Outbound{}, E.New("hysteria protocol '", p.Protocol, "' not supported in sing-box") + } + + /** + if p.Obfs == "xplus" { + return nil, E.New("hysteria obfs `%s` not supported in sing-box", p.Obfs) + } + */ + + out := option.Outbound{ + Tag: p.Tag, + Type: C.TypeHysteria, + HysteriaOptions: option.HysteriaOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: p.Address, + ServerPort: p.Port, + }, + AuthString: p.AuthStr, + UpMbps: int(p.UpMbps), + DownMbps: int(p.DownMbps), + Obfs: p.ObfsParam, + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: p.SNI, + Insecure: p.Insecure, + ALPN: p.ALPN, + }, + }, + } + + out.HysteriaOptions.DialerOptions = p.Dialer + + return out, nil +} diff --git a/subscribe/proxy/proxy.go b/subscribe/proxy/proxy.go new file mode 100644 index 00000000..7619b88f --- /dev/null +++ b/subscribe/proxy/proxy.go @@ -0,0 +1,93 @@ +package proxy + +import ( + "fmt" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "strings" +) + +type Proxy interface { + GetTag() string // 获取节点名称 + GetType() string // 获取节点类型 + SetDialer(dialer option.DialerOptions) // 设置Dialer + ParseLink(link string) error // 解析链接 + // ParseClash(config string) error // 解析Clash配置 + GenerateOutboundOptions() (option.Outbound, error) // 获取配置 +} + +func CheckLink(link string) string { + switch { + case strings.Index(link, "http://") == 0: + return C.TypeHTTP + case strings.Index(link, "https://") == 0: + return C.TypeHTTP + case strings.Index(link, "socks") == 0: + return C.TypeSocks + case strings.Index(link, "socks4") == 0: + return C.TypeSocks + case strings.Index(link, "socks4a") == 0: + return C.TypeSocks + case strings.Index(link, "socks5") == 0: + return C.TypeSocks + case strings.Index(link, "socks5h") == 0: + return C.TypeSocks + case strings.Index(link, "vmess://") == 0: + return C.TypeVMess + case strings.Index(link, "ss://") == 0: + return C.TypeShadowsocks + case strings.Index(link, "ssr://") == 0: + return C.TypeShadowsocksR + case strings.Index(link, "trojan://") == 0: + return C.TypeTrojan + case strings.Index(link, "hysteria://") == 0: + return C.TypeHysteria + default: + return "" + } +} + +func ParsePeers(content string) ([]Proxy, error) { + raw, err := Base64Decode(content) + if err != nil { + return nil, fmt.Errorf("parse peers failed: %s", err) + } + + peerLinks := strings.Split(string(raw), "\r\n") + + peers := make([]Proxy, 0) + + for _, link := range peerLinks { + proxyType := CheckLink(link) + var proxy Proxy + switch proxyType { + case C.TypeHTTP: + proxy = &ProxyHTTP{} + case C.TypeSocks: + proxy = &ProxySocks{} + case C.TypeVMess: + proxy = &ProxyVMess{} + case C.TypeShadowsocks: + proxy = &ProxyShadowsocks{} + case C.TypeShadowsocksR: + proxy = &ProxyShadowsocksR{} + case C.TypeTrojan: + proxy = &ProxyTrojan{} + case C.TypeHysteria: + proxy = &ProxyHysteria{} + default: + continue + } + err := proxy.ParseLink(link) + if err != nil { + continue + } + peers = append(peers, proxy) + } + + if len(peers) == 0 { + return nil, fmt.Errorf("no valid peers") + } + + return peers, nil +} diff --git a/subscribe/proxy/shadowsocks.go b/subscribe/proxy/shadowsocks.go new file mode 100644 index 00000000..d4109b08 --- /dev/null +++ b/subscribe/proxy/shadowsocks.go @@ -0,0 +1,181 @@ +package proxy + +import ( + "encoding/base64" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "net" + "net/url" + "strconv" + "strings" +) + +type ProxyShadowsocks struct { + Tag string // 标签 + Type string // 代理类型 + // + Dialer option.DialerOptions + // + Address string // IP地址或域名 + Port uint16 // 端口 + Method string // 加密方式 + Password string // 密码 + Plugin string // 插件 + PluginOptions string // 插件选项 +} + +func (p *ProxyShadowsocks) GetType() string { + return C.TypeSocks +} + +func (p *ProxyShadowsocks) GetTag() string { + return p.Tag +} + +func (p *ProxyShadowsocks) ParseLink(link string) error { + configStr := strings.TrimPrefix(link, "ss://") + + r := func(uri string) int { + func() { + var ( + suri = strings.Split(uri, "#") + stag = "" + ) + if len(suri) <= 2 { + if len(suri) == 2 { + stag = "#" + suri[1] + } + suriDecode, err := Base64Decode(suri[0]) + if err != nil { + return + } + uri = string(suriDecode) + stag + } + }() + u, err := url.Parse("http://" + uri) + if err != nil { + return 1 + } + if u.Host == "" { + return 1 + } + host, port, err := net.SplitHostPort(u.Host) + if err != nil { + host = u.Hostname() + port = "80" + } + portUint, err := strconv.ParseUint(port, 10, 16) + if err != nil || portUint == 0 || portUint > 65535 { + return 1 + } + var userinfo []string + if u.User != nil { + username := u.User.Username() + password, _ := u.User.Password() + if username != "" && password != "" { + userinfo = []string{username, password} + } else if username != "" { + usernameDecode, err := base64.RawURLEncoding.DecodeString(username) + if err != nil { + return 1 + } + userinfo = strings.Split(string(usernameDecode), ":") + if len(userinfo) != 2 { + return 1 + } + } + } else { + return 1 + } + + p.Address = host + p.Port = uint16(portUint) + p.Method = userinfo[0] + p.Password = userinfo[1] + + if u.RawQuery != "" { + plugin := u.Query().Get("plugin") + if plugin != "" { + pluginInfo := strings.Split(plugin, ";") + pi := pluginInfo[0] + p.Plugin = pi + if pi != "" { + pluginOpts := strings.Join(pluginInfo[1:], ";") + p.PluginOptions = pluginOpts + } + } + } + + if u.Fragment != "" { + p.Tag = u.Fragment + } else { + p.Tag = net.JoinHostPort(host, port) + } + + return 0 + + }(configStr) + + if r != 0 { + uriSlice := strings.Split(configStr, "@") + if len(uriSlice) < 2 { + return E.New("invalid shadowsocks link") + } else if len(uriSlice) > 2 { + uriSlice = []string{strings.Join(uriSlice[:len(uriSlice)-1], "@"), uriSlice[len(uriSlice)-1]} + } + host, port, err := net.SplitHostPort(uriSlice[1]) + if err != nil { + return E.New("invalid shadowsocks link") + } + portUint, err := strconv.ParseUint(port, 10, 16) + if err != nil || portUint == 0 || portUint > 65535 { + return E.New("invalid shadowsocks link") + } + authInfo := strings.SplitN(uriSlice[0], ":", 2) + + p.Address = host + p.Port = uint16(portUint) + p.Method = authInfo[0] + p.Password = authInfo[1] + + p.Tag = net.JoinHostPort(host, port) + } + + p.Type = C.TypeShadowsocks + + return nil +} + +func (p *ProxyShadowsocks) SetDialer(dialer option.DialerOptions) { + p.Dialer = dialer +} + +func (p *ProxyShadowsocks) GenerateOutboundOptions() (option.Outbound, error) { + + if !checkShadowsocksAllowMethod(p.Method) { + return option.Outbound{}, E.New("shadowsocks method '", p.Method, "' is not supported in sing-box") + } + + out := option.Outbound{ + Tag: p.Tag, + Type: C.TypeShadowsocks, + ShadowsocksOptions: option.ShadowsocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: p.Address, + ServerPort: p.Port, + }, + Password: p.Password, + Method: p.Method, + }, + } + + if p.Plugin != "" { + out.ShadowsocksOptions.Plugin = p.Plugin + out.ShadowsocksOptions.PluginOptions = p.PluginOptions + } + + out.ShadowsocksOptions.DialerOptions = p.Dialer + + return out, nil +} diff --git a/subscribe/proxy/shadowsocksr.go b/subscribe/proxy/shadowsocksr.go new file mode 100644 index 00000000..5ee4bb6c --- /dev/null +++ b/subscribe/proxy/shadowsocksr.go @@ -0,0 +1,143 @@ +package proxy + +import ( + "encoding/base64" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "net" + "net/url" + "strconv" + "strings" +) + +type ProxyShadowsocksR struct { + Tag string // 标签 + Type string // 代理类型 + // + Dialer option.DialerOptions + // + Address string // IP地址或域名 + Port uint16 // 端口 + Method string // 加密方式 + Password string // 密码 + Obfs string // 混淆 + ObfsParam string // 混淆参数 + Protocol string // 协议 + ProtocolParam string // 协议参数 +} + +func (p *ProxyShadowsocksR) GetType() string { + return C.TypeShadowsocksR +} + +func (p *ProxyShadowsocksR) GetTag() string { + return p.Tag +} + +func (p *ProxyShadowsocksR) ParseLink(link string) error { + link = strings.TrimPrefix(link, "ssr://") + uriDecodeBytes, err := base64.StdEncoding.DecodeString(link) + if err != nil { + return E.New("invalid shadowsocksR link: ", err.Error()) + } + uriSlice := strings.Split(string(uriDecodeBytes), "/") + userInfo := strings.Split(uriSlice[0], ":") + if len(userInfo) != 6 { + return E.New("invalid shadowsocksR link") + } + _, params, found := strings.Cut(uriSlice[1], "?") + if !found { + return E.New("invalid shadowsocksR link") + } + query, err := url.ParseQuery(params) + if err != nil { + return E.New("invalid shadowsocksR link: ", err.Error()) + } + var ( + protoParam string + obfsParam string + remarks string + ) + if query.Get("protoparam") != "" { + protoParamBytes, err := base64.StdEncoding.DecodeString(query.Get("protoparam")) + if err == nil { + protoParam = string(protoParamBytes) + } + } + if query.Get("obfsparam") != "" { + obfsParamBytes, err := base64.StdEncoding.DecodeString(query.Get("obfsparam")) + if err == nil { + obfsParam = string(obfsParamBytes) + } + } + if query.Get("remarks") != "" { + remarksBytes, err := base64.StdEncoding.DecodeString(query.Get("remarks")) + if err == nil { + remarks = string(remarksBytes) + } + } + var ( + server = userInfo[0] + serverPort = userInfo[1] + protocol = userInfo[2] + method = userInfo[3] + obfs = userInfo[4] + passwordBase64 = userInfo[5] + password string + ) + passwordBytes, err := base64.StdEncoding.DecodeString(passwordBase64) + if err != nil { + return E.New("invalid shadowsocksR link") + } + password = string(passwordBytes) + portUint, err := strconv.ParseUint(serverPort, 10, 16) + if err != nil || portUint == 0 || portUint > 65535 { + return E.New("invalid shadowsocksR link") + } + if remarks == "" { + remarks = net.JoinHostPort(server, serverPort) + } + + p.Address = server + p.Port = uint16(portUint) + p.Method = method + p.Password = password + p.Obfs = obfs + p.ObfsParam = obfsParam + p.Protocol = protocol + p.ProtocolParam = protoParam + + p.Tag = remarks + + p.Type = C.TypeShadowsocksR + + return nil +} + +func (p *ProxyShadowsocksR) SetDialer(dialer option.DialerOptions) { + p.Dialer = dialer +} + +func (p *ProxyShadowsocksR) GenerateOutboundOptions() (option.Outbound, error) { + out := option.Outbound{ + Tag: p.Tag, + Type: C.TypeShadowsocksR, + ShadowsocksROptions: option.ShadowsocksROutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: p.Address, + ServerPort: p.Port, + }, + Method: p.Method, + Password: p.Password, + Obfs: p.Obfs, + ObfsParam: p.ObfsParam, + Protocol: p.Protocol, + ProtocolParam: p.ProtocolParam, + }, + } + + out.ShadowsocksROptions.DialerOptions = p.Dialer + + return out, nil +} diff --git a/subscribe/proxy/socks.go b/subscribe/proxy/socks.go new file mode 100644 index 00000000..dfc839b7 --- /dev/null +++ b/subscribe/proxy/socks.go @@ -0,0 +1,92 @@ +package proxy + +import ( + C "github.com/sagernet/sing-box/constant" + option "github.com/sagernet/sing-box/option" + "net" + "net/url" + "strconv" + "strings" +) + +type ProxySocks struct { + Tag string // 标签 + Type string // 代理类型 + // + Dialer option.DialerOptions + // + Address string // IP地址或域名 + Port uint16 // 端口 + Username string // 用户名 + Password string // 密码 + SocksVersion string // SOCKS版本 +} + +func (p *ProxySocks) GetType() string { + return C.TypeSocks +} + +func (p *ProxySocks) GetTag() string { + return p.Tag +} + +func (p *ProxySocks) ParseLink(link string) error { + u, err := url.Parse(link) + if err != nil { + return err + } + + p.Address = u.Hostname() + if u.Port() == "" { + p.Port = 80 + } else { + portUint16, err := strconv.ParseUint(u.Port(), 10, 16) + if err != nil { + return err + } + p.Port = uint16(portUint16) + } + + p.Username = u.User.Username() + p.Password, _ = u.User.Password() + + if strings.Index(u.Scheme, "4") > 0 { + p.SocksVersion = "4" + } else { + p.SocksVersion = "5" + } + + p.Tag = u.Fragment + + if p.Tag == "" { + p.Tag = net.JoinHostPort(p.Address, strconv.FormatUint(uint64(p.Port), 10)) + } + + p.Type = C.TypeSocks + + return nil +} + +func (p *ProxySocks) SetDialer(dialer option.DialerOptions) { + p.Dialer = dialer +} + +func (p *ProxySocks) GenerateOutboundOptions() (option.Outbound, error) { + out := option.Outbound{ + Tag: p.Tag, + Type: C.TypeSocks, + SocksOptions: option.SocksOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: p.Address, + ServerPort: p.Port, + }, + Username: p.Username, + Password: p.Password, + Version: p.SocksVersion, + }, + } + + out.SocksOptions.DialerOptions = p.Dialer + + return out, nil +} diff --git a/subscribe/proxy/tools.go b/subscribe/proxy/tools.go new file mode 100644 index 00000000..45da4bf0 --- /dev/null +++ b/subscribe/proxy/tools.go @@ -0,0 +1,44 @@ +package proxy + +import "encoding/base64" + +func Base64Decode(rawData string) ([]byte, error) { + r, err := base64.RawURLEncoding.DecodeString(rawData) + if err == nil { + return r, nil + } + r, err = base64.URLEncoding.DecodeString(rawData) + if err == nil { + return r, nil + } + r, err = base64.StdEncoding.DecodeString(rawData) + if err == nil { + return r, nil + } + return nil, err +} + +var allowShadowsocksMethod = map[string]bool{ + "none": true, + "2022-blake3-aes-128-gcm": true, + "2022-blake3-aes-256-gcm": true, + "2022-blake3-chacha20-poly1305": true, + "aes-128-gcm": true, + "aes-192-gcm": true, + "aes-256-gcm": true, + "chacha20-ietf-poly1305": true, + "xchacha20-ietf-poly1305": true, + "aes-128-ctr": true, + "aes-192-ctr": true, + "aes-256-ctr": true, + "aes-128-cfb": true, + "aes-192-cfb": true, + "aes-256-cfb": true, + "rc4-md5": true, + "chacha20-ietf": true, + "xchacha20": true, +} + +func checkShadowsocksAllowMethod(method string) bool { + return allowShadowsocksMethod[method] +} diff --git a/subscribe/proxy/trojan.go b/subscribe/proxy/trojan.go new file mode 100644 index 00000000..ca96c32d --- /dev/null +++ b/subscribe/proxy/trojan.go @@ -0,0 +1,92 @@ +package proxy + +import ( + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "net" + "net/url" + "strconv" + "strings" +) + +type ProxyTrojan struct { + Tag string // 标签 + Type string // 代理类型 + // + Dialer option.DialerOptions + // + Address string // IP地址或域名 + Port uint16 // 端口 + Password string // 密码 + SNI string // SNI +} + +func (p *ProxyTrojan) GetTag() string { + return p.Tag +} + +func (p *ProxyTrojan) GetType() string { + return C.TypeVMess +} + +func (p *ProxyTrojan) ParseLink(link string) error { + link = strings.TrimPrefix(link, "trojan://") + u, err := url.Parse("http://" + link) + if err != nil { + return E.New("invalid trojan link: ", err.Error()) + } + if u.User == nil || u.User.Username() == "" { + return E.New("invalid trojan link") + } + host, port, err := net.SplitHostPort(u.Host) + if err != nil { + host = u.Hostname() + port = "443" + } + portUint, err := strconv.ParseUint(port, 10, 16) + if err != nil || portUint == 0 || portUint > 65535 { + return E.New("invalid trojan link: ", err.Error()) + } + + p.Address = host + p.Port = uint16(portUint) + p.Password = u.User.Username() + p.SNI = u.Query().Get("sni") + + if u.Fragment != "" { + p.Tag = u.Fragment + } else { + p.Tag = u.Host + } + + p.Type = C.TypeTrojan + + return nil +} + +func (p *ProxyTrojan) SetDialer(dialer option.DialerOptions) { + p.Dialer = dialer +} + +func (p *ProxyTrojan) GenerateOutboundOptions() (option.Outbound, error) { + out := option.Outbound{ + Tag: p.Tag, + Type: C.TypeTrojan, + TrojanOptions: option.TrojanOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: p.Address, + ServerPort: p.Port, + }, + Password: p.Password, + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: p.SNI, + }, + }, + } + + out.TrojanOptions.DialerOptions = p.Dialer + + return out, nil +} diff --git a/subscribe/proxy/vmess.go b/subscribe/proxy/vmess.go new file mode 100644 index 00000000..066617a0 --- /dev/null +++ b/subscribe/proxy/vmess.go @@ -0,0 +1,269 @@ +package proxy + +import ( + "encoding/json" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "net" + "strconv" + "strings" +) + +type ProxyVMess struct { + Tag string // 标签 + Type string // 代理类型 + // + Dialer option.DialerOptions + // + Address string // IP地址或域名 + Port uint16 // 端口 + UUID string // UUID + AlterID uint16 // AlterID + Encrypt string // 加密方式 + TLSEnable bool // 是否启用TLS + TLSSNI string // TLS SNI + TLSALPN []string // TLS ALPN + // + TransportMode string // 传输方式 TCP/KCP/WS/H2/QUIC/GRPC + // + TransportFakeType string // 伪装类型 none/http/srtp/utp/wechat-video + // + TransportTCPHost []string // TCP Host + // + TransportH2Host string // HTTP/2 Host + TransportH2Path string // HTTP/2 Path + // + TransportWSHost string // WebSocket Host + TransportWSEarlyDataHeader string // WebSocket Early-Data Header + TransportWSPath string // WebSocket Path + // + TransportGRPCServiceName string // gRPC Service Name + // + TransportQUICSecurity string // QUIC Security + TransportQUICKey string // QUIC Key + // + TransportKCPSeed string // KCP Seed +} + +type configVMessJSON struct { + Version string `json:"v"` + Tag string `json:"ps"` + Address string `json:"add"` + Port string `json:"port"` + UUID string `json:"id"` + AlterID string `json:"aid"` + Security string `json:"scy"` + Network string `json:"net"` + Type string `json:"type"` + Host string `json:"host"` + Path string `json:"path"` + TLS string `json:"tls"` + SNI string `json:"sni"` +} + +func (p *ProxyVMess) GetTag() string { + return p.Tag +} + +func (p *ProxyVMess) GetType() string { + return C.TypeVMess +} + +func (p *ProxyVMess) ParseLink(link string) error { + configStr := strings.TrimPrefix(link, "vmess://") + + configDec, err := Base64Decode(configStr) + if err != nil { + return err + } + + var j configVMessJSON + + err = json.Unmarshal([]byte(configDec), &j) + if err != nil { + return err + } + + p.Address = j.Address + portUint16, err := strconv.ParseUint(j.Port, 10, 16) + if err != nil { + return err + } + p.Port = uint16(portUint16) + + p.UUID = j.UUID + + if j.AlterID != "" { + alterIDInt, err := strconv.ParseInt(j.AlterID, 10, 16) + if err != nil { + return err + } + p.AlterID = uint16(alterIDInt) + } + + if j.Security != "" { + p.Encrypt = j.Security + } else { + p.Encrypt = "auto" + } + + switch j.Network { + case "tcp": + p.TransportMode = "TCP" + if j.Type != "" { + p.TransportFakeType = j.Type + } + if j.Host != "" { + p.TransportTCPHost = strings.Split(j.Host, ",") + } + case "kcp": + p.TransportMode = "KCP" + if j.Type != "" { + p.TransportFakeType = j.Type + } + if j.Path != "" { + p.TransportKCPSeed = j.Path + } + case "ws": + p.TransportMode = "WS" + if j.Host != "" { + p.TransportWSHost = j.Host + } + if j.Path != "" { + p.TransportWSPath = j.Path + } + case "h2": + p.TransportMode = "H2" + if j.Host != "" { + p.TransportH2Host = j.Host + } + if j.Path != "" { + p.TransportH2Path = j.Path + } + case "quic": + p.TransportMode = "QUIC" + if j.Type != "" { + p.TransportFakeType = j.Type + } + if j.Host != "" { + p.TransportQUICSecurity = j.Host + } + if j.Path != "" { + p.TransportQUICKey = j.Path + } + case "grpc": + p.TransportMode = "GRPC" + if j.Path != "" { + p.TransportGRPCServiceName = j.Path + } + default: + return E.New("unknown net: ", j.Network) + } + + if j.TLS != "" { + p.TLSEnable = true + if j.SNI != "" { + p.TLSSNI = j.SNI + } + } + + if j.Tag != "" { + p.Tag = j.Tag + } else { + p.Tag = net.JoinHostPort(p.Address, strconv.Itoa(int(p.Port))) + } + + p.Type = C.TypeVMess + + return nil +} + +func (p *ProxyVMess) SetDialer(dialer option.DialerOptions) { + p.Dialer = dialer +} + +func (p *ProxyVMess) GenerateOutboundOptions() (option.Outbound, error) { + + if p.TransportMode == "KCP" { + return option.Outbound{}, E.New("vmess kcp not supported in sing-box") + } + + if p.TransportFakeType != "" && p.TransportFakeType != "none" && p.TransportFakeType != "http" { + return option.Outbound{}, E.New("vmess fake type `%s` not supported in sing-box", p.TransportFakeType) + } + + out := option.Outbound{ + Tag: p.Tag, + Type: C.TypeVMess, + VMessOptions: option.VMessOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: p.Address, + ServerPort: p.Port, + }, + UUID: p.UUID, + Security: p.Encrypt, + AlterId: int(p.AlterID), + }, + } + + switch p.TransportMode { + case "TCP": + if p.TransportTCPHost != nil { + out.VMessOptions.Transport = &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Host: p.TransportTCPHost, + }, + } + } + case "WS": + out.VMessOptions.Transport = &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeWebsocket, + WebsocketOptions: option.V2RayWebsocketOptions{ + Path: p.TransportWSPath, + Headers: map[string]option.Listable[string]{ + "Host": {p.TransportWSHost}, + }, + }, + } + case "H2": + out.VMessOptions.Transport = &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeHTTP, + HTTPOptions: option.V2RayHTTPOptions{ + Host: p.TransportTCPHost, + Path: p.TransportH2Path, + }, + } + if !p.TLSEnable { + return option.Outbound{}, E.New("vmess h2 must enable tls") + } + case "QUIC": + out.VMessOptions.Transport = &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeQUIC, + QUICOptions: option.V2RayQUICOptions{}, + } + if !p.TLSEnable { + return option.Outbound{}, E.New("vmess quic must enable tls") + } + case "GRPC": + out.VMessOptions.Transport = &option.V2RayTransportOptions{ + Type: C.V2RayTransportTypeGRPC, + GRPCOptions: option.V2RayGRPCOptions{ + ServiceName: p.TransportGRPCServiceName, + }, + } + } + + if p.TLSEnable { + out.VMessOptions.TLS = &option.OutboundTLSOptions{ + Enabled: true, + ServerName: p.TLSSNI, + ALPN: p.TLSALPN, + } + } + + out.VMessOptions.DialerOptions = p.Dialer + + return out, nil +} diff --git a/subscribe/subscribe.go b/subscribe/subscribe.go new file mode 100644 index 00000000..400b06c5 --- /dev/null +++ b/subscribe/subscribe.go @@ -0,0 +1,301 @@ +//go:build with_subscribe + +package subscribe + +import ( + "bytes" + "context" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/subscribe/proxy" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "time" +) + +const requestTimeout = 20 * time.Second + +func ParsePeer(ctx context.Context, tag string, options option.SubscribeOutboundOptions) ([]option.Outbound, error) { + content, err := requestWithCache(ctx, options) + if err != nil { + return nil, err + } + + outboundRawOptions, err := proxy.ParsePeers(string(content)) + if err != nil { + return nil, err + } + + if options.Filter != nil { + newOutboundRawOptions := make([]proxy.Proxy, 0) + for _, outboundRawOption := range outboundRawOptions { + if filterMatch(options.Filter, outboundRawOption.GetTag()) { + newOutboundRawOptions = append(newOutboundRawOptions, outboundRawOption) + } + } + outboundRawOptions = newOutboundRawOptions + } + + if options.DialerOptions != nil { + for i := range outboundRawOptions { + outboundRawOptions[i].SetDialer(*options.DialerOptions) + } + } + + outboundOptions := make([]option.Outbound, 0) + for _, outboundRawOption := range outboundRawOptions { + outboundOption, err := outboundRawOption.GenerateOutboundOptions() + if err != nil { + return nil, err + } + outboundOptions = append(outboundOptions, outboundOption) + } + + globalOptions := make([]option.Outbound, 0) + for _, o := range outboundOptions { + globalOptions = append(globalOptions, o) + } + + if options.CustomGroup != nil && len(options.CustomGroup) > 0 { + groupOptions := make([]option.Outbound, 0) + for _, c := range options.CustomGroup { + if c.Tag == "" { + return nil, E.New("group tag cannot be empty") + } + + groupOutTags := make([]string, 0) + groupOutTagMap := make(map[string]bool) + for _, o := range outboundOptions { + if filterMatch(c.Filter, o.Tag) { + groupOutTags = append(groupOutTags, o.Tag) + groupOutTagMap[o.Tag] = true + } + } + + groupOut := option.Outbound{} + + switch c.ProxyType { + case C.TypeSelector: + groupOut.Tag = c.Tag + groupOut.Type = C.TypeSelector + if c.SelectorOptions != nil { + groupOut.SelectorOptions = *c.SelectorOptions + if c.SelectorOptions.Default != "" { + if _, ok := groupOutTagMap[c.SelectorOptions.Default]; !ok { + return nil, E.New("default outbound '", c.SelectorOptions.Default, "' not found") + } + } + } else { + groupOut.SelectorOptions = option.SelectorOutboundOptions{} + } + + groupOut.SelectorOptions.Outbounds = groupOutTags + case C.TypeURLTest: + groupOut.Tag = c.Tag + groupOut.Type = C.TypeURLTest + if c.URLTestOptions != nil { + groupOut.URLTestOptions = *c.URLTestOptions + } else { + groupOut.URLTestOptions = option.URLTestOutboundOptions{} + } + + groupOut.URLTestOptions.Outbounds = groupOutTags + default: + return nil, E.New("unsupported proxy type: ", c.ProxyType) + } + + groupOptions = append(groupOptions, groupOut) + } + globalOptions = append(globalOptions, groupOptions...) + } + + globalTags := make([]string, 0) + globalTagMap := make(map[string]bool) + + for _, g := range globalOptions { + globalTagMap[g.Tag] = true + globalTags = append(globalTags, g.Tag) + } + + globalOut := option.Outbound{} + + switch options.ProxyType { + case C.TypeSelector: + globalOut.Tag = tag + globalOut.Type = C.TypeSelector + if options.SelectorOptions != nil { + globalOut.SelectorOptions = *options.SelectorOptions + if options.SelectorOptions.Default != "" { + if _, ok := globalTagMap[options.SelectorOptions.Default]; !ok { + return nil, E.New("default outbound '", options.SelectorOptions.Default, "' not found") + } + } + } else { + globalOut.SelectorOptions = option.SelectorOutboundOptions{} + } + + globalOut.SelectorOptions.Outbounds = globalTags + case C.TypeURLTest: + globalOut.Tag = tag + globalOut.Type = C.TypeURLTest + if options.URLTestOptions != nil { + globalOut.URLTestOptions = *options.URLTestOptions + } else { + globalOut.URLTestOptions = option.URLTestOutboundOptions{} + } + + globalOut.URLTestOptions.Outbounds = globalTags + default: + return nil, E.New("unsupported proxy type: ", options.ProxyType) + } + + globalOptions = append(globalOptions, globalOut) + + return globalOptions, nil +} + +func request(ctx context.Context, options option.SubscribeOutboundOptions) ([]byte, error) { + u, err := url.Parse(options.Url) + if err != nil { + return nil, err + } + + if options.RequestDialerOptions == nil { + options.RequestDialerOptions = &option.RequestDialerOptions{} + } + + dialer := NewDialer(*options.RequestDialerOptions) + + host := u.Hostname() + ip, err := netip.ParseAddr(host) + if err != nil { + dns, err := NewDNS(ctx, options.DNS, dialer) + if err != nil { + return nil, err + } + + ips, err := dns.QueryIP(host) + if err != nil { + return nil, err + } + + ip, _ = netip.ParseAddr(ips[0]) + } + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + + req.RemoteAddr = net.JoinHostPort(ip.String(), u.Port()) + + client := &http.Client{ + Transport: &http.Transport{ + ForceAttemptHTTP2: true, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + }, + } + + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + + req = req.WithContext(ctx) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + reader := bytes.NewBuffer(nil) + + _, err = reader.ReadFrom(resp.Body) + if err != nil { + return nil, err + } + + return reader.Bytes(), nil +} + +func requestWithCache(ctx context.Context, options option.SubscribeOutboundOptions) ([]byte, error) { + var cache []byte + + if options.CacheFile != "" { + f, err := os.OpenFile(options.CacheFile, os.O_RDONLY, 0666) + if err == nil { + fs, err := f.Stat() + if err == nil { + if fs.Size() > 0 { + readBuf := bytes.NewBuffer(nil) + _, err = readBuf.ReadFrom(f) + if err == nil { + cache = readBuf.Bytes() + if time.Now().Before(fs.ModTime().Add(time.Duration(options.ForceUpdateDuration))) { + f.Close() + return cache, nil + } + } + } + } + } + + f.Close() + } + + content, err := request(ctx, options) + if err != nil { + if cache != nil { + return cache, nil + } + return nil, err + } + + if options.CacheFile != "" { + err = os.WriteFile(options.CacheFile, content, 0666) + if err != nil { + return nil, err + } + } + + return content, nil +} + +func RequestAndCache(ctx context.Context, options option.SubscribeOutboundOptions) error { + content, err := request(ctx, options) + if err != nil { + return err + } + + return os.WriteFile(options.CacheFile, content, 0666) +} + +func filterMatch(f *option.Filter, tag string) bool { + if f.Rule != nil && len(f.Rule) > 0 { + match := false + for _, r := range f.Rule { + if r.MatchString(tag) { + match = true + break + } + } + if f.WhiteMode { + if !match { + return false + } + } else { + if match { + return false + } + } + } + + return true +} diff --git a/subscribe/subscribe_stub.go b/subscribe/subscribe_stub.go new file mode 100644 index 00000000..edecacec --- /dev/null +++ b/subscribe/subscribe_stub.go @@ -0,0 +1,13 @@ +//go:build !with_subscribe + +package subscribe + +import ( + "context" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func ParsePeer(ctx context.Context, tag string, options option.SubscribeOutboundOptions) ([]option.Outbound, error) { + return nil, E.New(`Subscribe is not included in this build, rebuild with -tags with_subscribe`) +}