From 613becd8eac3bfe1f3584c186b78f77760263311 Mon Sep 17 00:00:00 2001 From: enfein <83481737+enfein@users.noreply.github.com> Date: Mon, 9 Dec 2024 04:05:11 +0000 Subject: [PATCH] feat: support mieru protocol (#1702) --- adapter/outbound/mieru.go | 268 +++++++++++++++++++++++++++++++++ adapter/outbound/mieru_test.go | 92 +++++++++++ adapter/parser.go | 7 + constant/adapters.go | 4 +- docs/config.yaml | 12 +- go.mod | 3 + go.sum | 8 +- 7 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 adapter/outbound/mieru.go create mode 100644 adapter/outbound/mieru_test.go diff --git a/adapter/outbound/mieru.go b/adapter/outbound/mieru.go new file mode 100644 index 00000000..0d32ca41 --- /dev/null +++ b/adapter/outbound/mieru.go @@ -0,0 +1,268 @@ +package outbound + +import ( + "context" + "fmt" + "net" + "runtime" + "strconv" + "sync" + + mieruclient "github.com/enfein/mieru/v3/apis/client" + mierumodel "github.com/enfein/mieru/v3/apis/model" + mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb" + "github.com/metacubex/mihomo/component/dialer" + "github.com/metacubex/mihomo/component/proxydialer" + C "github.com/metacubex/mihomo/constant" + "google.golang.org/protobuf/proto" +) + +type Mieru struct { + *Base + option *MieruOption + client mieruclient.Client + mu sync.Mutex +} + +type MieruOption struct { + BasicOption + Name string `proxy:"name"` + Server string `proxy:"server"` + Port int `proxy:"port,omitempty"` + PortRange string `proxy:"port-range,omitempty"` + Transport string `proxy:"transport"` + UserName string `proxy:"username"` + Password string `proxy:"password"` +} + +// DialContext implements C.ProxyAdapter +func (m *Mieru) DialContext(ctx context.Context, metadata *C.Metadata, opts ...dialer.Option) (C.Conn, error) { + if err := m.ensureClientIsRunning(opts...); err != nil { + return nil, err + } + addr := metadataToMieruNetAddrSpec(metadata) + c, err := m.client.DialContext(ctx, addr) + if err != nil { + return nil, fmt.Errorf("dial to %s failed: %w", addr, err) + } + return NewConn(c, m), nil +} + +// ProxyInfo implements C.ProxyAdapter +func (m *Mieru) ProxyInfo() C.ProxyInfo { + info := m.Base.ProxyInfo() + info.DialerProxy = m.option.DialerProxy + return info +} + +func (m *Mieru) ensureClientIsRunning(opts ...dialer.Option) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.client.IsRunning() { + return nil + } + + // Create a dialer and add it to the client config, before starting the client. + var dialer C.Dialer = dialer.NewDialer(m.Base.DialOptions(opts...)...) + var err error + if len(m.option.DialerProxy) > 0 { + dialer, err = proxydialer.NewByName(m.option.DialerProxy, dialer) + if err != nil { + return err + } + } + config, err := m.client.Load() + if err != nil { + return err + } + config.Dialer = dialer + if err := m.client.Store(config); err != nil { + return err + } + + if err := m.client.Start(); err != nil { + return fmt.Errorf("failed to start mieru client: %w", err) + } + return nil +} + +func NewMieru(option MieruOption) (*Mieru, error) { + config, err := buildMieruClientConfig(option) + if err != nil { + return nil, fmt.Errorf("failed to build mieru client config: %w", err) + } + c := mieruclient.NewClient() + if err := c.Store(config); err != nil { + return nil, fmt.Errorf("failed to store mieru client config: %w", err) + } + // Client is started lazily on the first use. + + var addr string + if option.Port != 0 { + addr = net.JoinHostPort(option.Server, strconv.Itoa(option.Port)) + } else { + beginPort, _, _ := beginAndEndPortFromPortRange(option.PortRange) + addr = net.JoinHostPort(option.Server, strconv.Itoa(beginPort)) + } + outbound := &Mieru{ + Base: &Base{ + name: option.Name, + addr: addr, + iface: option.Interface, + tp: C.Mieru, + udp: false, + xudp: false, + rmark: option.RoutingMark, + prefer: C.NewDNSPrefer(option.IPVersion), + }, + option: &option, + client: c, + } + runtime.SetFinalizer(outbound, closeMieru) + return outbound, nil +} + +func closeMieru(m *Mieru) { + m.mu.Lock() + defer m.mu.Unlock() + if m.client != nil && m.client.IsRunning() { + m.client.Stop() + } +} + +func metadataToMieruNetAddrSpec(metadata *C.Metadata) mierumodel.NetAddrSpec { + if metadata.Host != "" { + return mierumodel.NetAddrSpec{ + AddrSpec: mierumodel.AddrSpec{ + FQDN: metadata.Host, + Port: int(metadata.DstPort), + }, + Net: "tcp", + } + } else { + return mierumodel.NetAddrSpec{ + AddrSpec: mierumodel.AddrSpec{ + IP: metadata.DstIP.AsSlice(), + Port: int(metadata.DstPort), + }, + Net: "tcp", + } + } +} + +func buildMieruClientConfig(option MieruOption) (*mieruclient.ClientConfig, error) { + if err := validateMieruOption(option); err != nil { + return nil, fmt.Errorf("failed to validate mieru option: %w", err) + } + + transportProtocol := mierupb.TransportProtocol_TCP.Enum() + var server *mierupb.ServerEndpoint + if net.ParseIP(option.Server) != nil { + // server is an IP address + if option.PortRange != "" { + server = &mierupb.ServerEndpoint{ + IpAddress: proto.String(option.Server), + PortBindings: []*mierupb.PortBinding{ + { + PortRange: proto.String(option.PortRange), + Protocol: transportProtocol, + }, + }, + } + } else { + server = &mierupb.ServerEndpoint{ + IpAddress: proto.String(option.Server), + PortBindings: []*mierupb.PortBinding{ + { + Port: proto.Int32(int32(option.Port)), + Protocol: transportProtocol, + }, + }, + } + } + } else { + // server is a domain name + if option.PortRange != "" { + server = &mierupb.ServerEndpoint{ + DomainName: proto.String(option.Server), + PortBindings: []*mierupb.PortBinding{ + { + PortRange: proto.String(option.PortRange), + Protocol: transportProtocol, + }, + }, + } + } else { + server = &mierupb.ServerEndpoint{ + DomainName: proto.String(option.Server), + PortBindings: []*mierupb.PortBinding{ + { + Port: proto.Int32(int32(option.Port)), + Protocol: transportProtocol, + }, + }, + } + } + } + return &mieruclient.ClientConfig{ + Profile: &mierupb.ClientProfile{ + ProfileName: proto.String(option.Name), + User: &mierupb.User{ + Name: proto.String(option.UserName), + Password: proto.String(option.Password), + }, + Servers: []*mierupb.ServerEndpoint{server}, + }, + }, nil +} + +func validateMieruOption(option MieruOption) error { + if option.Name == "" { + return fmt.Errorf("name is empty") + } + if option.Server == "" { + return fmt.Errorf("server is empty") + } + if option.Port == 0 && option.PortRange == "" { + return fmt.Errorf("either port or port-range must be set") + } + if option.Port != 0 && option.PortRange != "" { + return fmt.Errorf("port and port-range cannot be set at the same time") + } + if option.Port != 0 && (option.Port < 1 || option.Port > 65535) { + return fmt.Errorf("port must be between 1 and 65535") + } + if option.PortRange != "" { + begin, end, err := beginAndEndPortFromPortRange(option.PortRange) + if err != nil { + return fmt.Errorf("invalid port-range format") + } + if begin < 1 || begin > 65535 { + return fmt.Errorf("begin port must be between 1 and 65535") + } + if end < 1 || end > 65535 { + return fmt.Errorf("end port must be between 1 and 65535") + } + if begin > end { + return fmt.Errorf("begin port must be less than or equal to end port") + } + } + + if option.Transport != "TCP" { + return fmt.Errorf("transport must be TCP") + } + if option.UserName == "" { + return fmt.Errorf("username is empty") + } + if option.Password == "" { + return fmt.Errorf("password is empty") + } + return nil +} + +func beginAndEndPortFromPortRange(portRange string) (int, int, error) { + var begin, end int + _, err := fmt.Sscanf(portRange, "%d-%d", &begin, &end) + return begin, end, err +} diff --git a/adapter/outbound/mieru_test.go b/adapter/outbound/mieru_test.go new file mode 100644 index 00000000..086b7910 --- /dev/null +++ b/adapter/outbound/mieru_test.go @@ -0,0 +1,92 @@ +package outbound + +import "testing" + +func TestNewMieru(t *testing.T) { + testCases := []struct { + option MieruOption + wantBaseAddr string + }{ + { + option: MieruOption{ + Name: "test", + Server: "1.2.3.4", + Port: 10000, + Transport: "TCP", + UserName: "test", + Password: "test", + }, + wantBaseAddr: "1.2.3.4:10000", + }, + { + option: MieruOption{ + Name: "test", + Server: "2001:db8::1", + PortRange: "10001-10002", + Transport: "TCP", + UserName: "test", + Password: "test", + }, + wantBaseAddr: "[2001:db8::1]:10001", + }, + { + option: MieruOption{ + Name: "test", + Server: "example.com", + Port: 10003, + Transport: "TCP", + UserName: "test", + Password: "test", + }, + wantBaseAddr: "example.com:10003", + }, + } + + for _, testCase := range testCases { + mieru, err := NewMieru(testCase.option) + if err != nil { + t.Error(err) + } + if mieru.addr != testCase.wantBaseAddr { + t.Errorf("got addr %q, want %q", mieru.addr, testCase.wantBaseAddr) + } + } +} + +func TestBeginAndEndPortFromPortRange(t *testing.T) { + testCases := []struct { + input string + begin int + end int + hasErr bool + }{ + {"1-10", 1, 10, false}, + {"1000-2000", 1000, 2000, false}, + {"65535-65535", 65535, 65535, false}, + {"1", 0, 0, true}, + {"1-", 0, 0, true}, + {"-10", 0, 0, true}, + {"a-b", 0, 0, true}, + {"1-b", 0, 0, true}, + {"a-10", 0, 0, true}, + } + + for _, testCase := range testCases { + begin, end, err := beginAndEndPortFromPortRange(testCase.input) + if testCase.hasErr { + if err == nil { + t.Errorf("beginAndEndPortFromPortRange(%s) should return an error", testCase.input) + } + } else { + if err != nil { + t.Errorf("beginAndEndPortFromPortRange(%s) should not return an error, but got %v", testCase.input, err) + } + if begin != testCase.begin { + t.Errorf("beginAndEndPortFromPortRange(%s) begin port mismatch, got %d, want %d", testCase.input, begin, testCase.begin) + } + if end != testCase.end { + t.Errorf("beginAndEndPortFromPortRange(%s) end port mismatch, got %d, want %d", testCase.input, end, testCase.end) + } + } + } +} diff --git a/adapter/parser.go b/adapter/parser.go index c64ee13a..ce4e91d5 100644 --- a/adapter/parser.go +++ b/adapter/parser.go @@ -141,6 +141,13 @@ func ParseProxy(mapping map[string]any) (C.Proxy, error) { break } proxy, err = outbound.NewSsh(*sshOption) + case "mieru": + mieruOption := &outbound.MieruOption{} + err = decoder.Decode(mapping, mieruOption) + if err != nil { + break + } + proxy, err = outbound.NewMieru(*mieruOption) default: return nil, fmt.Errorf("unsupport proxy type: %s", proxyType) } diff --git a/constant/adapters.go b/constant/adapters.go index 664054d7..420a797f 100644 --- a/constant/adapters.go +++ b/constant/adapters.go @@ -42,6 +42,7 @@ const ( WireGuard Tuic Ssh + Mieru ) const ( @@ -226,7 +227,8 @@ func (at AdapterType) String() string { return "Tuic" case Ssh: return "Ssh" - + case Mieru: + return "Mieru" case Relay: return "Relay" case Selector: diff --git a/docs/config.yaml b/docs/config.yaml index 5e83eea3..23acb78f 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -846,6 +846,16 @@ proxies: # socks5 password: password privateKey: path + # mieru + - name: mieru + type: mieru + server: 1.2.3.4 + port: 2999 + # port-range: 2090-2099 #(不可同时填写 port 和 port-range) + transport: TCP # 只支持 TCP + username: user + password: password + # dns 出站会将请求劫持到内部 dns 模块,所有请求均在内部处理 - name: "dns-out" type: dns @@ -1202,4 +1212,4 @@ listeners: # authentication-timeout: 1000 # alpn: # - h3 -# max-udp-relay-packet-size: 1500 \ No newline at end of file +# max-udp-relay-packet-size: 1500 diff --git a/go.mod b/go.mod index 905b1597..1ae7c26d 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/bahlo/generic-list-go v0.2.0 github.com/coreos/go-iptables v0.8.0 github.com/dlclark/regexp2 v1.11.4 + github.com/enfein/mieru/v3 v3.8.3 github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/render v1.0.3 github.com/gobwas/ws v1.4.0 @@ -114,6 +115,8 @@ require ( golang.org/x/text v0.20.0 // indirect golang.org/x/time v0.7.0 // indirect golang.org/x/tools v0.24.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3 // indirect + google.golang.org/grpc v1.64.1 // indirect ) replace github.com/sagernet/sing => github.com/metacubex/sing v0.0.0-20241121030428-33b6ebc52000 diff --git a/go.sum b/go.sum index 7845d294..7aac4628 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ 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.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/enfein/mieru/v3 v3.8.3 h1:s4K0hMFDg6LHltokR8/nBTVCq15XnnxPsvc1LrHwpoo= +github.com/enfein/mieru/v3 v3.8.3/go.mod h1:YtU00qjAEt54mCBQu4WZPCey6cBdB1BUtXjvrHLEUNQ= github.com/ericlagergren/aegis v0.0.0-20230312195928-b4ce538b56f9 h1:/5RkVc9Rc81XmMyVqawCiDyrBHZbLAZgTTCqou4mwj8= github.com/ericlagergren/aegis v0.0.0-20230312195928-b4ce538b56f9/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I= github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g= @@ -59,7 +61,7 @@ github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakr github.com/gofrs/uuid/v5 v5.3.0 h1:m0mUMr+oVYUdxpMLgSYCZiXe7PuVPnI94+OMeVBNedk= github.com/gofrs/uuid/v5 v5.3.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -274,6 +276,10 @@ golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3 h1:9Xyg6I9IWQZhRVfCWjKK+l6kI0jHcPesVlMnT//aHNo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= +google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=