From c1fa912990b84acd7e54c35a7a376dfe7f3d80b2 Mon Sep 17 00:00:00 2001 From: FN Date: Wed, 3 Dec 2025 01:25:03 +0700 Subject: [PATCH 1/4] feat(hy2): implement salamander obfuscation --- dialer/hysteria2/hysteria2.go | 16 +++++ protocol/hysteria2/client/client.go | 4 ++ protocol/hysteria2/client/config.go | 22 ++++--- protocol/hysteria2/client/salamander.go | 86 +++++++++++++++++++++++++ protocol/hysteria2/dialer.go | 6 +- 5 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 protocol/hysteria2/client/salamander.go diff --git a/dialer/hysteria2/hysteria2.go b/dialer/hysteria2/hysteria2.go index 2f32be8..51a1892 100644 --- a/dialer/hysteria2/hysteria2.go +++ b/dialer/hysteria2/hysteria2.go @@ -28,6 +28,8 @@ type Hysteria2 struct { User string Password string Server string + Obfs string + ObfsPass string Insecure bool Sni string PinSHA256 string @@ -81,6 +83,12 @@ func (s *Hysteria2) Dialer(option *dialer.ExtraOption, nextDialer netproxy.Diale } } } + if s.Obfs != "" { + feature1.ObfuscationConfig = client.ObfuscationConfig{ + Obfuscation: s.Obfs, + ObfuscationKey: []byte(s.ObfsPass), + } + } header.Feature1 = feature1 if s.PinSHA256 != "" { @@ -148,6 +156,8 @@ func ParseHysteria2URL(link string) (*Hysteria2, error) { Name: u.Fragment, User: u.User.Username(), Server: u.Host, + Obfs: q.Get("obfs"), + ObfsPass: q.Get("obfs-password"), Insecure: insecure, Sni: q.Get("sni"), PinSHA256: q.Get("pinSHA256"), @@ -169,6 +179,12 @@ func (s *Hysteria2) ExportToURL() string { t.User = url.UserPassword(s.User, s.Password) } q := t.Query() + if s.Obfs != "" { + q.Set("obfs", s.Obfs) + } + if s.ObfsPass != "" { + q.Set("obfs-password", s.ObfsPass) + } if s.Insecure { q.Set("insecure", "1") } diff --git a/protocol/hysteria2/client/client.go b/protocol/hysteria2/client/client.go index 3d1dc1d..693c3ab 100644 --- a/protocol/hysteria2/client/client.go +++ b/protocol/hysteria2/client/client.go @@ -63,6 +63,10 @@ func (c *clientImpl) connect(ctx context.Context) (*HandshakeInfo, error) { if err != nil { return nil, err } + // Wrap in Salamander implementation + if c.config.ObfuscationConfig.Obfuscation == "salamander" { + pktConn = NewSalamanderPacketConn(pktConn, c.config.ObfuscationConfig.ObfuscationKey) + } // Convert config to TLS config & QUIC config tlsConfig := &tls.Config{ ServerName: c.config.TLSConfig.ServerName, diff --git a/protocol/hysteria2/client/config.go b/protocol/hysteria2/client/config.go index 961bc05..71eed1f 100644 --- a/protocol/hysteria2/client/config.go +++ b/protocol/hysteria2/client/config.go @@ -18,14 +18,15 @@ const ( ) type Config struct { - ConnFactory ConnFactory - ServerAddr net.Addr - Auth string - TLSConfig TLSConfig - QUICConfig QUICConfig - BandwidthConfig BandwidthConfig - UDPHopInterval time.Duration - FastOpen bool + ConnFactory ConnFactory + ServerAddr net.Addr + Auth string + TLSConfig TLSConfig + QUICConfig QUICConfig + BandwidthConfig BandwidthConfig + UDPHopInterval time.Duration + ObfuscationConfig ObfuscationConfig + FastOpen bool filled bool // whether the fields have been verified and filled } @@ -114,3 +115,8 @@ type BandwidthConfig struct { MaxTx uint64 MaxRx uint64 } + +type ObfuscationConfig struct { + Obfuscation string + ObfuscationKey []byte +} diff --git a/protocol/hysteria2/client/salamander.go b/protocol/hysteria2/client/salamander.go new file mode 100644 index 0000000..7c252c9 --- /dev/null +++ b/protocol/hysteria2/client/salamander.go @@ -0,0 +1,86 @@ +package client + +import ( + "crypto/rand" + "crypto/subtle" + "net" + "time" + + "golang.org/x/crypto/blake2b" +) + +type SalamanderPacketConn struct { + Connection net.PacketConn + Key []byte +} + +func NewSalamanderPacketConn(conn net.PacketConn, key []byte) SalamanderPacketConn { + return SalamanderPacketConn{ + Connection: conn, + Key: key, + } +} + +func (s SalamanderPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + packet := make([]byte, len(p)+8) + + n, addr, err = s.Connection.ReadFrom(packet) + + if err != nil { + return + } + + if n <= 8 { + return 0, addr, nil + } + + s.Process(p, packet[8:n], packet[:8]) + + n -= 8 + + return +} + +func (s SalamanderPacketConn) WriteTo(p []byte, addr net.Addr) (n int, err error) { + packet := make([]byte, len(p)+8) + + rand.Read(packet[:8]) + + s.Process(packet[8:], p, packet[:8]) + + n, err = s.Connection.WriteTo(packet, addr) + + if err == nil { + n -= 8 + } + + return +} + +func (s SalamanderPacketConn) Process(dst, src, salt []byte) { + hash := blake2b.Sum256(append(s.Key, salt...)) + + for i := 0; i < len(src); i += 32 { + subtle.XORBytes(dst[i:], src[i:], hash[:]) + } +} + +func (s SalamanderPacketConn) Close() error { + return s.Connection.Close() +} + +func (s SalamanderPacketConn) LocalAddr() net.Addr { + return s.Connection.LocalAddr() +} + +func (s SalamanderPacketConn) SetDeadline(t time.Time) error { + return s.Connection.SetDeadline(t) +} + +func (s SalamanderPacketConn) SetReadDeadline(t time.Time) error { + return s.Connection.SetReadDeadline(t) +} + +func (s SalamanderPacketConn) SetWriteDeadline(t time.Time) error { + return s.Connection.SetWriteDeadline(t) +} diff --git a/protocol/hysteria2/dialer.go b/protocol/hysteria2/dialer.go index 282efc9..2ba1e7a 100644 --- a/protocol/hysteria2/dialer.go +++ b/protocol/hysteria2/dialer.go @@ -24,8 +24,9 @@ type Dialer struct { } type Feature1 struct { - BandwidthConfig client.BandwidthConfig - UDPHopInterval time.Duration + BandwidthConfig client.BandwidthConfig + UDPHopInterval time.Duration + ObfuscationConfig client.ObfuscationConfig } func NewDialer(nextDialer netproxy.Dialer, header protocol.Header) (netproxy.Dialer, error) { @@ -54,6 +55,7 @@ func NewDialer(nextDialer netproxy.Dialer, header protocol.Header) (netproxy.Dia if feature := header.Feature1; feature != nil { config.BandwidthConfig = feature.(*Feature1).BandwidthConfig config.UDPHopInterval = feature.(*Feature1).UDPHopInterval + config.ObfuscationConfig = feature.(*Feature1).ObfuscationConfig } var err error From cbaac8f24d8375228e0e7c20f8865d90a29fb4a3 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 30 Jan 2026 09:13:27 +0800 Subject: [PATCH 2/4] feat: obfuscation mode validation Prevents unknown obfuscation values by validating the mode and returning an error for unsupported options, improving configuration safety and error transparency. --- protocol/hysteria2/client/client.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/protocol/hysteria2/client/client.go b/protocol/hysteria2/client/client.go index 693c3ab..25340d3 100644 --- a/protocol/hysteria2/client/client.go +++ b/protocol/hysteria2/client/client.go @@ -63,10 +63,15 @@ func (c *clientImpl) connect(ctx context.Context) (*HandshakeInfo, error) { if err != nil { return nil, err } - // Wrap in Salamander implementation - if c.config.ObfuscationConfig.Obfuscation == "salamander" { + + switch c.config.ObfuscationConfig.Obfuscation { + case "salamander": pktConn = NewSalamanderPacketConn(pktConn, c.config.ObfuscationConfig.ObfuscationKey) + case "": + default: + return nil, errors.New("unknown obfuscation: " + c.config.ObfuscationConfig.Obfuscation) } + // Convert config to TLS config & QUIC config tlsConfig := &tls.Config{ ServerName: c.config.TLSConfig.ServerName, From 306c368b70072955abbadd70c48e4ae0040d4c79 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 30 Jan 2026 15:55:08 +0800 Subject: [PATCH 3/4] fix: `plain` as obfuscation method alias --- protocol/hysteria2/client/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protocol/hysteria2/client/client.go b/protocol/hysteria2/client/client.go index 25340d3..98e16c7 100644 --- a/protocol/hysteria2/client/client.go +++ b/protocol/hysteria2/client/client.go @@ -67,7 +67,7 @@ func (c *clientImpl) connect(ctx context.Context) (*HandshakeInfo, error) { switch c.config.ObfuscationConfig.Obfuscation { case "salamander": pktConn = NewSalamanderPacketConn(pktConn, c.config.ObfuscationConfig.ObfuscationKey) - case "": + case "", "plain": default: return nil, errors.New("unknown obfuscation: " + c.config.ObfuscationConfig.Obfuscation) } From e43a84ca1343a49390fe1da3e2ed64dce4b3d078 Mon Sep 17 00:00:00 2001 From: FN Date: Mon, 16 Feb 2026 18:13:30 +0700 Subject: [PATCH 4/4] style: remove obsolete todo comment --- dialer/hysteria2/hysteria2.go | 1 - 1 file changed, 1 deletion(-) diff --git a/dialer/hysteria2/hysteria2.go b/dialer/hysteria2/hysteria2.go index 51a1892..22b48b2 100644 --- a/dialer/hysteria2/hysteria2.go +++ b/dialer/hysteria2/hysteria2.go @@ -128,7 +128,6 @@ func normalizeCertHash(hash string) string { // ref: https://v2.hysteria.network/zh/docs/developers/URI-Scheme/ func ParseHysteria2URL(link string) (*Hysteria2, error) { - // TODO: support salamander obfuscation u, err := url.Parse(link) if err != nil { return nil, err