package hysteria2 import ( "context" "net" "net/http" "net/netip" "net/url" "os" "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/outbound" "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/protocol/tuic" qtls "github.com/sagernet/sing-quic" "github.com/sagernet/sing-quic/hysteria" "github.com/sagernet/sing-quic/hysteria2" "github.com/sagernet/sing-quic/hysteria2/realm" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/bufio" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/service" ) func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.Hysteria2OutboundOptions](registry, C.TypeHysteria2, NewOutbound) } var ( _ adapter.Outbound = (*tuic.Outbound)(nil) _ adapter.InterfaceUpdateListener = (*tuic.Outbound)(nil) ) type Outbound struct { outbound.Adapter logger logger.ContextLogger client *hysteria2.Client } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.Hysteria2OutboundOptions) (adapter.Outbound, error) { options.UDPFragmentDefault = true if options.TLS == nil || !options.TLS.Enabled { return nil, C.ErrTLSRequired } tlsServerAddress, tlsOptions, err := outboundTLSOptions(options) if err != nil { return nil, err } tlsConfig, err := tls.NewClient(ctx, logger, tlsServerAddress, tlsOptions) if err != nil { return nil, err } var salamanderPassword string var geckoPassword string var geckoMinPacketSize, geckoMaxPacketSize int if options.Obfs != nil { if options.Obfs.Password == "" { return nil, E.New("missing obfs password") } switch options.Obfs.Type { case hysteria2.ObfsTypeSalamander: salamanderPassword = options.Obfs.Password case hysteria2.ObfsTypeGecko: geckoPassword = options.Obfs.Password geckoMinPacketSize = options.Obfs.GeckoOptions.MinPacketSize geckoMaxPacketSize = options.Obfs.GeckoOptions.MaxPacketSize default: return nil, E.New("unknown obfs type: ", options.Obfs.Type) } } outboundDialer, err := dialer.NewWithOptions(dialer.Options{ Context: ctx, Options: options.DialerOptions, RemoteIsDomain: options.ServerIsDomain(), }) if err != nil { return nil, err } var realmOptions *realm.Options if options.Realm != nil { queryOptions, err := adapter.DNSQueryOptionsFrom(ctx, options.DialerOptions.DomainResolver) if err != nil { return nil, err } httpClientTransport, err := service.FromContext[adapter.HTTPClientManager](ctx).ResolveTransport(ctx, logger, common.PtrValueOrDefault(options.Realm.HTTPClient)) if err != nil { return nil, E.Cause(err, "create realm http client") } dnsRouter := service.FromContext[adapter.DNSRouter](ctx) realmOptions = &realm.Options{ ServerURL: options.Realm.ServerURL, Token: options.Realm.Token, RealmID: options.Realm.RealmID, STUNServers: options.Realm.STUNServers, HTTPClient: &http.Client{Transport: httpClientTransport}, Resolver: func(ctx context.Context, host string, ipv4, ipv6 bool) ([]netip.Addr, error) { dnsOptions := queryOptions switch { case ipv4 && !ipv6: dnsOptions.Strategy = C.DomainStrategyIPv4Only case !ipv4 && ipv6: dnsOptions.Strategy = C.DomainStrategyIPv6Only } return dnsRouter.Lookup(ctx, host, dnsOptions) }, Logger: logger, IPVersion: options.Realm.IPVersion, } if options.Realm.PortMapping != nil && options.Realm.PortMapping.Enabled { realmOptions.PortMapping = &realm.PortMappingOptions{ Timeout: time.Duration(options.Realm.PortMapping.Timeout), Lifetime: time.Duration(options.Realm.PortMapping.Lifetime), } } } networkList := options.Network.Build() client, err := hysteria2.NewClient(hysteria2.ClientOptions{ Context: ctx, Dialer: outboundDialer, Logger: logger, BrutalDebug: options.BrutalDebug, ServerAddress: options.ServerOptions.Build(), ServerPorts: options.ServerPorts, HopInterval: time.Duration(options.HopInterval), HopIntervalMax: time.Duration(options.HopIntervalMax), SendBPS: uint64(options.UpMbps * hysteria.MbpsToBps), ReceiveBPS: uint64(options.DownMbps * hysteria.MbpsToBps), SalamanderPassword: salamanderPassword, GeckoPassword: geckoPassword, GeckoMinPacketSize: geckoMinPacketSize, GeckoMaxPacketSize: geckoMaxPacketSize, Password: options.Password, TLSConfig: tlsConfig, QUICOptions: qtls.QUICOptions{ IdleTimeout: options.IdleTimeout.Build(), KeepAlivePeriod: options.KeepAlivePeriod.Build(), StreamReceiveWindow: options.StreamReceiveWindow.Value(), ConnectionReceiveWindow: options.ConnectionReceiveWindow.Value(), MaxConcurrentStreams: options.MaxConcurrentStreams, InitialPacketSize: options.InitialPacketSize, DisablePathMTUDiscovery: options.DisablePathMTUDiscovery, }, UDPDisabled: !common.Contains(networkList, N.NetworkUDP), BBRProfile: options.BBRProfile, RealmOptions: realmOptions, }) if err != nil { return nil, err } return &Outbound{ Adapter: outbound.NewAdapterWithDialerOptions(C.TypeHysteria2, tag, networkList, options.DialerOptions), logger: logger, client: client, }, nil } func outboundTLSOptions(options option.Hysteria2OutboundOptions) (string, option.OutboundTLSOptions, error) { tlsOptions := common.PtrValueOrDefault(options.TLS) if options.Realm == nil { return options.Server, tlsOptions, nil } if options.Server != "" || options.ServerPort != 0 || len(options.ServerPorts) > 0 { return "", tlsOptions, E.New("realm conflicts with server, server_port, and server_ports") } serverURL, err := url.Parse(options.Realm.ServerURL) if err != nil { return "", tlsOptions, E.Cause(err, "parse realm server_url") } serverName := serverURL.Hostname() if serverName == "" { return "", tlsOptions, E.New("missing host in realm server_url") } return serverName, tlsOptions, nil } func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { switch N.NetworkName(network) { case N.NetworkTCP: h.logger.InfoContext(ctx, "outbound connection to ", destination) return h.client.DialConn(ctx, destination) case N.NetworkUDP: conn, err := h.ListenPacket(ctx, destination) if err != nil { return nil, err } return bufio.NewBindPacketConn(conn, destination), nil default: return nil, E.New("unsupported network: ", network) } } func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { h.logger.InfoContext(ctx, "outbound packet connection to ", destination) return h.client.ListenPacket(ctx) } func (h *Outbound) InterfaceUpdated() { h.client.CloseWithError(E.New("network changed")) } func (h *Outbound) Close() error { return h.client.CloseWithError(os.ErrClosed) }