Skip to content

Hysteria2 Protocol ​

Hysteria2 is a QUIC-based proxy protocol featuring bandwidth negotiation via the Brutal congestion control algorithm, Salamander obfuscation, and HTTP/3 masquerading. sing-box delegates the protocol implementation to sing-quic/hysteria2.

Source: protocol/hysteria2/inbound.go, protocol/hysteria2/outbound.go, sing-quic/hysteria2

Architecture Overview ​

Both inbound and outbound are thin wrappers around the sing-quic/hysteria2 library:

go
// Inbound
type Inbound struct {
    inbound.Adapter
    router       adapter.Router
    logger       log.ContextLogger
    listener     *listener.Listener
    tlsConfig    tls.ServerConfig
    service      *hysteria2.Service[int]
    userNameList []string
}

// Outbound
type Outbound struct {
    outbound.Adapter
    logger logger.ContextLogger
    client *hysteria2.Client
}

TLS Requirement ​

Hysteria2 unconditionally requires TLS on both sides:

go
if options.TLS == nil || !options.TLS.Enabled {
    return nil, C.ErrTLSRequired
}

Salamander Obfuscation ​

Salamander is the only supported obfuscation type. It wraps QUIC packets in a layer of obfuscation to prevent deep packet inspection from identifying them as QUIC:

go
var salamanderPassword string
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
    default:
        return nil, E.New("unknown obfs type: ", options.Obfs.Type)
    }
}

When Salamander is enabled, the password must match between client and server.

Bandwidth Negotiation (Brutal CC) ​

Hysteria2's core feature is its Brutal congestion control algorithm, which requires the client to declare its bandwidth. The server can also set bandwidth limits:

go
service, err := hysteria2.NewService[int](hysteria2.ServiceOptions{
    Context:               ctx,
    Logger:                logger,
    BrutalDebug:           options.BrutalDebug,
    SendBPS:               uint64(options.UpMbps * hysteria.MbpsToBps),
    ReceiveBPS:            uint64(options.DownMbps * hysteria.MbpsToBps),
    SalamanderPassword:    salamanderPassword,
    TLSConfig:             tlsConfig,
    IgnoreClientBandwidth: options.IgnoreClientBandwidth,
    UDPTimeout:            udpTimeout,
    Handler:               inbound,
    MasqueradeHandler:     masqueradeHandler,
})

Key bandwidth fields:

  • SendBPS / ReceiveBPS: Server's send and receive bandwidth in bits per second, converted from Mbps using hysteria.MbpsToBps
  • IgnoreClientBandwidth: When true, the server ignores client-declared bandwidth and uses its own settings
  • BrutalDebug: Enables debug logging for congestion control

The outbound similarly declares its bandwidth:

go
client, err := hysteria2.NewClient(hysteria2.ClientOptions{
    SendBPS:    uint64(options.UpMbps * hysteria.MbpsToBps),
    ReceiveBPS: uint64(options.DownMbps * hysteria.MbpsToBps),
    // ...
})

Masquerade ​

When non-Hysteria2 traffic arrives (e.g., a web browser), the inbound can serve a masquerade response. Three masquerade types are supported:

File Server ​

go
case C.Hysterai2MasqueradeTypeFile:
    masqueradeHandler = http.FileServer(http.Dir(options.Masquerade.FileOptions.Directory))

Reverse Proxy ​

go
case C.Hysterai2MasqueradeTypeProxy:
    masqueradeURL, _ := url.Parse(options.Masquerade.ProxyOptions.URL)
    masqueradeHandler = &httputil.ReverseProxy{
        Rewrite: func(r *httputil.ProxyRequest) {
            r.SetURL(masqueradeURL)
            if !options.Masquerade.ProxyOptions.RewriteHost {
                r.Out.Host = r.In.Host
            }
        },
    }

Static String ​

go
case C.Hysterai2MasqueradeTypeString:
    masqueradeHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if options.Masquerade.StringOptions.StatusCode != 0 {
            w.WriteHeader(options.Masquerade.StringOptions.StatusCode)
        }
        w.Write([]byte(options.Masquerade.StringOptions.Content))
    })

Port Hopping ​

The outbound supports port hopping -- connecting to multiple server ports to evade per-port throttling:

go
client, err := hysteria2.NewClient(hysteria2.ClientOptions{
    ServerAddress: options.ServerOptions.Build(),
    ServerPorts:   options.ServerPorts,         // port range list
    HopInterval:   time.Duration(options.HopInterval),  // how often to switch ports
    // ...
})

Listener Model ​

Unlike TCP-based protocols, Hysteria2 listens on UDP (QUIC). The inbound starts by listening for UDP packets and passing them to the QUIC service:

go
func (h *Inbound) Start(stage adapter.StartStage) error {
    if stage != adapter.StartStateStart {
        return nil
    }
    h.tlsConfig.Start()
    packetConn, err := h.listener.ListenUDP()
    if err != nil {
        return err
    }
    return h.service.Start(packetConn)
}

User Management ​

Users are identified by integer index, with a parallel name list for logging:

go
userList := make([]int, 0, len(options.Users))
userNameList := make([]string, 0, len(options.Users))
userPasswordList := make([]string, 0, len(options.Users))
for index, user := range options.Users {
    userList = append(userList, index)
    userNameList = append(userNameList, user.Name)
    userPasswordList = append(userPasswordList, user.Password)
}
service.UpdateUsers(userList, userPasswordList)

Authentication uses the user index stored in context:

go
userID, _ := auth.UserFromContext[int](ctx)
if userName := h.userNameList[userID]; userName != "" {
    metadata.User = userName
}

Connection Handling ​

Both TCP and UDP connections follow the standard sing-box pattern:

go
func (h *Inbound) NewConnectionEx(ctx, conn, source, destination, onClose) {
    // Set metadata fields
    h.router.RouteConnectionEx(ctx, conn, metadata, onClose)
}

func (h *Inbound) NewPacketConnectionEx(ctx, conn, source, destination, onClose) {
    // Set metadata fields
    h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
}

Outbound Connection ​

go
func (h *Outbound) DialContext(ctx, network, destination) (net.Conn, error) {
    switch N.NetworkName(network) {
    case N.NetworkTCP:
        return h.client.DialConn(ctx, destination)
    case N.NetworkUDP:
        conn, err := h.ListenPacket(ctx, destination)
        return bufio.NewBindPacketConn(conn, destination), nil
    }
}

func (h *Outbound) ListenPacket(ctx, destination) (net.PacketConn, error) {
    return h.client.ListenPacket(ctx)
}

Interface Update ​

The outbound implements adapter.InterfaceUpdateListener to handle network changes by closing the QUIC connection:

go
func (h *Outbound) InterfaceUpdated() {
    h.client.CloseWithError(E.New("network changed"))
}

Configuration Examples ​

Inbound ​

json
{
  "type": "hysteria2",
  "tag": "hy2-in",
  "listen": "::",
  "listen_port": 443,
  "up_mbps": 100,
  "down_mbps": 100,
  "obfs": {
    "type": "salamander",
    "password": "obfs-password"
  },
  "users": [
    { "name": "user1", "password": "user-password" }
  ],
  "tls": {
    "enabled": true,
    "certificate_path": "/path/to/cert.pem",
    "key_path": "/path/to/key.pem"
  },
  "masquerade": {
    "type": "proxy",
    "proxy": {
      "url": "https://www.example.com",
      "rewrite_host": true
    }
  }
}

Outbound ​

json
{
  "type": "hysteria2",
  "tag": "hy2-out",
  "server": "example.com",
  "server_port": 443,
  "up_mbps": 50,
  "down_mbps": 100,
  "password": "user-password",
  "obfs": {
    "type": "salamander",
    "password": "obfs-password"
  },
  "tls": {
    "enabled": true,
    "server_name": "example.com"
  }
}

With Port Hopping ​

json
{
  "type": "hysteria2",
  "tag": "hy2-hop",
  "server": "example.com",
  "server_ports": "443,8443-8500",
  "hop_interval": "30s",
  "password": "user-password",
  "tls": {
    "enabled": true,
    "server_name": "example.com"
  }
}