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
This commit is contained in:
goukey 2025-12-12 15:16:03 +08:00
parent b753a57e6a
commit 86835c9be3
7 changed files with 296 additions and 0 deletions

View File

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

View File

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

View File

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

1
go.mod
View File

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

2
go.sum
View File

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

View File

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

View File

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