feat: support mieru inbound (#2347)

This commit is contained in:
enfein 2025-11-09 01:29:47 +00:00 committed by GitHub
parent ff76576cbe
commit a4b76809ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 541 additions and 3 deletions

View File

@ -38,6 +38,7 @@ const (
TUIC TUIC
HYSTERIA2 HYSTERIA2
ANYTLS ANYTLS
MIERU
INNER INNER
) )
@ -109,6 +110,8 @@ func (t Type) String() string {
return "Hysteria2" return "Hysteria2"
case ANYTLS: case ANYTLS:
return "AnyTLS" return "AnyTLS"
case MIERU:
return "Mieru"
case INNER: case INNER:
return "Inner" return "Inner"
default: default:
@ -149,6 +152,8 @@ func ParseType(t string) (*Type, error) {
res = HYSTERIA2 res = HYSTERIA2
case "ANYTLS": case "ANYTLS":
res = ANYTLS res = ANYTLS
case "MIERU":
res = MIERU
case "INNER": case "INNER":
res = INNER res = INNER
default: default:

View File

@ -1556,6 +1556,15 @@ listeners:
# -----END ECH KEYS----- # -----END ECH KEYS-----
# padding-scheme: "" # https://github.com/anytls/anytls-go/blob/main/docs/protocol.md#cmdupdatepaddingscheme # 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 - name: trojan-in-1
type: trojan type: trojan
port: 10819 # 支持使用ports格式例如200,302 or 200,204,401-429,501-503 port: 10819 # 支持使用ports格式例如200,302 or 200,204,401-429,501-503

2
go.mod
View File

@ -7,7 +7,7 @@ require (
github.com/coreos/go-iptables v0.8.0 github.com/coreos/go-iptables v0.8.0
github.com/dlclark/regexp2 v1.11.5 github.com/dlclark/regexp2 v1.11.5
github.com/ebitengine/purego v0.9.0 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/chi/v5 v5.2.3
github.com/go-chi/render v1.0.3 github.com/go-chi/render v1.0.3
github.com/gobwas/ws v1.4.0 github.com/gobwas/ws v1.4.0

4
go.sum
View File

@ -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/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 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 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.22.1 h1:/XGYYXpEhEJlxosmtbpEJkhtRLHB8IToG7LB8kU2ZDY=
github.com/enfein/mieru/v3 v3.20.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= 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 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I= 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= github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g=

181
listener/inbound/mieru.go Normal file
View File

@ -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
}

View File

@ -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)
}

124
listener/mieru/server.go Normal file
View File

@ -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()
}

View File

@ -127,6 +127,13 @@ func ParseListener(mapping map[string]any) (C.InboundListener, error) {
return nil, err return nil, err
} }
listener, err = IN.NewAnyTLS(anytlsOption) 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: default:
return nil, fmt.Errorf("unsupport proxy type: %s", proxyType) return nil, fmt.Errorf("unsupport proxy type: %s", proxyType)
} }