diff --git a/adapter/outbound/hysteria.go b/adapter/outbound/hysteria.go index 7da4975d..ff6e5deb 100644 --- a/adapter/outbound/hysteria.go +++ b/adapter/outbound/hysteria.go @@ -268,42 +268,6 @@ func NewHysteria(option HysteriaOption) (*Hysteria, error) { }, nil } -func stringToBps(s string) uint64 { - if s == "" { - return 0 - } - - // when have not unit, use Mbps - if v, err := strconv.Atoi(s); err == nil { - return stringToBps(fmt.Sprintf("%d Mbps", v)) - } - - m := rateStringRegexp.FindStringSubmatch(s) - if m == nil { - return 0 - } - var n uint64 - switch m[2] { - case "K": - n = 1 << 10 - case "M": - n = 1 << 20 - case "G": - n = 1 << 30 - case "T": - n = 1 << 40 - default: - n = 1 - } - v, _ := strconv.ParseUint(m[1], 10, 64) - n = v * n - if m[3] == "b" { - // Bits, need to convert to bytes - n = n >> 3 - } - return n -} - type hyPacketConn struct { core.UDPConn } diff --git a/adapter/outbound/hysteria2.go b/adapter/outbound/hysteria2.go new file mode 100644 index 00000000..61c8b7c7 --- /dev/null +++ b/adapter/outbound/hysteria2.go @@ -0,0 +1,218 @@ +package outbound + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "net" + "os" + "runtime" + "strconv" + + CN "github.com/Dreamacro/clash/common/net" + "github.com/Dreamacro/clash/component/dialer" + "github.com/Dreamacro/clash/component/proxydialer" + tlsC "github.com/Dreamacro/clash/component/tls" + C "github.com/Dreamacro/clash/constant" + tuicCommon "github.com/Dreamacro/clash/transport/tuic/common" + + "github.com/metacubex/sing-quic/hysteria2" + + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func init() { + hysteria2.SetCongestionController = tuicCommon.SetCongestionController +} + +type Hysteria2 struct { + *Base + + option *Hysteria2Option + client *hysteria2.Client + dialer *hy2SingDialer +} + +type Hysteria2Option struct { + BasicOption + Name string `proxy:"name"` + Server string `proxy:"server"` + Port int `proxy:"port"` + Up string `proxy:"up,omitempty"` + Down string `proxy:"down,omitempty"` + Password string `proxy:"password,omitempty"` + Obfs string `proxy:"obfs,omitempty"` + ObfsPassword string `proxy:"obfs-password,omitempty"` + SNI string `proxy:"sni,omitempty"` + SkipCertVerify bool `proxy:"skip-cert-verify,omitempty"` + Fingerprint string `proxy:"fingerprint,omitempty"` + ALPN []string `proxy:"alpn,omitempty"` + CustomCA string `proxy:"ca,omitempty"` + CustomCAString string `proxy:"ca-str,omitempty"` +} + +type hy2SingDialer struct { + dialer dialer.Dialer + proxyName string +} + +var _ N.Dialer = (*hy2SingDialer)(nil) + +func (d *hy2SingDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + var cDialer C.Dialer = d.dialer + if len(d.proxyName) > 0 { + pd, err := proxydialer.NewByName(d.proxyName, d.dialer) + if err != nil { + return nil, err + } + cDialer = pd + } + return cDialer.DialContext(ctx, network, destination.String()) +} + +func (d *hy2SingDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + var cDialer C.Dialer = d.dialer + if len(d.proxyName) > 0 { + pd, err := proxydialer.NewByName(d.proxyName, d.dialer) + if err != nil { + return nil, err + } + cDialer = pd + } + return cDialer.ListenPacket(ctx, "udp", "", destination.AddrPort()) +} + +func (h *Hysteria2) DialContext(ctx context.Context, metadata *C.Metadata, opts ...dialer.Option) (_ C.Conn, err error) { + options := h.Base.DialOptions(opts...) + h.dialer.dialer = dialer.NewDialer(options...) + c, err := h.client.DialConn(ctx, M.ParseSocksaddr(metadata.RemoteAddress())) + if err != nil { + return nil, err + } + return NewConn(CN.NewRefConn(c, h), h), nil +} + +func (h *Hysteria2) ListenPacketContext(ctx context.Context, metadata *C.Metadata, opts ...dialer.Option) (_ C.PacketConn, err error) { + options := h.Base.DialOptions(opts...) + h.dialer.dialer = dialer.NewDialer(options...) + pc, err := h.client.ListenPacket(ctx) + if err != nil { + return nil, err + } + if pc == nil { + return nil, errors.New("packetConn is nil") + } + return newPacketConn(CN.NewRefPacketConn(CN.NewThreadSafePacketConn(pc), h), h), nil +} + +func closeHysteria2(h *Hysteria2) { + if h.client != nil { + _ = h.client.CloseWithError(errors.New("proxy removed")) + } +} + +func NewHysteria2(option Hysteria2Option) (*Hysteria2, error) { + addr := net.JoinHostPort(option.Server, strconv.Itoa(option.Port)) + var salamanderPassword string + if len(option.Obfs) > 0 { + if option.ObfsPassword == "" { + return nil, errors.New("missing obfs password") + } + switch option.Obfs { + case hysteria2.ObfsTypeSalamander: + salamanderPassword = option.ObfsPassword + default: + return nil, fmt.Errorf("unknown obfs type: %s", option.Obfs) + } + } + + serverName := option.Server + if option.SNI != "" { + serverName = option.SNI + } + + tlsConfig := &tls.Config{ + ServerName: serverName, + InsecureSkipVerify: option.SkipCertVerify, + MinVersion: tls.VersionTLS13, + } + + var bs []byte + var err error + if len(option.CustomCA) > 0 { + bs, err = os.ReadFile(option.CustomCA) + if err != nil { + return nil, fmt.Errorf("hysteria %s load ca error: %w", option.Name, err) + } + } else if option.CustomCAString != "" { + bs = []byte(option.CustomCAString) + } + + if len(bs) > 0 { + block, _ := pem.Decode(bs) + if block == nil { + return nil, fmt.Errorf("CA cert is not PEM") + } + + fpBytes := sha256.Sum256(block.Bytes) + if len(option.Fingerprint) == 0 { + option.Fingerprint = hex.EncodeToString(fpBytes[:]) + } + } + + if len(option.Fingerprint) != 0 { + var err error + tlsConfig, err = tlsC.GetSpecifiedFingerprintTLSConfig(tlsConfig, option.Fingerprint) + if err != nil { + return nil, err + } + } else { + tlsConfig = tlsC.GetGlobalTLSConfig(tlsConfig) + } + + if len(option.ALPN) > 0 { + tlsConfig.NextProtos = option.ALPN + } + + singDialer := &hy2SingDialer{dialer: dialer.NewDialer(), proxyName: option.DialerProxy} + + clientOptions := hysteria2.ClientOptions{ + Context: context.TODO(), + Dialer: singDialer, + ServerAddress: M.ParseSocksaddrHostPort(option.Server, uint16(option.Port)), + SendBPS: stringToBps(option.Up), + ReceiveBPS: stringToBps(option.Down), + SalamanderPassword: salamanderPassword, + Password: option.Password, + TLSConfig: tlsConfig, + UDPDisabled: false, + } + + client, err := hysteria2.NewClient(clientOptions) + if err != nil { + return nil, err + } + + outbound := &Hysteria2{ + Base: &Base{ + name: option.Name, + addr: addr, + tp: C.Hysteria2, + udp: true, + iface: option.Interface, + rmark: option.RoutingMark, + prefer: C.NewDNSPrefer(option.IPVersion), + }, + option: &option, + client: client, + dialer: singDialer, + } + runtime.SetFinalizer(outbound, closeHysteria2) + + return outbound, nil +} diff --git a/adapter/outbound/util.go b/adapter/outbound/util.go index 36607e4f..3faa3c43 100644 --- a/adapter/outbound/util.go +++ b/adapter/outbound/util.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "crypto/tls" + "fmt" "net" "net/netip" + "strconv" "sync" "github.com/Dreamacro/clash/component/resolver" @@ -120,3 +122,39 @@ func safeConnClose(c net.Conn, err error) { _ = c.Close() } } + +func stringToBps(s string) uint64 { + if s == "" { + return 0 + } + + // when have not unit, use Mbps + if v, err := strconv.Atoi(s); err == nil { + return stringToBps(fmt.Sprintf("%d Mbps", v)) + } + + m := rateStringRegexp.FindStringSubmatch(s) + if m == nil { + return 0 + } + var n uint64 + switch m[2] { + case "K": + n = 1 << 10 + case "M": + n = 1 << 20 + case "G": + n = 1 << 30 + case "T": + n = 1 << 40 + default: + n = 1 + } + v, _ := strconv.ParseUint(m[1], 10, 64) + n = v * n + if m[3] == "b" { + // Bits, need to convert to bytes + n = n >> 3 + } + return n +} diff --git a/adapter/parser.go b/adapter/parser.go index 78e287f9..eeb0fd59 100644 --- a/adapter/parser.go +++ b/adapter/parser.go @@ -92,6 +92,13 @@ func ParseProxy(mapping map[string]any) (C.Proxy, error) { break } proxy, err = outbound.NewHysteria(*hyOption) + case "hysteria2": + hyOption := &outbound.Hysteria2Option{} + err = decoder.Decode(mapping, hyOption) + if err != nil { + break + } + proxy, err = outbound.NewHysteria2(*hyOption) case "wireguard": wgOption := &outbound.WireGuardOption{} err = decoder.Decode(mapping, wgOption) diff --git a/constant/adapters.go b/constant/adapters.go index 5639dd47..33b9a44f 100644 --- a/constant/adapters.go +++ b/constant/adapters.go @@ -36,6 +36,7 @@ const ( Vless Trojan Hysteria + Hysteria2 WireGuard Tuic ) @@ -200,6 +201,8 @@ func (at AdapterType) String() string { return "Trojan" case Hysteria: return "Hysteria" + case Hysteria2: + return "Hysteria2" case WireGuard: return "WireGuard" case Tuic: diff --git a/go.mod b/go.mod index 698fcc8a..d16ccb01 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/mdlayher/netlink v1.7.2 github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 github.com/metacubex/quic-go v0.38.1-0.20230909013832-033f6a2115cf + github.com/metacubex/sing-quic v0.0.0-20230921015854-1ed89eed54d5 github.com/metacubex/sing-shadowsocks v0.2.5 github.com/metacubex/sing-shadowsocks2 v0.1.4 github.com/metacubex/sing-tun v0.1.12 diff --git a/go.sum b/go.sum index 74cff3cf..45cb3cbb 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,8 @@ github.com/metacubex/quic-go v0.38.1-0.20230909013832-033f6a2115cf h1:hflzPbb2M+ github.com/metacubex/quic-go v0.38.1-0.20230909013832-033f6a2115cf/go.mod h1:7RCcKJJk1DMeNQQNnYKS+7FqftqPfG031oP8jrYRMw8= github.com/metacubex/sing v0.0.0-20230921005553-6eacdd2c7a24 h1:652uMd78eKMU7/sVkW8qqAdZkJaiDoUflfCs5LHvb0Q= github.com/metacubex/sing v0.0.0-20230921005553-6eacdd2c7a24/go.mod h1:GQ673iPfUnkbK/dIPkfd1Xh1MjOGo36gkl/mkiHY7Jg= +github.com/metacubex/sing-quic v0.0.0-20230921015854-1ed89eed54d5 h1:XhWilr6vJoXy4n/sP2datek28FTF3s3rWHhezdFFD8s= +github.com/metacubex/sing-quic v0.0.0-20230921015854-1ed89eed54d5/go.mod h1:oGpQmqe5tj3sPdPWCNLbBoUSwqd+Z6SqVO7TlMNVnH4= github.com/metacubex/sing-shadowsocks v0.2.5 h1:O2RRSHlKGEpAVG/OHJQxyHqDy8uvvdCW/oW2TDBOIhc= github.com/metacubex/sing-shadowsocks v0.2.5/go.mod h1:Xz2uW9BEYGEoA8B4XEpoxt7ERHClFCwsMAvWaruoyMo= github.com/metacubex/sing-shadowsocks2 v0.1.4 h1:OOCf8lgsVcpTOJUeaFAMzyKVebaQOBnKirDdUdBoKIE=