diff --git a/adapter/outbound/hysteria2.go b/adapter/outbound/hysteria2.go index 8fc038ea..168a4221 100644 --- a/adapter/outbound/hysteria2.go +++ b/adapter/outbound/hysteria2.go @@ -5,19 +5,23 @@ import ( "errors" "fmt" "net" + "net/netip" "strconv" "time" N "github.com/metacubex/mihomo/common/net" "github.com/metacubex/mihomo/common/utils" "github.com/metacubex/mihomo/component/ca" + "github.com/metacubex/mihomo/component/resolver" C "github.com/metacubex/mihomo/constant" "github.com/metacubex/mihomo/log" "github.com/metacubex/mihomo/transport/tuic/common" + "github.com/metacubex/http" "github.com/metacubex/quic-go" qtls "github.com/metacubex/sing-quic" "github.com/metacubex/sing-quic/hysteria2" + "github.com/metacubex/sing-quic/hysteria2/realm" M "github.com/metacubex/sing/common/metadata" "github.com/metacubex/tls" ) @@ -55,6 +59,8 @@ type Hysteria2Option struct { BBRProfile string `proxy:"bbr-profile,omitempty"` UdpMTU int `proxy:"udp-mtu,omitempty"` + RealmOpts Hysteria2RealmOption `proxy:"realm-opts,omitempty"` + // quic-go special config InitialStreamReceiveWindow uint64 `proxy:"initial-stream-receive-window,omitempty"` MaxStreamReceiveWindow uint64 `proxy:"max-stream-receive-window,omitempty"` @@ -62,6 +68,22 @@ type Hysteria2Option struct { MaxConnectionReceiveWindow uint64 `proxy:"max-connection-receive-window,omitempty"` } +type Hysteria2RealmOption struct { + Enable bool `proxy:"enable,omitempty"` + ServerURL string `proxy:"server-url,omitempty"` + Token string `proxy:"token,omitempty"` + RealmID string `proxy:"realm-id,omitempty"` + STUNServers []string `proxy:"stun-servers,omitempty"` + + // for ServerURL + SNI string `proxy:"sni,omitempty"` + SkipCertVerify bool `proxy:"skip-cert-verify,omitempty"` + Fingerprint string `proxy:"fingerprint,omitempty"` + Certificate string `proxy:"certificate,omitempty"` + PrivateKey string `proxy:"private-key,omitempty"` + ALPN []string `proxy:"alpn,omitempty"` +} + func (h *Hysteria2) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn, err error) { c, err := h.client.DialConn(ctx, M.ParseSocksaddrHostPort(metadata.String(), metadata.DstPort)) if err != nil { @@ -229,6 +251,48 @@ func NewHysteria2(option Hysteria2Option) (*Hysteria2, error) { return nil, errors.New("invalid port") } + if option.RealmOpts.Enable { + httpTLSClientConfig, err := ca.GetTLSConfig(ca.Option{ + TLSConfig: &tls.Config{ + ServerName: option.RealmOpts.SNI, + InsecureSkipVerify: option.RealmOpts.SkipCertVerify, + }, + Fingerprint: option.RealmOpts.Fingerprint, + Certificate: option.RealmOpts.Certificate, + PrivateKey: option.RealmOpts.PrivateKey, + }) + if err != nil { + return nil, err + } + clientOptions.RealmOptions = &realm.Options{ + ServerURL: option.RealmOpts.ServerURL, + Token: option.RealmOpts.Token, + RealmID: option.RealmOpts.RealmID, + STUNServers: option.RealmOpts.STUNServers, + HTTPClient: &http.Client{ + Transport: &http.Transport{ + DialContext: outbound.dialer.DialContext, + TLSClientConfig: httpTLSClientConfig, + // from http.DefaultTransport + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + }, + Resolver: func(ctx context.Context, host string, ipv4, ipv6 bool) ([]netip.Addr, error) { + if ipv4 && !ipv6 { + return resolver.LookupIPv4WithResolver(ctx, host, resolver.ProxyServerHostResolver) + } else if ipv6 && !ipv4 { + return resolver.LookupIPv4WithResolver(ctx, host, resolver.ProxyServerHostResolver) + } + return resolver.LookupIPWithResolver(ctx, host, resolver.ProxyServerHostResolver) + }, + Logger: log.SingLogger, + } + } + client, err := hysteria2.NewClient(clientOptions) if err != nil { return nil, err diff --git a/docs/config.yaml b/docs/config.yaml index 15e8b7fc..5199aff2 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -1014,6 +1014,18 @@ proxies: # socks5 # private-key: ./client.key # 证书对应的私钥 PEM 格式,或者私钥路径 # alpn: # - h3 + # realm-opts: + # enable: true # 必须手动开启 + # server-url: https://realm.hy2.io + # token: public + # realm-id: my-cabin-1f3a8c2e9b + # stun-servers: + # - stun.nextcloud.com:3478 + # - stun.sip.us:3478 + # - global.stun.twilio.com:3478 + # # 下面支持填写针对server-url的TLS配置(sni, skip-cert-verify, fingerprint, certificate, private-key, alpn) + # # skip-cert-verify: false + # # ...... ###quic-go特殊配置项,不要随意修改除非你知道你在干什么### # initial-stream-receive-window: 8388608 # max-stream-receive-window: 8388608 @@ -1903,6 +1915,18 @@ listeners: # masquerade: file:///var/www # 作为文件服务器 # masquerade: http://127.0.0.1:8080 #作为反向代理 # masquerade: https://127.0.0.1:8080 #作为反向代理 + # realm-opts: + # enable: true # 必须手动开启 + # server-url: https://realm.hy2.io + # token: public + # realm-id: my-cabin-1f3a8c2e9b + # stun-servers: + # - stun.nextcloud.com:3478 + # - stun.sip.us:3478 + # - global.stun.twilio.com:3478 + # # 下面支持填写针对server-url的TLS配置(sni, skip-cert-verify, fingerprint, certificate, private-key, alpn) + # # skip-cert-verify: false + # # ...... - name: trusttunnel-in-1 type: trusttunnel diff --git a/go.mod b/go.mod index 8871a177..5fe3250b 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( github.com/metacubex/restls-client-go v0.1.7 github.com/metacubex/sing v0.5.7 github.com/metacubex/sing-mux v0.3.9 - github.com/metacubex/sing-quic v0.0.0-20260414034501-3ea3410d197a + github.com/metacubex/sing-quic v0.0.0-20260511111944-ed400da99ad4 github.com/metacubex/sing-shadowsocks v0.2.12 github.com/metacubex/sing-shadowsocks2 v0.2.7 github.com/metacubex/sing-shadowtls v0.0.0-20250503063515-5d9f966d17a2 diff --git a/go.sum b/go.sum index d0badd1f..49a7e399 100644 --- a/go.sum +++ b/go.sum @@ -127,8 +127,8 @@ github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM= github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w= github.com/metacubex/sing-mux v0.3.9 h1:/aoBD2+sK2qsXDlNDe3hkR0GZuFDtwIZhOeGUx9W0Yk= github.com/metacubex/sing-mux v0.3.9/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc= -github.com/metacubex/sing-quic v0.0.0-20260414034501-3ea3410d197a h1:977o0ZYYbiQAGuOxql7Q6UN3rEy59OyAE0tELq4gZfI= -github.com/metacubex/sing-quic v0.0.0-20260414034501-3ea3410d197a/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk= +github.com/metacubex/sing-quic v0.0.0-20260511111944-ed400da99ad4 h1:WwMH5gADSmQ2RgudpoAym4nSk5U70QwfovgCqXBX34M= +github.com/metacubex/sing-quic v0.0.0-20260511111944-ed400da99ad4/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk= github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE= github.com/metacubex/sing-shadowsocks v0.2.12/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU= github.com/metacubex/sing-shadowsocks2 v0.2.7 h1:hSuuc0YpsfiqYqt1o+fP4m34BQz4e6wVj3PPBVhor3A= diff --git a/listener/config/hysteria2.go b/listener/config/hysteria2.go index 6f27bfbb..8f67316c 100644 --- a/listener/config/hysteria2.go +++ b/listener/config/hysteria2.go @@ -28,6 +28,8 @@ type Hysteria2Server struct { UdpMTU int `yaml:"udp-mtu" json:"udp-mtu,omitempty"` MuxOption sing.MuxOption `yaml:"mux-option" json:"mux-option,omitempty"` + RealmOpts Hysteria2RealmOption `yaml:"realm-opts" json:"realm-opts,omitempty"` + // quic-go special config InitialStreamReceiveWindow uint64 `yaml:"initial-stream-receive-window" json:"initial-stream-receive-window,omitempty"` MaxStreamReceiveWindow uint64 `yaml:"max-stream-receive-window" json:"max-stream-receive-window,omitempty"` @@ -35,6 +37,22 @@ type Hysteria2Server struct { MaxConnectionReceiveWindow uint64 `yaml:"max-connection-receive-window" json:"max-connection-receive-window,omitempty"` } +type Hysteria2RealmOption struct { + Enable bool `yaml:"enable" json:"enable,omitempty"` + ServerURL string `yaml:"server-url" json:"server-url,omitempty"` + Token string `yaml:"token" json:"token,omitempty"` + RealmID string `yaml:"realm-id" json:"realm-id,omitempty"` + STUNServers []string `yaml:"stun-servers" json:"stun-servers,omitempty"` + + // for ServerURL + SNI string `yaml:"sni" json:"sni,omitempty"` + SkipCertVerify bool `yaml:"skip-cert-verify" json:"skip-cert-verify,omitempty"` + Fingerprint string `yaml:"fingerprint" json:"fingerprint,omitempty"` + Certificate string `yaml:"certificate" json:"certificate,omitempty"` + PrivateKey string `yaml:"private-key" json:"private-key,omitempty"` + ALPN []string `yaml:"alpn" json:"alpn,omitempty"` +} + func (h Hysteria2Server) String() string { b, _ := json.Marshal(h) return string(b) diff --git a/listener/inbound/hysteria2.go b/listener/inbound/hysteria2.go index d9cbd5cc..a1b93a73 100644 --- a/listener/inbound/hysteria2.go +++ b/listener/inbound/hysteria2.go @@ -30,6 +30,8 @@ type Hysteria2Option struct { UdpMTU int `inbound:"udp-mtu,omitempty"` MuxOption MuxOption `inbound:"mux-option,omitempty"` + RealmOpts Hysteria2RealmOption `inbound:"realm-opts,omitempty"` + // quic-go special config InitialStreamReceiveWindow uint64 `inbound:"initial-stream-receive-window,omitempty"` MaxStreamReceiveWindow uint64 `inbound:"max-stream-receive-window,omitempty"` @@ -37,6 +39,38 @@ type Hysteria2Option struct { MaxConnectionReceiveWindow uint64 `inbound:"max-connection-receive-window,omitempty"` } +type Hysteria2RealmOption struct { + Enable bool `inbound:"enable,omitempty"` + ServerURL string `inbound:"server-url,omitempty"` + Token string `inbound:"token,omitempty"` + RealmID string `inbound:"realm-id,omitempty"` + STUNServers []string `inbound:"stun-servers,omitempty"` + + // for ServerURL + SNI string `inbound:"sni,omitempty"` + SkipCertVerify bool `inbound:"skip-cert-verify,omitempty"` + Fingerprint string `inbound:"fingerprint,omitempty"` + Certificate string `inbound:"certificate,omitempty"` + PrivateKey string `inbound:"private-key,omitempty"` + ALPN []string `inbound:"alpn,omitempty"` +} + +func (o Hysteria2RealmOption) Build() LC.Hysteria2RealmOption { + return LC.Hysteria2RealmOption{ + Enable: o.Enable, + ServerURL: o.ServerURL, + Token: o.Token, + RealmID: o.RealmID, + STUNServers: o.STUNServers, + SNI: o.SNI, + SkipCertVerify: o.SkipCertVerify, + Fingerprint: o.Fingerprint, + Certificate: o.Certificate, + PrivateKey: o.PrivateKey, + ALPN: o.ALPN, + } +} + func (o Hysteria2Option) Equal(config C.InboundConfig) bool { return optionToString(o) == optionToString(config) } @@ -77,6 +111,7 @@ func NewHysteria2(options *Hysteria2Option) (*Hysteria2, error) { BBRProfile: options.BBRProfile, UdpMTU: options.UdpMTU, MuxOption: options.MuxOption.Build(), + RealmOpts: options.RealmOpts.Build(), // quic-go special config InitialStreamReceiveWindow: options.InitialStreamReceiveWindow, MaxStreamReceiveWindow: options.MaxStreamReceiveWindow, diff --git a/listener/sing_hysteria2/server.go b/listener/sing_hysteria2/server.go index 9472fec9..8f85b67d 100644 --- a/listener/sing_hysteria2/server.go +++ b/listener/sing_hysteria2/server.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net" + "net/netip" "net/url" "strings" "time" @@ -14,6 +15,7 @@ import ( "github.com/metacubex/mihomo/common/sockopt" "github.com/metacubex/mihomo/component/ca" "github.com/metacubex/mihomo/component/ech" + "github.com/metacubex/mihomo/component/resolver" C "github.com/metacubex/mihomo/constant" LC "github.com/metacubex/mihomo/listener/config" "github.com/metacubex/mihomo/listener/inner" @@ -26,6 +28,7 @@ import ( "github.com/metacubex/http/httputil" "github.com/metacubex/quic-go" "github.com/metacubex/sing-quic/hysteria2" + "github.com/metacubex/sing-quic/hysteria2/realm" E "github.com/metacubex/sing/common/exceptions" "github.com/metacubex/tls" ) @@ -146,6 +149,48 @@ func New(config LC.Hysteria2Server, tunnel C.Tunnel, additions ...inbound.Additi return nil, E.New("unknown masquerade URL scheme: ", masqueradeURL.Scheme) } } + var realmOptions *realm.Options + if config.RealmOpts.Enable { + httpTLSClientConfig, err := ca.GetTLSConfig(ca.Option{ + TLSConfig: &tls.Config{ + ServerName: config.RealmOpts.SNI, + InsecureSkipVerify: config.RealmOpts.SkipCertVerify, + }, + Fingerprint: config.RealmOpts.Fingerprint, + Certificate: config.RealmOpts.Certificate, + PrivateKey: config.RealmOpts.PrivateKey, + }) + if err != nil { + return nil, err + } + realmOptions = &realm.Options{ + ServerURL: config.RealmOpts.ServerURL, + Token: config.RealmOpts.Token, + RealmID: config.RealmOpts.RealmID, + STUNServers: config.RealmOpts.STUNServers, + HTTPClient: &http.Client{Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + return inner.HandleTcp(tunnel, address, "") + }, + TLSClientConfig: httpTLSClientConfig, + // from http.DefaultTransport + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }}, + Resolver: func(ctx context.Context, host string, ipv4, ipv6 bool) ([]netip.Addr, error) { + if ipv4 && !ipv6 { + return resolver.LookupIPv4WithResolver(ctx, host, resolver.ProxyServerHostResolver) + } else if ipv6 && !ipv4 { + return resolver.LookupIPv4WithResolver(ctx, host, resolver.ProxyServerHostResolver) + } + return resolver.LookupIPWithResolver(ctx, host, resolver.ProxyServerHostResolver) + }, + Logger: log.SingLogger, + } + } if config.UdpMTU == 0 { // "1200" from quic-go's MaxDatagramSize @@ -173,6 +218,7 @@ func New(config LC.Hysteria2Server, tunnel C.Tunnel, additions ...inbound.Additi Handler: h, MasqueradeHandler: masqueradeHandler, UdpMTU: config.UdpMTU, + RealmOptions: realmOptions, SetBBRCongestion: func(quicConn *quic.Conn) { common.SetCongestionController(quicConn, "bbr", config.CWND, config.BBRProfile) },