From 86835c9be3605edd75ba4b39ea4a22497a955669 Mon Sep 17 00:00:00 2001 From: goukey Date: Fri, 12 Dec 2025 15:16:03 +0800 Subject: [PATCH] feat: Add ECH-Tunnel protocol support - Add ECHTunnel adapter implementation - Add transport layer for WebSocket+ECH - Add protocol parser and constants - Add gorilla/websocket dependency --- adapter/outbound/echtunnel.go | 115 ++++++++++++++++++++++++++++++++++ adapter/parser.go | 7 +++ constant/adapters.go | 3 + go.mod | 1 + go.sum | 2 + transport/echtunnel/client.go | 92 +++++++++++++++++++++++++++ transport/echtunnel/conn.go | 76 ++++++++++++++++++++++ 7 files changed, 296 insertions(+) create mode 100644 adapter/outbound/echtunnel.go create mode 100644 transport/echtunnel/client.go create mode 100644 transport/echtunnel/conn.go diff --git a/adapter/outbound/echtunnel.go b/adapter/outbound/echtunnel.go new file mode 100644 index 00000000..fdb17268 --- /dev/null +++ b/adapter/outbound/echtunnel.go @@ -0,0 +1,115 @@ +package outbound + +import ( + "context" + "fmt" + "net" + "strconv" + + C "github.com/metacubex/mihomo/constant" + "github.com/metacubex/mihomo/transport/echtunnel" +) + +type ECHTunnel struct { + *Base + option *ECHTunnelOption + client *echtunnel.Client +} + +type ECHTunnelOption struct { + BasicOption + Name string `proxy:"name"` + Server string `proxy:"server"` + Port int `proxy:"port"` + WSPath string `proxy:"ws-path,omitempty"` + Token string `proxy:"token,omitempty"` + ECHDomain string `proxy:"ech-domain,omitempty"` + DNS string `proxy:"dns,omitempty"` + IP string `proxy:"ip,omitempty"` + UDP bool `proxy:"udp,omitempty"` +} + +// DialContext implements C.ProxyAdapter +func (e *ECHTunnel) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn, err error) { + // 使用 ECH-Tunnel 客户端建立连接 + c, err := e.client.DialContext(ctx, metadata.RemoteAddress()) + if err != nil { + return nil, fmt.Errorf("%s connect error: %w", e.addr, err) + } + + return NewConn(c, e), nil +} + +// ListenPacketContext implements C.ProxyAdapter +func (e *ECHTunnel) ListenPacketContext(ctx context.Context, metadata *C.Metadata) (_ C.PacketConn, err error) { + if !e.option.UDP { + return nil, fmt.Errorf("UDP not supported") + } + + // ECH-Tunnel 的 UDP 实现 + c, err := e.client.DialContext(ctx, metadata.RemoteAddress()) + if err != nil { + return nil, fmt.Errorf("%s connect error: %w", e.addr, err) + } + + pc := echtunnel.NewPacketConn(c) + return newPacketConn(pc, e), nil +} + +// SupportUOT implements C.ProxyAdapter +func (e *ECHTunnel) SupportUOT() bool { + return false +} + +// ProxyInfo implements C.ProxyAdapter +func (e *ECHTunnel) ProxyInfo() C.ProxyInfo { + info := e.Base.ProxyInfo() + info.DialerProxy = e.option.DialerProxy + return info +} + +// Close implements C.ProxyAdapter +func (e *ECHTunnel) Close() error { + if e.client != nil { + return e.client.Close() + } + return nil +} + +func NewECHTunnel(option ECHTunnelOption) (*ECHTunnel, error) { + addr := net.JoinHostPort(option.Server, strconv.Itoa(option.Port)) + + client, err := echtunnel.NewClient(echtunnel.Config{ + Server: option.Server, + Port: option.Port, + WSPath: option.WSPath, + Token: option.Token, + ECHDomain: option.ECHDomain, + DNS: option.DNS, + IP: option.IP, + }) + + if err != nil { + return nil, err + } + + e := &ECHTunnel{ + Base: &Base{ + name: option.Name, + addr: addr, + tp: C.ECHTunnel, + pdName: option.ProviderName, + udp: option.UDP, + tfo: option.TFO, + mpTcp: option.MPTCP, + iface: option.Interface, + rmark: option.RoutingMark, + prefer: option.IPVersion, + }, + option: &option, + client: client, + } + e.dialer = option.NewDialer(e.DialOptions()) + + return e, nil +} diff --git a/adapter/parser.go b/adapter/parser.go index 08f90afe..f4549ebc 100644 --- a/adapter/parser.go +++ b/adapter/parser.go @@ -159,6 +159,13 @@ func ParseProxy(mapping map[string]any, options ...ProxyOption) (C.Proxy, error) break } proxy, err = outbound.NewSudoku(*sudokuOption) + case "echtunnel": + echTunnelOption := &outbound.ECHTunnelOption{BasicOption: basicOption} + err = decoder.Decode(mapping, echTunnelOption) + if err != nil { + break + } + proxy, err = outbound.NewECHTunnel(*echTunnelOption) default: return nil, fmt.Errorf("unsupport proxy type: %s", proxyType) } diff --git a/constant/adapters.go b/constant/adapters.go index 07ae5de1..165d64d8 100644 --- a/constant/adapters.go +++ b/constant/adapters.go @@ -45,6 +45,7 @@ const ( Mieru AnyTLS Sudoku + ECHTunnel ) const ( @@ -212,6 +213,8 @@ func (at AdapterType) String() string { return "AnyTLS" case Sudoku: return "Sudoku" + case ECHTunnel: + return "ECHTunnel" case Relay: return "Relay" case Selector: diff --git a/go.mod b/go.mod index 8fb7e51f..2bbdd2dd 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/gobwas/ws v1.4.0 github.com/gofrs/uuid/v5 v5.4.0 github.com/golang/snappy v1.0.0 + github.com/gorilla/websocket v1.5.3 github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 github.com/klauspost/compress v1.17.9 // lastest version compatible with golang1.20 github.com/mdlayher/netlink v1.7.2 diff --git a/go.sum b/go.sum index 08d1d002..188456b7 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/tink/go v1.6.1 h1:t7JHqO8Ath2w2ig5vjwQYJzhGEZymedQc90lQXUBa4I= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 h1:q3OEI9RaN/wwcx+qgGo6ZaoJkCiDYe/gjDLfq7lQQF4= github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905/go.mod h1:VvGYjkZoJyKqlmT1yzakUs4mfKMNB0XdODP0+rdml6k= diff --git a/transport/echtunnel/client.go b/transport/echtunnel/client.go new file mode 100644 index 00000000..2a6bf726 --- /dev/null +++ b/transport/echtunnel/client.go @@ -0,0 +1,92 @@ +package echtunnel + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/url" + "strconv" + "time" + + "github.com/gorilla/websocket" +) + +type Config struct { + Server string + Port int + WSPath string + Token string + ECHDomain string + DNS string + IP string +} + +type Client struct { + config Config + dialer *websocket.Dialer +} + +func NewClient(config Config) (*Client, error) { + // 设置默认值 + if config.WSPath == "" { + config.WSPath = "/tunnel" + } + if config.ECHDomain == "" { + config.ECHDomain = "cloudflare-ech.com" + } + + // TLS 配置 + tlsConfig := &tls.Config{ + ServerName: config.ECHDomain, + MinVersion: tls.VersionTLS13, + // TODO: 添加 ECH 配置 + // 可以使用 Mihomo 现有的 ECH 支持 + } + + dialer := &websocket.Dialer{ + TLSClientConfig: tlsConfig, + HandshakeTimeout: 30 * time.Second, + } + + return &Client{ + config: config, + dialer: dialer, + }, nil +} + +func (c *Client) DialContext(ctx context.Context, address string) (net.Conn, error) { + // 构建 WebSocket URL + u := url.URL{ + Scheme: "wss", + Host: net.JoinHostPort(c.config.Server, strconv.Itoa(c.config.Port)), + Path: c.config.WSPath, + } + + // 添加 Token + if c.config.Token != "" { + q := u.Query() + q.Set("token", c.config.Token) + u.RawQuery = q.Encode() + } + + // 建立 WebSocket 连接 + conn, _, err := c.dialer.DialContext(ctx, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("websocket dial failed: %w", err) + } + + // 发送目标地址 + err = conn.WriteMessage(websocket.TextMessage, []byte(address)) + if err != nil { + conn.Close() + return nil, fmt.Errorf("send target address failed: %w", err) + } + + // 返回包装后的连接 + return &WSConn{Conn: conn}, nil +} + +func (c *Client) Close() error { + return nil +} diff --git a/transport/echtunnel/conn.go b/transport/echtunnel/conn.go new file mode 100644 index 00000000..a2ffa125 --- /dev/null +++ b/transport/echtunnel/conn.go @@ -0,0 +1,76 @@ +package echtunnel + +import ( + "fmt" + "io" + "net" + "time" + + "github.com/gorilla/websocket" +) + +type WSConn struct { + *websocket.Conn + reader io.Reader +} + +func (c *WSConn) Read(b []byte) (int, error) { + if c.reader == nil { + msgType, r, err := c.NextReader() + if err != nil { + return 0, err + } + if msgType != websocket.BinaryMessage { + return 0, fmt.Errorf("unexpected message type: %d", msgType) + } + c.reader = r + } + + n, err := c.reader.Read(b) + if err == io.EOF { + c.reader = nil + err = nil + } + return n, err +} + +func (c *WSConn) Write(b []byte) (int, error) { + err := c.WriteMessage(websocket.BinaryMessage, b) + if err != nil { + return 0, err + } + return len(b), nil +} + +func (c *WSConn) SetDeadline(t time.Time) error { + if err := c.SetReadDeadline(t); err != nil { + return err + } + return c.SetWriteDeadline(t) +} + +func (c *WSConn) LocalAddr() net.Addr { + return c.Conn.LocalAddr() +} + +func (c *WSConn) RemoteAddr() net.Addr { + return c.Conn.RemoteAddr() +} + +// PacketConn for UDP +type PacketConn struct { + net.Conn +} + +func NewPacketConn(conn net.Conn) *PacketConn { + return &PacketConn{Conn: conn} +} + +func (pc *PacketConn) ReadFrom(b []byte) (int, net.Addr, error) { + n, err := pc.Read(b) + return n, pc.RemoteAddr(), err +} + +func (pc *PacketConn) WriteTo(b []byte, addr net.Addr) (int, error) { + return pc.Write(b) +}