diff --git a/adapter/outbound/mieru.go b/adapter/outbound/mieru.go new file mode 100644 index 00000000..6e899fd1 --- /dev/null +++ b/adapter/outbound/mieru.go @@ -0,0 +1,237 @@ +package outbound + +import ( + "context" + "fmt" + "net" + "runtime" + "strconv" + + 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" + N "github.com/metacubex/mihomo/common/net" + "github.com/metacubex/mihomo/component/dialer" + C "github.com/metacubex/mihomo/constant" + "google.golang.org/protobuf/proto" +) + +const ( + // Default MTU used in mieru UDP transport. + mieruDefaultMTU = 1400 +) + +type Mieru struct { + *Base + option *MieruOption + client mieruclient.Client +} + +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"` + MTU int `proxy:"mtu,omitempty"` +} + +// DialContext implements C.ProxyAdapter +func (m *Mieru) DialContext(ctx context.Context, metadata *C.Metadata, _ ...dialer.Option) (_ C.Conn, err error) { + c, err := m.client.DialContext(ctx) + if err != nil { + return nil, err + } + + addrSpec := mierumodel.AddrSpec{ + Port: int(metadata.DstPort), + } + if metadata.Host != "" { + addrSpec.FQDN = metadata.Host + } else { + addrSpec.IP = metadata.DstIP.AsSlice() + } + if err := m.client.HandshakeWithConnect(ctx, c, addrSpec); err != nil { + return nil, err + } + return NewConn(N.NewRefConn(c, m), m), 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) + } + if err := c.Start(); err != nil { + return nil, fmt.Errorf("failed to start mieru client: %w", err) + } + + 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) { + if m.client != nil { + m.client.Stop() + } +} + +func buildMieruClientConfig(option MieruOption) (*mieruclient.ClientConfig, error) { + if err := validateMieruOption(option); err != nil { + return nil, fmt.Errorf("failed to validate mieru option: %w", err) + } + + var transportProtocol *mierupb.TransportProtocol + if option.Transport == "TCP" { + transportProtocol = mierupb.TransportProtocol_TCP.Enum() + } else if option.Transport == "UDP" { + transportProtocol = mierupb.TransportProtocol_UDP.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, + }, + }, + } + } + } + if option.MTU == 0 { + option.MTU = mieruDefaultMTU + } + 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}, + Mtu: proto.Int32(int32(option.MTU)), + Multiplexing: &mierupb.MultiplexingConfig{ + // Multiplexing doesn't work well with connection tracking. + Level: mierupb.MultiplexingLevel_MULTIPLEXING_OFF.Enum(), + }, + }, + }, nil +} + +func validateMieruOption(option MieruOption) error { + if option.DialerProxy != "" { + return fmt.Errorf("dialer proxy is not supported") + } + 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" && option.Transport != "UDP" { + return fmt.Errorf("transport must be TCP or UDP") + } + 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..5e7a8569 --- /dev/null +++ b/adapter/outbound/mieru_test.go @@ -0,0 +1,81 @@ +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: "example.com", + PortRange: "10001-10002", + Transport: "UDP", + UserName: "test", + Password: "test", + }, + wantBaseAddr: "example.com:10001", + }, + } + + 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 b303eb84..f5089fcd 100644 --- a/constant/adapters.go +++ b/constant/adapters.go @@ -42,6 +42,7 @@ const ( WireGuard Tuic Ssh + Mieru ) const ( @@ -213,6 +214,8 @@ func (at AdapterType) String() string { return "WireGuard" case Tuic: return "Tuic" + case Mieru: + return "Mieru" case Relay: return "Relay" diff --git a/docs/config.yaml b/docs/config.yaml index e75e5bd5..3d2166a3 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -846,6 +846,17 @@ 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 # Available: "TCP", "UDP". + username: user + password: password + # mtu: 1450 # Available: 1280 - 1500. Default: 1400. + # dns 出站会将请求劫持到内部 dns 模块,所有请求均在内部处理 - name: "dns-out" type: dns @@ -1200,4 +1211,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 a223cf77..c2d2d04a 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.6.0 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.19.0 // indirect golang.org/x/time v0.5.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-20240724044459-6f3cf5896297 diff --git a/go.sum b/go.sum index 2b3bba65..6901da72 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.6.0 h1:I9N8iBhBzPgQcQVQbVFV5dSrKWXVXVcv8BLHUQytpJk= +github.com/enfein/mieru/v3 v3.6.0/go.mod h1:tvbwPALapGNh8dJGaFAqj6bpoM+JKj1+wa4anrJGBmA= 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.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= github.com/google/btree v1.1.2/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=