diff --git a/app/client.example.yaml b/app/client.example.yaml index df90b4b..ec6a6a8 100644 --- a/app/client.example.yaml +++ b/app/client.example.yaml @@ -1,5 +1,10 @@ server: example.com +# obfs: +# type: salamander +# salamander: +# password: some_password + auth: some_password # tls: diff --git a/app/cmd/client.go b/app/cmd/client.go index 6a61ea3..f1243f1 100644 --- a/app/cmd/client.go +++ b/app/cmd/client.go @@ -17,6 +17,7 @@ import ( "github.com/apernet/hysteria/app/internal/http" "github.com/apernet/hysteria/app/internal/socks5" "github.com/apernet/hysteria/core/client" + "github.com/apernet/hysteria/extras/obfs" ) var clientCmd = &cobra.Command{ @@ -32,7 +33,13 @@ func init() { type clientConfig struct { Server string `mapstructure:"server"` Auth string `mapstructure:"auth"` - TLS struct { + Obfs struct { + Type string `mapstructure:"type"` + Salamander struct { + Password string `mapstructure:"password"` + } `mapstructure:"salamander"` + } `mapstructure:"obfs"` + TLS struct { SNI string `mapstructure:"sni"` Insecure bool `mapstructure:"insecure"` CA string `mapstructure:"ca"` @@ -80,6 +87,24 @@ type forwardingEntry struct { // Config validates the fields and returns a ready-to-use Hysteria client config func (c *clientConfig) Config() (*client.Config, error) { hyConfig := &client.Config{} + // ConnFactory + switch strings.ToLower(c.Obfs.Type) { + case "", "plain": + // Default, do nothing + case "salamander": + ob, err := obfs.NewSalamanderObfuscator([]byte(c.Obfs.Salamander.Password)) + if err != nil { + return nil, configError{Field: "obfs.salamander.password", Err: err} + } + hyConfig.ConnFactory = &obfsConnFactory{ + NewFunc: func(addr net.Addr) (net.PacketConn, error) { + return net.ListenUDP("udp", nil) + }, + Obfuscator: ob, + } + default: + return nil, configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")} + } // ServerAddr if c.Server == "" { return nil, configError{Field: "server", Err: errors.New("server address is empty")} @@ -311,6 +336,20 @@ func parseServerAddrString(addrStr string) (host, hostPort string) { return h, addrStr } +// obfsConnFactory adds obfuscation to a function that creates net.PacketConn. +type obfsConnFactory struct { + NewFunc func(addr net.Addr) (net.PacketConn, error) + Obfuscator obfs.Obfuscator +} + +func (f *obfsConnFactory) New(addr net.Addr) (net.PacketConn, error) { + conn, err := f.NewFunc(addr) + if err != nil { + return nil, err + } + return obfs.WrapPacketConn(conn, f.Obfuscator), nil +} + type socks5Logger struct{} func (l *socks5Logger) TCPRequest(addr net.Addr, reqAddr string) { diff --git a/app/cmd/client_test.go b/app/cmd/client_test.go index 795dfa7..5e74b06 100644 --- a/app/cmd/client_test.go +++ b/app/cmd/client_test.go @@ -22,6 +22,19 @@ func TestClientConfig(t *testing.T) { if !reflect.DeepEqual(config, clientConfig{ Server: "example.com", Auth: "weak_ahh_password", + Obfs: struct { + Type string `mapstructure:"type"` + Salamander struct { + Password string `mapstructure:"password"` + } `mapstructure:"salamander"` + }{ + Type: "salamander", + Salamander: struct { + Password string `mapstructure:"password"` + }{ + Password: "cry_me_a_r1ver", + }, + }, TLS: struct { SNI string `mapstructure:"sni"` Insecure bool `mapstructure:"insecure"` diff --git a/app/cmd/client_test.yaml b/app/cmd/client_test.yaml index ea5aa9e..9f576a7 100644 --- a/app/cmd/client_test.yaml +++ b/app/cmd/client_test.yaml @@ -2,6 +2,11 @@ server: example.com auth: weak_ahh_password +obfs: + type: salamander + salamander: + password: cry_me_a_r1ver + tls: sni: another.example.com insecure: true diff --git a/app/cmd/server.go b/app/cmd/server.go index 04a3298..efe981c 100644 --- a/app/cmd/server.go +++ b/app/cmd/server.go @@ -11,13 +11,14 @@ import ( "strings" "time" - "github.com/apernet/hysteria/core/server" - "github.com/apernet/hysteria/extras/auth" - "github.com/caddyserver/certmagic" "github.com/spf13/cobra" "github.com/spf13/viper" "go.uber.org/zap" + + "github.com/apernet/hysteria/core/server" + "github.com/apernet/hysteria/extras/auth" + "github.com/apernet/hysteria/extras/obfs" ) var serverCmd = &cobra.Command{ @@ -31,10 +32,16 @@ func init() { } type serverConfig struct { - Listen string `mapstructure:"listen"` - TLS *serverConfigTLS `mapstructure:"tls"` - ACME *serverConfigACME `mapstructure:"acme"` - QUIC struct { + Listen string `mapstructure:"listen"` + Obfs struct { + Type string `mapstructure:"type"` + Salamander struct { + Password string `mapstructure:"password"` + } `mapstructure:"salamander"` + } `mapstructure:"obfs"` + TLS *serverConfigTLS `mapstructure:"tls"` + ACME *serverConfigACME `mapstructure:"acme"` + QUIC struct { InitStreamReceiveWindow uint64 `mapstructure:"initStreamReceiveWindow"` MaxStreamReceiveWindow uint64 `mapstructure:"maxStreamReceiveWindow"` InitConnectionReceiveWindow uint64 `mapstructure:"initConnReceiveWindow"` @@ -92,10 +99,22 @@ func (c *serverConfig) Config() (*server.Config, error) { if err != nil { return nil, configError{Field: "listen", Err: err} } - hyConfig.Conn, err = net.ListenUDP("udp", uAddr) + conn, err := net.ListenUDP("udp", uAddr) if err != nil { return nil, configError{Field: "listen", Err: err} } + switch strings.ToLower(c.Obfs.Type) { + case "", "plain": + hyConfig.Conn = conn + case "salamander": + ob, err := obfs.NewSalamanderObfuscator([]byte(c.Obfs.Salamander.Password)) + if err != nil { + return nil, configError{Field: "obfs.salamander.password", Err: err} + } + hyConfig.Conn = obfs.WrapPacketConn(conn, ob) + default: + return nil, configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")} + } // TLSConfig if c.TLS == nil && c.ACME == nil { return nil, configError{Field: "tls", Err: errors.New("must set either tls or acme")} diff --git a/app/cmd/server_test.go b/app/cmd/server_test.go index e2d13b7..f7f1791 100644 --- a/app/cmd/server_test.go +++ b/app/cmd/server_test.go @@ -21,6 +21,19 @@ func TestServerConfig(t *testing.T) { } if !reflect.DeepEqual(config, serverConfig{ Listen: ":8443", + Obfs: struct { + Type string `mapstructure:"type"` + Salamander struct { + Password string `mapstructure:"password"` + } `mapstructure:"salamander"` + }{ + Type: "salamander", + Salamander: struct { + Password string `mapstructure:"password"` + }{ + Password: "cry_me_a_r1ver", + }, + }, TLS: &serverConfigTLS{ Cert: "some.crt", Key: "some.key", diff --git a/app/cmd/server_test.yaml b/app/cmd/server_test.yaml index b4ecc26..74eea8f 100644 --- a/app/cmd/server_test.yaml +++ b/app/cmd/server_test.yaml @@ -1,5 +1,10 @@ listen: :8443 +obfs: + type: salamander + salamander: + password: cry_me_a_r1ver + tls: cert: some.crt key: some.key diff --git a/app/internal/http/server.go b/app/internal/http/server.go index 3919a69..ba56312 100644 --- a/app/internal/http/server.go +++ b/app/internal/http/server.go @@ -159,6 +159,7 @@ func (s *Server) handleConnect(conn net.Conn, req *http.Request) { rConn, err := s.HyClient.DialTCP(reqAddr) if err != nil { _ = sendSimpleResponse(conn, req, http.StatusBadGateway) + closeErr = err return } defer rConn.Close() diff --git a/app/server.example.yaml b/app/server.example.yaml index 788ea93..0faba3a 100644 --- a/app/server.example.yaml +++ b/app/server.example.yaml @@ -1,5 +1,10 @@ listen: :443 +# obfs: +# type: salamander +# salamander: +# password: some_password + # tls: # cert: my.crt # key: my.key diff --git a/extras/obfs/conn.go b/extras/obfs/conn.go new file mode 100644 index 0000000..d4fd6d6 --- /dev/null +++ b/extras/obfs/conn.go @@ -0,0 +1,121 @@ +package obfs + +import ( + "net" + "sync" + "syscall" + "time" +) + +const udpBufSize = 2048 // QUIC packets are at most 1500 bytes long, so 2k should be more than enough + +// Obfuscator is the interface that wraps the Obfuscate and Deobfuscate methods. +// Both methods return the number of bytes written to out. +// If a packet is not valid, the methods should return 0. +type Obfuscator interface { + Obfuscate(in, out []byte) int + Deobfuscate(in, out []byte) int +} + +var _ net.PacketConn = (*obfsPacketConn)(nil) + +type obfsPacketConn struct { + Conn net.PacketConn + Obfs Obfuscator + + readBuf []byte + readMutex sync.Mutex + writeBuf []byte + writeMutex sync.Mutex +} + +// obfsPacketConnUDP is a special case of obfsPacketConn that uses a UDPConn +// as the underlying connection. We pass additional methods to quic-go to +// enable UDP-specific optimizations. +type obfsPacketConnUDP struct { + *obfsPacketConn + UDPConn *net.UDPConn +} + +// WrapPacketConn enables obfuscation on a net.PacketConn. +// The obfuscation is transparent to the caller - the n bytes returned by +// ReadFrom and WriteTo are the number of original bytes, not after +// obfuscation/deobfuscation. +func WrapPacketConn(conn net.PacketConn, obfs Obfuscator) net.PacketConn { + opc := &obfsPacketConn{ + Conn: conn, + Obfs: obfs, + readBuf: make([]byte, udpBufSize), + writeBuf: make([]byte, udpBufSize), + } + if udpConn, ok := conn.(*net.UDPConn); ok { + return &obfsPacketConnUDP{ + obfsPacketConn: opc, + UDPConn: udpConn, + } + } else { + return opc + } +} + +func (c *obfsPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) { + for { + c.readMutex.Lock() + n, addr, err = c.Conn.ReadFrom(c.readBuf) + if n <= 0 { + c.readMutex.Unlock() + return + } + n = c.Obfs.Deobfuscate(c.readBuf[:n], p) + c.readMutex.Unlock() + if n > 0 || err != nil { + return + } + // Invalid packet, try again + } +} + +func (c *obfsPacketConn) WriteTo(p []byte, addr net.Addr) (n int, err error) { + c.writeMutex.Lock() + nn := c.Obfs.Obfuscate(p, c.writeBuf) + _, err = c.Conn.WriteTo(c.writeBuf[:nn], addr) + c.writeMutex.Unlock() + if err == nil { + n = len(p) + } + return +} + +func (c *obfsPacketConn) Close() error { + return c.Conn.Close() +} + +func (c *obfsPacketConn) LocalAddr() net.Addr { + return c.Conn.LocalAddr() +} + +func (c *obfsPacketConn) SetDeadline(t time.Time) error { + return c.Conn.SetDeadline(t) +} + +func (c *obfsPacketConn) SetReadDeadline(t time.Time) error { + return c.Conn.SetReadDeadline(t) +} + +func (c *obfsPacketConn) SetWriteDeadline(t time.Time) error { + return c.Conn.SetWriteDeadline(t) +} + +// UDP-specific methods below + +func (c *obfsPacketConnUDP) SetReadBuffer(bytes int) error { + return c.UDPConn.SetReadBuffer(bytes) +} + +func (c *obfsPacketConnUDP) SetWriteBuffer(bytes int) error { + return c.UDPConn.SetWriteBuffer(bytes) +} + +func (c *obfsPacketConnUDP) SyscallConn() (syscall.RawConn, error) { + return c.UDPConn.SyscallConn() +} diff --git a/extras/obfs/salamander.go b/extras/obfs/salamander.go new file mode 100644 index 0000000..50a3ce2 --- /dev/null +++ b/extras/obfs/salamander.go @@ -0,0 +1,71 @@ +package obfs + +import ( + "fmt" + "math/rand" + "sync" + "time" + + "golang.org/x/crypto/blake2b" +) + +const ( + smPSKMinLen = 4 + smSaltLen = 8 + smKeyLen = blake2b.Size256 +) + +var _ Obfuscator = (*SalamanderObfuscator)(nil) + +var ErrPSKTooShort = fmt.Errorf("PSK must be at least %d bytes", smPSKMinLen) + +// SalamanderObfuscator is an obfuscator that obfuscates each packet with +// the BLAKE2b-256 hash of a pre-shared key combined with a random salt. +// Packet format: [8-byte salt][payload] +type SalamanderObfuscator struct { + PSK []byte + RandSrc *rand.Rand + + lk sync.Mutex +} + +func NewSalamanderObfuscator(psk []byte) (*SalamanderObfuscator, error) { + if len(psk) < smPSKMinLen { + return nil, ErrPSKTooShort + } + return &SalamanderObfuscator{ + PSK: psk, + RandSrc: rand.New(rand.NewSource(time.Now().UnixNano())), + }, nil +} + +func (o *SalamanderObfuscator) Obfuscate(in, out []byte) int { + outLen := len(in) + smSaltLen + if len(out) < outLen { + return 0 + } + o.lk.Lock() + _, _ = o.RandSrc.Read(out[:smSaltLen]) + o.lk.Unlock() + key := o.key(out[:smSaltLen]) + for i, c := range in { + out[i+smSaltLen] = c ^ key[i%smKeyLen] + } + return outLen +} + +func (o *SalamanderObfuscator) Deobfuscate(in, out []byte) int { + outLen := len(in) - smSaltLen + if outLen <= 0 || len(out) < outLen { + return 0 + } + key := o.key(in[:smSaltLen]) + for i, c := range in[smSaltLen:] { + out[i] = c ^ key[i%smKeyLen] + } + return outLen +} + +func (o *SalamanderObfuscator) key(salt []byte) [smKeyLen]byte { + return blake2b.Sum256(append(o.PSK, salt...)) +} diff --git a/extras/obfs/salamander_test.go b/extras/obfs/salamander_test.go new file mode 100644 index 0000000..b11024e --- /dev/null +++ b/extras/obfs/salamander_test.go @@ -0,0 +1,56 @@ +package obfs + +import ( + "bytes" + "crypto/rand" + "testing" +) + +func BenchmarkSalamanderObfuscator_Obfuscate(b *testing.B) { + o, _ := NewSalamanderObfuscator([]byte("average_password")) + in := make([]byte, 1200) + _, _ = rand.Read(in) + out := make([]byte, 2048) + b.ResetTimer() + for i := 0; i < b.N; i++ { + o.Obfuscate(in, out) + } +} + +func BenchmarkSalamanderObfuscator_Deobfuscate(b *testing.B) { + o, _ := NewSalamanderObfuscator([]byte("average_password")) + in := make([]byte, 1200) + _, _ = rand.Read(in) + out := make([]byte, 2048) + b.ResetTimer() + for i := 0; i < b.N; i++ { + o.Deobfuscate(in, out) + } +} + +func TestSalamanderObfuscator(t *testing.T) { + o, _ := NewSalamanderObfuscator([]byte("average_password")) + in := make([]byte, 1200) + oOut := make([]byte, 2048) + dOut := make([]byte, 2048) + for i := 0; i < 1000; i++ { + _, _ = rand.Read(in) + n := o.Obfuscate(in, oOut) + if n == 0 { + t.Fatal("Failed to obfuscate") + } + if n != len(in)+smSaltLen { + t.Fatal("Wrong obfuscated length") + } + n = o.Deobfuscate(oOut[:n], dOut) + if n == 0 { + t.Fatal("Failed to deobfuscate") + } + if n != len(in) { + t.Fatal("Wrong deobfuscated length") + } + if !bytes.Equal(in, dOut[:n]) { + t.Fatal("Deobfuscated data mismatch") + } + } +}