From a4b76809accb8bbe16397285e4e5610c791479bf Mon Sep 17 00:00:00 2001 From: enfein <83481737+enfein@users.noreply.github.com> Date: Sun, 9 Nov 2025 01:29:47 +0000 Subject: [PATCH] feat: support mieru inbound (#2347) --- constant/metadata.go | 5 + docs/config.yaml | 9 ++ go.mod | 2 +- go.sum | 4 +- listener/inbound/mieru.go | 181 ++++++++++++++++++++++++++++ listener/inbound/mieru_test.go | 212 +++++++++++++++++++++++++++++++++ listener/mieru/server.go | 124 +++++++++++++++++++ listener/parse.go | 7 ++ 8 files changed, 541 insertions(+), 3 deletions(-) create mode 100644 listener/inbound/mieru.go create mode 100644 listener/inbound/mieru_test.go create mode 100644 listener/mieru/server.go diff --git a/constant/metadata.go b/constant/metadata.go index 72b9995b..23cdcd86 100644 --- a/constant/metadata.go +++ b/constant/metadata.go @@ -38,6 +38,7 @@ const ( TUIC HYSTERIA2 ANYTLS + MIERU INNER ) @@ -109,6 +110,8 @@ func (t Type) String() string { return "Hysteria2" case ANYTLS: return "AnyTLS" + case MIERU: + return "Mieru" case INNER: return "Inner" default: @@ -149,6 +152,8 @@ func ParseType(t string) (*Type, error) { res = HYSTERIA2 case "ANYTLS": res = ANYTLS + case "MIERU": + res = MIERU case "INNER": res = INNER default: diff --git a/docs/config.yaml b/docs/config.yaml index 57a1adcf..e71b06f4 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -1556,6 +1556,15 @@ listeners: # -----END ECH KEYS----- # padding-scheme: "" # https://github.com/anytls/anytls-go/blob/main/docs/protocol.md#cmdupdatepaddingscheme + - name: mieru-in-1 + type: mieru + port: 10818 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 + listen: 0.0.0.0 + transport: TCP # 支持 TCP 或者 UDP + users: + username1: password1 + username2: password2 + - name: trojan-in-1 type: trojan port: 10819 # 支持使用ports格式,例如200,302 or 200,204,401-429,501-503 diff --git a/go.mod b/go.mod index ea42bce7..74ca8e5a 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/coreos/go-iptables v0.8.0 github.com/dlclark/regexp2 v1.11.5 github.com/ebitengine/purego v0.9.0 - github.com/enfein/mieru/v3 v3.20.0 + github.com/enfein/mieru/v3 v3.22.1 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/render v1.0.3 github.com/gobwas/ws v1.4.0 diff --git a/go.sum b/go.sum index bfc1a8f5..71d24950 100644 --- a/go.sum +++ b/go.sum @@ -25,8 +25,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/enfein/mieru/v3 v3.20.0 h1:1ob7pCIVSH5FYFAfYvim8isLW1vBOS4cFOUF9exJS38= -github.com/enfein/mieru/v3 v3.20.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= +github.com/enfein/mieru/v3 v3.22.1 h1:/XGYYXpEhEJlxosmtbpEJkhtRLHB8IToG7LB8kU2ZDY= +github.com/enfein/mieru/v3 v3.22.1/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo= github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I= github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g= diff --git a/listener/inbound/mieru.go b/listener/inbound/mieru.go new file mode 100644 index 00000000..cf8cc403 --- /dev/null +++ b/listener/inbound/mieru.go @@ -0,0 +1,181 @@ +package inbound + +import ( + "context" + "fmt" + "net" + "sync" + + "github.com/metacubex/mihomo/adapter/inbound" + "github.com/metacubex/mihomo/common/utils" + C "github.com/metacubex/mihomo/constant" + "github.com/metacubex/mihomo/listener/mieru" + "github.com/metacubex/mihomo/log" + "google.golang.org/protobuf/proto" + + mieruserver "github.com/enfein/mieru/v3/apis/server" + mierupb "github.com/enfein/mieru/v3/pkg/appctl/appctlpb" +) + +type Mieru struct { + *Base + option *MieruOption + server mieruserver.Server + mu sync.Mutex +} + +type MieruOption struct { + BaseOption + Transport string `inbound:"transport"` + Users map[string]string `inbound:"users"` +} + +type mieruListenerFactory struct{} + +func (mieruListenerFactory) Listen(ctx context.Context, network, address string) (net.Listener, error) { + return inbound.ListenContext(ctx, network, address) +} + +func (mieruListenerFactory) ListenPacket(ctx context.Context, network, address string) (net.PacketConn, error) { + return inbound.ListenPacketContext(ctx, network, address) +} + +func NewMieru(option *MieruOption) (*Mieru, error) { + base, err := NewBase(&option.BaseOption) + if err != nil { + return nil, err + } + + config, err := buildMieruServerConfig(option, base.ports) + if err != nil { + return nil, fmt.Errorf("failed to build mieru server config: %w", err) + } + s := mieruserver.NewServer() + if err := s.Store(config); err != nil { + return nil, fmt.Errorf("failed to store mieru server config: %w", err) + } + // Server is started lazily when Listen() is called for the first time. + return &Mieru{ + Base: base, + option: option, + server: s, + }, nil +} + +func (m *Mieru) Config() C.InboundConfig { + return m.option +} + +func (m *Mieru) Listen(tunnel C.Tunnel) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.server.IsRunning() { + if err := m.server.Start(); err != nil { + return fmt.Errorf("failed to start mieru server: %w", err) + } + } + + additions := m.config.Additions() + if len(additions) == 0 { + additions = []inbound.Addition{ + inbound.WithInName("DEFAULT-MIERU"), + inbound.WithSpecialRules(""), + } + } + + go func() { + for { + c, req, err := m.server.Accept() + if err != nil { + if !m.server.IsRunning() { + break + } + } + go mieru.Handle(c, tunnel, req, additions...) + } + }() + log.Infoln("Mieru[%s] proxy listening at: %s", m.Name(), m.Address()) + return nil +} + +func (m *Mieru) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.server.IsRunning() { + return m.server.Stop() + } + + return nil +} + +var _ C.InboundListener = (*Mieru)(nil) + +func (o MieruOption) Equal(config C.InboundConfig) bool { + return optionToString(o) == optionToString(config) +} + +func buildMieruServerConfig(option *MieruOption, ports utils.IntRanges[uint16]) (*mieruserver.ServerConfig, error) { + if err := validateMieruOption(option); err != nil { + return nil, fmt.Errorf("failed to validate mieru option: %w", err) + } + if len(ports) == 0 { + return nil, fmt.Errorf("port is not set") + } + + var transportProtocol *mierupb.TransportProtocol + switch option.Transport { + case "TCP": + transportProtocol = mierupb.TransportProtocol_TCP.Enum() + case "UDP": + transportProtocol = mierupb.TransportProtocol_UDP.Enum() + } + var portBindings []*mierupb.PortBinding + for _, portRange := range ports { + if portRange.Start() == portRange.End() { + portBindings = append(portBindings, &mierupb.PortBinding{ + Port: proto.Int32(int32(portRange.Start())), + Protocol: transportProtocol, + }) + } else { + portBindings = append(portBindings, &mierupb.PortBinding{ + PortRange: proto.String(fmt.Sprintf("%d-%d", portRange.Start(), portRange.End())), + Protocol: transportProtocol, + }) + } + } + var users []*mierupb.User + for username, password := range option.Users { + users = append(users, &mierupb.User{ + Name: proto.String(username), + Password: proto.String(password), + }) + } + return &mieruserver.ServerConfig{ + Config: &mierupb.ServerConfig{ + PortBindings: portBindings, + Users: users, + }, + StreamListenerFactory: mieruListenerFactory{}, + PacketListenerFactory: mieruListenerFactory{}, + }, nil +} + +func validateMieruOption(option *MieruOption) error { + if option.Transport != "TCP" && option.Transport != "UDP" { + return fmt.Errorf("transport must be TCP or UDP") + } + if len(option.Users) == 0 { + return fmt.Errorf("users is empty") + } + for username, password := range option.Users { + if username == "" { + return fmt.Errorf("username is empty") + } + if password == "" { + return fmt.Errorf("password is empty") + } + } + return nil +} diff --git a/listener/inbound/mieru_test.go b/listener/inbound/mieru_test.go new file mode 100644 index 00000000..d163b49d --- /dev/null +++ b/listener/inbound/mieru_test.go @@ -0,0 +1,212 @@ +package inbound_test + +import ( + "net" + "net/netip" + "strconv" + "testing" + + "github.com/metacubex/mihomo/adapter/outbound" + "github.com/metacubex/mihomo/listener/inbound" + "github.com/stretchr/testify/assert" +) + +func TestNewMieru(t *testing.T) { + type args struct { + option *inbound.MieruOption + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "valid with port", + args: args{ + option: &inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + Port: "8080", + }, + Transport: "TCP", + Users: map[string]string{"user": "pass"}, + }, + }, + wantErr: false, + }, + { + name: "valid with port range", + args: args{ + option: &inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + Port: "8090-8099", + }, + Transport: "UDP", + Users: map[string]string{"user": "pass"}, + }, + }, + wantErr: false, + }, + { + name: "valid mix of port and port-range", + args: args{ + option: &inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + Port: "8080,8090-8099", + }, + Transport: "TCP", + Users: map[string]string{"user": "pass"}, + }, + }, + wantErr: false, + }, + { + name: "invalid - no port", + args: args{ + option: &inbound.MieruOption{ + Transport: "TCP", + Users: map[string]string{"user": "pass"}, + }, + }, + wantErr: true, + }, + { + name: "invalid - transport", + args: args{ + option: &inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + Port: "8080", + }, + Transport: "INVALID", + Users: map[string]string{"user": "pass"}, + }, + }, + wantErr: true, + }, + { + name: "invalid - no transport", + args: args{ + option: &inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + Port: "8080", + }, + Users: map[string]string{"user": "pass"}, + }, + }, + wantErr: true, + }, + { + name: "invalid - no users", + args: args{ + option: &inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + Port: "8080", + }, + Transport: "TCP", + Users: map[string]string{}, + }, + }, + wantErr: true, + }, + { + name: "invalid - empty username", + args: args{ + option: &inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + Port: "8080", + }, + Transport: "TCP", + Users: map[string]string{"": "pass"}, + }, + }, + wantErr: true, + }, + { + name: "invalid - empty password", + args: args{ + option: &inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + Port: "8080", + }, + Transport: "TCP", + Users: map[string]string{"user": ""}, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := inbound.NewMieru(tt.args.option) + if (err != nil) != tt.wantErr { + t.Errorf("NewMieru() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err == nil { + got.Close() + } + }) + } +} + +func TestInboundMieru(t *testing.T) { + t.Run("HANDSHAKE_STANDARD", func(t *testing.T) { + testInboundMieruTCP(t, "HANDSHAKE_STANDARD") + }) + t.Run("HANDSHAKE_NO_WAIT", func(t *testing.T) { + testInboundMieruTCP(t, "HANDSHAKE_NO_WAIT") + }) +} + +func testInboundMieruTCP(t *testing.T, handshakeMode string) { + t.Parallel() + l, err := net.Listen("tcp", "127.0.0.1:0") + if !assert.NoError(t, err) { + return + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + + inboundOptions := inbound.MieruOption{ + BaseOption: inbound.BaseOption{ + NameStr: "mieru_inbound", + Listen: "127.0.0.1", + Port: strconv.Itoa(port), + }, + Transport: "TCP", + Users: map[string]string{"test": "password"}, + } + in, err := inbound.NewMieru(&inboundOptions) + if !assert.NoError(t, err) { + return + } + + tunnel := NewHttpTestTunnel() + defer tunnel.Close() + + err = in.Listen(tunnel) + if !assert.NoError(t, err) { + return + } + defer in.Close() + + addrPort, err := netip.ParseAddrPort(in.Address()) + if !assert.NoError(t, err) { + return + } + outboundOptions := outbound.MieruOption{ + Name: "mieru_outbound", + Server: addrPort.Addr().String(), + Port: int(addrPort.Port()), + Transport: "TCP", + UserName: "test", + Password: "password", + HandshakeMode: handshakeMode, + } + out, err := outbound.NewMieru(outboundOptions) + if !assert.NoError(t, err) { + return + } + defer out.Close() + + tunnel.DoTest(t, out) +} diff --git a/listener/mieru/server.go b/listener/mieru/server.go new file mode 100644 index 00000000..9b1e2e6f --- /dev/null +++ b/listener/mieru/server.go @@ -0,0 +1,124 @@ +package mieru + +import ( + "errors" + "io" + "net" + "net/netip" + + "github.com/metacubex/mihomo/adapter/inbound" + N "github.com/metacubex/mihomo/common/net" + C "github.com/metacubex/mihomo/constant" + "github.com/metacubex/mihomo/transport/socks5" + + mierucommon "github.com/enfein/mieru/v3/apis/common" + mieruconstant "github.com/enfein/mieru/v3/apis/constant" + mierumodel "github.com/enfein/mieru/v3/apis/model" +) + +func Handle(conn net.Conn, tunnel C.Tunnel, request *mierumodel.Request, additions ...inbound.Addition) { + // Return a fake response to the client. + resp := &mierumodel.Response{ + Reply: mieruconstant.Socks5ReplySuccess, + BindAddr: mierumodel.AddrSpec{ + IP: net.IPv4zero, + Port: 0, + }, + } + if err := resp.WriteToSocks5(conn); err != nil { + conn.Close() + return + } + + // Handle the connection with tunnel. + metadata := mieruRequestToMetadata(request) + inbound.ApplyAdditions(&metadata, additions...) + switch metadata.NetWork { + case C.TCP: + tunnel.HandleTCPConn(conn, &metadata) + case C.UDP: + pc := mierucommon.NewPacketOverStreamTunnel(conn) + ep := N.NewEnhancePacketConn(pc) + for { + data, put, addr, err := ep.WaitReadFrom() + if err != nil { + if put != nil { + // Unresolved UDP packet, return buffer to the pool. + put() + } + // mieru returns EOF or ErrUnexpectedEOF when a session is closed. + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.ErrClosedPipe) { + break + } + continue + } + target, payload, err := socks5.DecodeUDPPacket(data) + if err != nil { + return + } + packet := &packet{ + pc: ep, + addr: addr, + payload: payload, + put: put, + } + tunnel.HandleUDPPacket(inbound.NewPacket(target, packet, C.MIERU, additions...)) + } + } +} + +func mieruRequestToMetadata(request *mierumodel.Request) C.Metadata { + m := C.Metadata{ + DstPort: uint16(request.DstAddr.Port), + } + switch request.Command { + case mieruconstant.Socks5ConnectCmd: + m.NetWork = C.TCP + case mieruconstant.Socks5UDPAssociateCmd: + m.NetWork = C.UDP + } + if request.DstAddr.FQDN != "" { + m.Host = request.DstAddr.FQDN + } else if request.DstAddr.IP != nil { + m.DstIP, _ = netip.AddrFromSlice(request.DstAddr.IP) + } + return m +} + +type packet struct { + pc net.PacketConn + addr net.Addr // source (i.e. remote) IP & Port of the packet + payload []byte + put func() +} + +var _ C.UDPPacket = (*packet)(nil) +var _ C.UDPPacketInAddr = (*packet)(nil) + +func (c *packet) Data() []byte { + return c.payload +} + +func (c *packet) WriteBack(b []byte, addr net.Addr) (n int, err error) { + packet, err := socks5.EncodeUDPPacket(socks5.ParseAddrToSocksAddr(addr), b) + if err != nil { + return + } + return c.pc.WriteTo(packet, c.addr) +} + +func (c *packet) Drop() { + if c.put != nil { + c.put() + c.put = nil + } + c.payload = nil +} + +func (c *packet) LocalAddr() net.Addr { + return c.addr +} + +func (c *packet) InAddr() net.Addr { + return c.pc.LocalAddr() +} diff --git a/listener/parse.go b/listener/parse.go index 8aec050e..4e893bf1 100644 --- a/listener/parse.go +++ b/listener/parse.go @@ -127,6 +127,13 @@ func ParseListener(mapping map[string]any) (C.InboundListener, error) { return nil, err } listener, err = IN.NewAnyTLS(anytlsOption) + case "mieru": + mieruOption := &IN.MieruOption{} + err = decoder.Decode(mapping, mieruOption) + if err != nil { + return nil, err + } + listener, err = IN.NewMieru(mieruOption) default: return nil, fmt.Errorf("unsupport proxy type: %s", proxyType) }