diff --git a/app/cmd/client.go b/app/cmd/client.go index a6ffc17..ba50d73 100644 --- a/app/cmd/client.go +++ b/app/cmd/client.go @@ -113,9 +113,16 @@ type clientConfigObfsSalamander struct { Password string `mapstructure:"password"` } +type clientConfigObfsGecko struct { + Password string `mapstructure:"password"` + MinPacketSize int `mapstructure:"minPacketSize"` + MaxPacketSize int `mapstructure:"maxPacketSize"` +} + type clientConfigObfs struct { Type string `mapstructure:"type"` Salamander clientConfigObfsSalamander `mapstructure:"salamander"` + Gecko clientConfigObfsGecko `mapstructure:"gecko"` } type clientConfigTLS struct { @@ -233,42 +240,46 @@ func (c *clientConfig) fillServerAddr(hyConfig *client.Config) error { return nil } -// fillConnFactory must be called after fillServerAddr, as we have different logic -// for ConnFactory depending on whether we have a port hopping address. +// fillConnFactory must be called after fillServerAddr, since the right kind +// of inner conn (plain vs port-hopping) depends on the resolved server addr. func (c *clientConfig) fillConnFactory(hyConfig *client.Config) error { so, err := c.socketOptions() if err != nil { return err } - // Inner PacketConn - var newFunc func(addr net.Addr) (net.PacketConn, error) hopInterval, err := c.Transport.UDP.hopIntervalConfig() if err != nil { return configError{Field: "transport.udp", Err: err} } + var openInner func() (net.PacketConn, error) switch strings.ToLower(c.Transport.Type) { case "", "udp": if hyConfig.ServerAddr.Network() == "udphop" { hopAddr := hyConfig.ServerAddr.(*udphop.UDPHopAddr) - newFunc = func(addr net.Addr) (net.PacketConn, error) { + openInner = func() (net.PacketConn, error) { return udphop.NewUDPHopPacketConn(hopAddr, hopInterval, so.ListenUDP) } } else { - newFunc = func(addr net.Addr) (net.PacketConn, error) { + openInner = func() (net.PacketConn, error) { return so.ListenUDP() } } default: return configError{Field: "transport.type", Err: errors.New("unsupported transport type")} } - // Obfuscation - ob, err := c.obfuscator() - if err != nil { - return err - } - hyConfig.ConnFactory = &adaptiveConnFactory{ - NewFunc: newFunc, - Obfuscator: ob, + hyConfig.ConnFactory = &singleUseConnFactory{ + Open: func() (net.PacketConn, error) { + conn, err := openInner() + if err != nil { + return nil, err + } + wrapped, err := c.wrapObfs(conn) + if err != nil { + _ = conn.Close() + return nil, err + } + return wrapped, nil + }, } return nil } @@ -292,16 +303,26 @@ func (c *clientConfig) socketOptions() (*sockopts.SocketOptions, error) { return so, nil } -func (c *clientConfig) obfuscator() (obfs.Obfuscator, error) { +func (c *clientConfig) wrapObfs(conn net.PacketConn) (net.PacketConn, error) { switch strings.ToLower(c.Obfs.Type) { case "", "plain": - return nil, nil + return conn, nil case "salamander": - ob, err := obfs.NewSalamanderObfuscator([]byte(c.Obfs.Salamander.Password)) + wrapped, err := obfs.WrapPacketConnSalamander(conn, []byte(c.Obfs.Salamander.Password)) if err != nil { return nil, configError{Field: "obfs.salamander.password", Err: err} } - return ob, nil + return wrapped, nil + case "gecko": + wrapped, err := obfs.WrapPacketConnGecko(conn, obfs.GeckoOptions{ + Password: []byte(c.Obfs.Gecko.Password), + MinPacketSize: c.Obfs.Gecko.MinPacketSize, + MaxPacketSize: c.Obfs.Gecko.MaxPacketSize, + }) + if err != nil { + return nil, configError{Field: "obfs.gecko", Err: err} + } + return wrapped, nil default: return nil, configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")} } @@ -456,6 +477,9 @@ func (c *clientConfig) URI() string { case "salamander": q.Set("obfs", "salamander") q.Set("obfs-password", c.Obfs.Salamander.Password) + case "gecko": + q.Set("obfs", "gecko") + q.Set("obfs-password", c.Obfs.Gecko.Password) } if c.TLS.SNI != "" { q.Set("sni", c.TLS.SNI) @@ -513,6 +537,8 @@ func (c *clientConfig) parseURI() bool { switch strings.ToLower(obfsType) { case "salamander": c.Obfs.Salamander.Password = q.Get("obfs-password") + case "gecko": + c.Obfs.Gecko.Password = q.Get("obfs-password") } } if sni := q.Get("sni"); sni != "" { @@ -680,15 +706,13 @@ func (c *clientConfig) realmConfig(addr *realm.Addr) (*client.Config, error) { zap.String("realm", addr.RealmID), zap.String("peer", result.PeerAddr.String())) - var finalConn net.PacketConn = baseConn - ob, err := c.obfuscator() + finalConn, err := c.wrapObfs(baseConn) if err != nil { return nil, err } - if ob != nil { - finalConn = obfs.WrapPacketConn(baseConn, ob) + hyConfig.ConnFactory = &singleUseConnFactory{ + Open: func() (net.PacketConn, error) { return finalConn, nil }, } - hyConfig.ConnFactory = &singleUseConnFactory{Conn: finalConn} success = true return hyConfig, nil } @@ -1135,25 +1159,14 @@ func normalizeCertHash(hash string) string { return r } -type adaptiveConnFactory struct { - NewFunc func(addr net.Addr) (net.PacketConn, error) - Obfuscator obfs.Obfuscator // nil if no obfuscation -} - -func (f *adaptiveConnFactory) New(addr net.Addr) (net.PacketConn, error) { - if f.Obfuscator == nil { - return f.NewFunc(addr) - } else { - conn, err := f.NewFunc(addr) - if err != nil { - return nil, err - } - return obfs.WrapPacketConn(conn, f.Obfuscator), nil - } -} - +// singleUseConnFactory invokes Open exactly once to produce the underlying +// conn. It defers any allocation to the moment core/client.connect() actually +// asks for the conn, so config-validation failures inside NewClient (e.g. +// verifyAndFill rejecting a value) don't leak a socket. For the realm path +// the conn is already open by the time we reach the factory, so Open just +// returns it; for the regular path Open opens the UDP socket fresh. type singleUseConnFactory struct { - Conn net.PacketConn + Open func() (net.PacketConn, error) mu sync.Mutex used bool @@ -1162,11 +1175,11 @@ type singleUseConnFactory struct { func (f *singleUseConnFactory) New(net.Addr) (net.PacketConn, error) { f.mu.Lock() defer f.mu.Unlock() - if f.used || f.Conn == nil { - return nil, errors.New("realm connection already used") + if f.used { + return nil, errors.New("connection factory already used") } f.used = true - return f.Conn, nil + return f.Open() } func addrPortStrings(addrs []netip.AddrPort) []string { diff --git a/app/cmd/client_test.go b/app/cmd/client_test.go index 3ec040a..00215b3 100644 --- a/app/cmd/client_test.go +++ b/app/cmd/client_test.go @@ -177,6 +177,20 @@ func TestClientConfigURI(t *testing.T) { }, }, }, + { + uri: "hysteria2://pw@geckotown.com:8443/?obfs=gecko&obfs-password=hidden", + uriOK: true, + config: &clientConfig{ + Server: "geckotown.com:8443", + Auth: "pw", + Obfs: clientConfigObfs{ + Type: "gecko", + Gecko: clientConfigObfsGecko{ + Password: "hidden", + }, + }, + }, + }, { uri: "invalid.bs", uriOK: false, @@ -238,7 +252,7 @@ func TestSingleUseConnFactory(t *testing.T) { assert.NoError(t, err) defer conn.Close() - f := &singleUseConnFactory{Conn: conn} + f := &singleUseConnFactory{Open: func() (net.PacketConn, error) { return conn, nil }} got, err := f.New(&net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443}) assert.NoError(t, err) assert.Equal(t, conn, got) diff --git a/app/cmd/server.go b/app/cmd/server.go index 0e8b9f0..8c9e8ea 100644 --- a/app/cmd/server.go +++ b/app/cmd/server.go @@ -97,9 +97,16 @@ type serverConfigObfsSalamander struct { Password string `mapstructure:"password"` } +type serverConfigObfsGecko struct { + Password string `mapstructure:"password"` + MinPacketSize int `mapstructure:"minPacketSize"` + MaxPacketSize int `mapstructure:"maxPacketSize"` +} + type serverConfigObfs struct { Type string `mapstructure:"type"` Salamander serverConfigObfsSalamander `mapstructure:"salamander"` + Gecko serverConfigObfsGecko `mapstructure:"gecko"` } type serverConfigTLS struct { @@ -312,30 +319,17 @@ func (c *serverConfig) fillConn(hyConfig *server.Config) error { return configError{Field: "listen", Err: err} } } - switch strings.ToLower(c.Obfs.Type) { - case "", "plain": - hyConfig.Conn = packetConn - hyConfig.Cleanup = cleanup - return nil - case "salamander": - ob, err := obfs.NewSalamanderObfuscator([]byte(c.Obfs.Salamander.Password)) - if err != nil { - _ = conn.Close() - if cleanup != nil { - _ = cleanup.Close() - } - return configError{Field: "obfs.salamander.password", Err: err} - } - hyConfig.Conn = obfs.WrapPacketConn(packetConn, ob) - hyConfig.Cleanup = cleanup - return nil - default: + wrapped, err := c.wrapObfs(packetConn) + if err != nil { _ = conn.Close() if cleanup != nil { _ = cleanup.Close() } - return configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")} + return err } + hyConfig.Conn = wrapped + hyConfig.Cleanup = cleanup + return nil } func parseServerRealmAddr(listen string) (*realm.Addr, bool, error) { @@ -371,8 +365,9 @@ func (c *serverConfig) fillRealmConn(hyConfig *server.Config, addr *realm.Addr) _ = conn.Close() return configError{Field: "realm", Err: err} } - packetConn, err := c.wrapServerPacketConn(punchConn, conn) + packetConn, err := c.wrapObfs(punchConn) if err != nil { + _ = conn.Close() return err } @@ -388,19 +383,27 @@ func (c *serverConfig) fillRealmConn(hyConfig *server.Config, addr *realm.Addr) return nil } -func (c *serverConfig) wrapServerPacketConn(packetConn net.PacketConn, udpConn *net.UDPConn) (net.PacketConn, error) { +func (c *serverConfig) wrapObfs(conn net.PacketConn) (net.PacketConn, error) { switch strings.ToLower(c.Obfs.Type) { case "", "plain": - return packetConn, nil + return conn, nil case "salamander": - ob, err := obfs.NewSalamanderObfuscator([]byte(c.Obfs.Salamander.Password)) + wrapped, err := obfs.WrapPacketConnSalamander(conn, []byte(c.Obfs.Salamander.Password)) if err != nil { - _ = udpConn.Close() return nil, configError{Field: "obfs.salamander.password", Err: err} } - return obfs.WrapPacketConn(packetConn, ob), nil + return wrapped, nil + case "gecko": + wrapped, err := obfs.WrapPacketConnGecko(conn, obfs.GeckoOptions{ + Password: []byte(c.Obfs.Gecko.Password), + MinPacketSize: c.Obfs.Gecko.MinPacketSize, + MaxPacketSize: c.Obfs.Gecko.MaxPacketSize, + }) + if err != nil { + return nil, configError{Field: "obfs.gecko", Err: err} + } + return wrapped, nil default: - _ = udpConn.Close() return nil, configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")} } } diff --git a/extras/obfs/conn.go b/extras/obfs/conn.go index 98d9a89..418dad6 100644 --- a/extras/obfs/conn.go +++ b/extras/obfs/conn.go @@ -9,10 +9,10 @@ import ( const udpBufferSize = 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. +// obfuscator wraps a per-packet, length-preserving cipher. +// Obfuscate / Deobfuscate return the number of bytes written to out. // If a packet is not valid, the methods should return 0. -type Obfuscator interface { +type obfuscator interface { Obfuscate(in, out []byte) int Deobfuscate(in, out []byte) int } @@ -21,7 +21,7 @@ var _ net.PacketConn = (*obfsPacketConn)(nil) type obfsPacketConn struct { Conn net.PacketConn - Obfs Obfuscator + Obfs obfuscator readBuf []byte readMutex sync.Mutex @@ -49,14 +49,14 @@ type obfsPacketConnUDP struct { UDPConn udpLikePacketConn } -// WrapPacketConn enables obfuscation on a net.PacketConn. +// wrapPacketConn enables per-packet 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 { +func wrapPacketConn(conn net.PacketConn, ob obfuscator) net.PacketConn { opc := &obfsPacketConn{ Conn: conn, - Obfs: obfs, + Obfs: ob, readBuf: make([]byte, udpBufferSize), writeBuf: make([]byte, udpBufferSize), } diff --git a/extras/obfs/conn_test.go b/extras/obfs/conn_test.go index c9f3f20..5b7fed3 100644 --- a/extras/obfs/conn_test.go +++ b/extras/obfs/conn_test.go @@ -46,19 +46,19 @@ func TestWrapPacketConnUsesUDPVariantForUDPConn(t *testing.T) { require.NoError(t, err) defer udp.Close() - wrapped := WrapPacketConn(udp, noopObfs{}) + wrapped := wrapPacketConn(udp, noopObfs{}) _, ok := wrapped.(*obfsPacketConnUDP) assert.True(t, ok, "wrapping a *net.UDPConn should return *obfsPacketConnUDP") } func TestWrapPacketConnUsesUDPVariantForUDPLikeWrapper(t *testing.T) { - wrapped := WrapPacketConn(fakeUDPLikeConn{}, noopObfs{}) + wrapped := wrapPacketConn(fakeUDPLikeConn{}, noopObfs{}) _, ok := wrapped.(*obfsPacketConnUDP) assert.True(t, ok, "wrapping a udpLikePacketConn should return *obfsPacketConnUDP") } func TestWrapPacketConnFallsBackForPlainPacketConn(t *testing.T) { - wrapped := WrapPacketConn(fakePlainConn{}, noopObfs{}) + wrapped := wrapPacketConn(fakePlainConn{}, noopObfs{}) _, isUDP := wrapped.(*obfsPacketConnUDP) assert.False(t, isUDP, "wrapping a plain net.PacketConn should not return *obfsPacketConnUDP") } diff --git a/extras/obfs/gecko.go b/extras/obfs/gecko.go new file mode 100644 index 0000000..f40a12d --- /dev/null +++ b/extras/obfs/gecko.go @@ -0,0 +1,341 @@ +package obfs + +import ( + "crypto/rand" + "encoding/binary" + "errors" + "net" + "sync" + "sync/atomic" + "syscall" + "time" +) + +// Gecko adds shape obfuscation on top of Salamander: QUIC long-header +// (handshake) packets are fragmented into randomly-sized, randomly-padded +// chunks; short-header packets pass through untouched. +const ( + geckoReassemblyTTL = 8 * time.Second + geckoMaxReassembly = 4096 + geckoMaxPerSource = 8 + + geckoBufferSize = 2048 + + geckoDefaultMinPacket = 512 + geckoDefaultMaxPacket = 1200 +) + +type GeckoOptions struct { + Password []byte + MinPacketSize int + MaxPacketSize int +} + +func WrapPacketConnGecko(conn net.PacketConn, opts GeckoOptions) (net.PacketConn, error) { + if len(opts.Password) == 0 { + return nil, errors.New("gecko: password is required") + } + minPkt, maxPkt := opts.MinPacketSize, opts.MaxPacketSize + if minPkt == 0 { + minPkt = geckoDefaultMinPacket + } + if maxPkt == 0 { + maxPkt = geckoDefaultMaxPacket + } + if minPkt <= 0 || minPkt > maxPkt || maxPkt > geckoBufferSize { + return nil, errors.New("gecko: invalid min/max packet size") + } + inner, err := WrapPacketConnSalamander(conn, opts.Password) + if err != nil { + return nil, err + } + return newGeckoPacketConn(inner, minPkt, maxPkt), nil +} + +type reassemblyKey struct { + addr string + msgID uint8 +} + +type reassemblyEntry struct { + chunks [][]byte + received int + total uint8 + deadline time.Time +} + +type geckoPacketConn struct { + inner net.PacketConn + minPkt, maxPkt int + + msgID atomic.Uint32 + + readMu sync.Mutex + readBuf []byte + + mu sync.Mutex + reassembly map[reassemblyKey]*reassemblyEntry + perSource map[string]int + + closeCh chan struct{} + closeOnce sync.Once +} + +func newGeckoPacketConn(inner net.PacketConn, minPkt, maxPkt int) *geckoPacketConn { + g := &geckoPacketConn{ + inner: inner, + minPkt: minPkt, + maxPkt: maxPkt, + readBuf: make([]byte, geckoBufferSize), + reassembly: make(map[reassemblyKey]*reassemblyEntry), + perSource: make(map[string]int), + closeCh: make(chan struct{}), + } + go g.gcLoop() + return g +} + +// --- Send path --- + +func (g *geckoPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { + if len(p) == 0 { + return 0, nil + } + if p[0]&0x80 != 0 { + // QUIC long header, do fragmentation. + return g.writeFragmented(p, addr) + } + // QUIC short header (data), pass through. + return g.inner.WriteTo(p, addr) +} + +func (g *geckoPacketConn) writeFragmented(p []byte, addr net.Addr) (int, error) { + chunks := randomFragmentChunks() + chunkSize := len(p) / chunks + msgID := uint8(g.msgID.Add(1)) + for i := range chunks { + start := i * chunkSize + end := len(p) + if i < chunks-1 { + end = start + chunkSize + } + chunk := p[start:end] + padLen := g.randomPadLen(len(chunk)) + buf := make([]byte, geckoHeaderSize+int(padLen)+len(chunk)) + n, err := encodeFrame(frameHeader{ + padLen: padLen, + msgID: msgID, + chunkIdx: uint8(i), + totalChunks: uint8(chunks), + }, chunk, buf) + if err != nil { + return 0, err + } + if _, err := g.inner.WriteTo(buf[:n], addr); err != nil { + return 0, err + } + } + return len(p), nil +} + +// randomPadLen picks padding so the final UDP datagram (Salamander salt + +// header + padding + chunk) falls within [minPkt, maxPkt]. If the chunk alone +// already exceeds maxPkt, no padding is added. +func (g *geckoPacketConn) randomPadLen(chunkLen int) uint16 { + base := smSaltLen + geckoHeaderSize + chunkLen + lo := max(g.minPkt, base) + if lo > g.maxPkt { + return 0 + } + return uint16(lo - base + randIntn(g.maxPkt-lo+1)) +} + +func randomFragmentChunks() int { + return geckoMinFragmentChunks + randIntn(geckoMaxFragmentChunks-geckoMinFragmentChunks+1) +} + +// randIntn returns a uniform random int in [0, n). +func randIntn(n int) int { + if n <= 1 { + return 0 + } + var b [4]byte + _, _ = rand.Read(b[:]) + return int(binary.BigEndian.Uint32(b[:]) % uint32(n)) +} + +// --- Receive path --- + +func (g *geckoPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { + g.readMu.Lock() + defer g.readMu.Unlock() + buf := g.readBuf + for { + n, addr, err := g.inner.ReadFrom(buf) + if err != nil { + return 0, addr, err + } + if n <= 0 { + continue + } + // Top bit set → Gecko fragment frame; clear → short-header packet + // or garbage, passed through for QUIC to handle. + if buf[0]&0x80 == 0 { + return copy(p, buf[:n]), addr, nil + } + h, payload, decErr := decodeFrame(buf[:n]) + if decErr != nil { + // Malformed frame; drop silently. + continue + } + out, ready := g.acceptChunk(addr, h, payload) + if !ready { + continue + } + return copy(p, out), addr, nil + } +} + +func (g *geckoPacketConn) acceptChunk(addr net.Addr, h frameHeader, payload []byte) ([]byte, bool) { + key := reassemblyKey{addr: addr.String(), msgID: h.msgID} + + g.mu.Lock() + defer g.mu.Unlock() + + e, exists := g.reassembly[key] + if !exists { + // Per-source cap. + if g.perSource[key.addr] >= geckoMaxPerSource { + return nil, false + } + // Global cap with eviction. + if len(g.reassembly) >= geckoMaxReassembly { + g.evictOldestLocked() + } + e = &reassemblyEntry{ + chunks: make([][]byte, h.totalChunks), + total: h.totalChunks, + deadline: time.Now().Add(geckoReassemblyTTL), + } + g.reassembly[key] = e + g.perSource[key.addr]++ + } else if e.total != h.totalChunks { + // Inconsistent chunk count; drop. + return nil, false + } + if int(h.chunkIdx) >= len(e.chunks) || e.chunks[h.chunkIdx] != nil { + // Bad index or duplicate; drop. + return nil, false + } + cp := make([]byte, len(payload)) + copy(cp, payload) + e.chunks[h.chunkIdx] = cp + e.received++ + if e.received < int(e.total) { + return nil, false + } + + total := 0 + for _, c := range e.chunks { + total += len(c) + } + out := make([]byte, total) + off := 0 + for _, c := range e.chunks { + off += copy(out[off:], c) + } + g.dropEntryLocked(key) + return out, true +} + +// --- Maintenance --- + +func (g *geckoPacketConn) gcLoop() { + t := time.NewTicker(geckoReassemblyTTL / 2) + defer t.Stop() + for { + select { + case <-g.closeCh: + return + case now := <-t.C: + g.gcExpired(now) + } + } +} + +func (g *geckoPacketConn) gcExpired(now time.Time) { + g.mu.Lock() + defer g.mu.Unlock() + for k, e := range g.reassembly { + if now.After(e.deadline) { + g.dropEntryLocked(k) + } + } +} + +// dropEntryLocked must be called with mu held. +func (g *geckoPacketConn) dropEntryLocked(k reassemblyKey) { + if _, ok := g.reassembly[k]; !ok { + return + } + delete(g.reassembly, k) + g.perSource[k.addr]-- + if g.perSource[k.addr] <= 0 { + delete(g.perSource, k.addr) + } +} + +// evictOldestLocked must be called with mu held. O(n) over the map; n is +// bounded by geckoMaxReassembly. +func (g *geckoPacketConn) evictOldestLocked() { + var oldestKey reassemblyKey + var oldestDeadline time.Time + first := true + for k, e := range g.reassembly { + if first || e.deadline.Before(oldestDeadline) { + oldestKey = k + oldestDeadline = e.deadline + first = false + } + } + if !first { + g.dropEntryLocked(oldestKey) + } +} + +// --- net.PacketConn boilerplate --- + +func (g *geckoPacketConn) Close() error { + g.closeOnce.Do(func() { close(g.closeCh) }) + return g.inner.Close() +} + +func (g *geckoPacketConn) LocalAddr() net.Addr { return g.inner.LocalAddr() } +func (g *geckoPacketConn) SetDeadline(t time.Time) error { return g.inner.SetDeadline(t) } +func (g *geckoPacketConn) SetReadDeadline(t time.Time) error { return g.inner.SetReadDeadline(t) } +func (g *geckoPacketConn) SetWriteDeadline(t time.Time) error { + return g.inner.SetWriteDeadline(t) +} + +// --- UDP-flavor passthrough --- + +func (g *geckoPacketConn) SyscallConn() (syscall.RawConn, error) { + if u, ok := g.inner.(udpLikePacketConn); ok { + return u.SyscallConn() + } + return nil, errors.ErrUnsupported +} + +func (g *geckoPacketConn) SetReadBuffer(bytes int) error { + if u, ok := g.inner.(udpLikePacketConn); ok { + return u.SetReadBuffer(bytes) + } + return errors.ErrUnsupported +} + +func (g *geckoPacketConn) SetWriteBuffer(bytes int) error { + if u, ok := g.inner.(udpLikePacketConn); ok { + return u.SetWriteBuffer(bytes) + } + return errors.ErrUnsupported +} diff --git a/extras/obfs/gecko_frame.go b/extras/obfs/gecko_frame.go new file mode 100644 index 0000000..437ffd1 --- /dev/null +++ b/extras/obfs/gecko_frame.go @@ -0,0 +1,86 @@ +package obfs + +import ( + "crypto/rand" + "encoding/binary" + "errors" +) + +const ( + geckoFlagFragment = 0x80 + geckoHeaderSize = 5 + + geckoMinFragmentChunks = 2 + geckoMaxFragmentChunks = 8 +) + +var ( + errFrameTruncated = errors.New("gecko frame truncated") + errFrameInvalid = errors.New("gecko frame invalid") +) + +// frameHeader is a Gecko fragment frame header. +// Wire layout (after Salamander decryption): +// +// byte 0: 0x80 (fragment marker; low 7 bits reserved) +// byte 1: msgID +// byte 2: chunkIdx:4 | totalChunks:4 +// byte 3-4: padLen (uint16, big-endian) +// then padLen random padding bytes, then the chunk payload +type frameHeader struct { + padLen uint16 + msgID uint8 + chunkIdx uint8 // < totalChunks + totalChunks uint8 // [2, 8] +} + +// encodeFrame writes a frame into out, filling the padding region with random +// bytes. out must be at least geckoHeaderSize + h.padLen + len(payload) long. +func encodeFrame(h frameHeader, payload, out []byte) (int, error) { + if h.totalChunks < geckoMinFragmentChunks || h.totalChunks > geckoMaxFragmentChunks { + return 0, errFrameInvalid + } + if h.chunkIdx >= h.totalChunks { + return 0, errFrameInvalid + } + needed := geckoHeaderSize + int(h.padLen) + len(payload) + if len(out) < needed { + return 0, errFrameTruncated + } + out[0] = geckoFlagFragment + out[1] = h.msgID + out[2] = h.chunkIdx<<4 | h.totalChunks&0x0f + binary.BigEndian.PutUint16(out[3:5], h.padLen) + if _, err := rand.Read(out[geckoHeaderSize : geckoHeaderSize+int(h.padLen)]); err != nil { + return 0, err + } + copy(out[geckoHeaderSize+int(h.padLen):], payload) + return needed, nil +} + +// decodeFrame parses a frame from in. The returned payload is a sub-slice of +// in (zero-copy) covering the bytes after the header and padding. +func decodeFrame(in []byte) (frameHeader, []byte, error) { + if len(in) < geckoHeaderSize { + return frameHeader{}, nil, errFrameTruncated + } + if in[0]&geckoFlagFragment == 0 { + return frameHeader{}, nil, errFrameInvalid + } + h := frameHeader{ + msgID: in[1], + chunkIdx: in[2] >> 4, + totalChunks: in[2] & 0x0f, + padLen: binary.BigEndian.Uint16(in[3:5]), + } + if h.totalChunks < geckoMinFragmentChunks || h.totalChunks > geckoMaxFragmentChunks { + return frameHeader{}, nil, errFrameInvalid + } + if h.chunkIdx >= h.totalChunks { + return frameHeader{}, nil, errFrameInvalid + } + if geckoHeaderSize+int(h.padLen) > len(in) { + return frameHeader{}, nil, errFrameTruncated + } + return h, in[geckoHeaderSize+int(h.padLen):], nil +} diff --git a/extras/obfs/gecko_frame_test.go b/extras/obfs/gecko_frame_test.go new file mode 100644 index 0000000..6868377 --- /dev/null +++ b/extras/obfs/gecko_frame_test.go @@ -0,0 +1,96 @@ +package obfs + +import ( + "bytes" + "errors" + "testing" +) + +func TestEncodeDecodeFrame(t *testing.T) { + payload := []byte{0xa1, 0xb2, 0xc3, 0xd4} + for total := geckoMinFragmentChunks; total <= geckoMaxFragmentChunks; total++ { + for idx := 0; idx < total; idx++ { + for _, padLen := range []int{0, 1, 64, 127, 512, 1100} { + h := frameHeader{ + padLen: uint16(padLen), + msgID: 0xa5, + chunkIdx: uint8(idx), + totalChunks: uint8(total), + } + out := make([]byte, geckoHeaderSize+padLen+len(payload)) + n, err := encodeFrame(h, payload, out) + if err != nil { + t.Fatalf("encode total=%d idx=%d padLen=%d: %v", total, idx, padLen, err) + } + if n != len(out) { + t.Fatalf("encode wrote %d, want %d", n, len(out)) + } + got, body, err := decodeFrame(out) + if err != nil { + t.Fatalf("decode total=%d idx=%d padLen=%d: %v", total, idx, padLen, err) + } + if got != h { + t.Fatalf("header mismatch: got %+v want %+v", got, h) + } + if !bytes.Equal(body, payload) { + t.Fatalf("payload mismatch total=%d idx=%d padLen=%d", total, idx, padLen) + } + } + } + } +} + +func TestEncodeFrameRejectsInvalid(t *testing.T) { + payload := []byte{0xff} + cases := []struct { + name string + h frameHeader + err error + }{ + {"totalChunks zero", frameHeader{totalChunks: 0}, errFrameInvalid}, + {"totalChunks one", frameHeader{totalChunks: 1}, errFrameInvalid}, + {"totalChunks nine", frameHeader{totalChunks: 9}, errFrameInvalid}, + {"chunkIdx out of range", frameHeader{totalChunks: 4, chunkIdx: 4}, errFrameInvalid}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out := make([]byte, 1024) + if _, err := encodeFrame(tc.h, payload, out); !errors.Is(err, tc.err) { + t.Fatalf("got %v, want %v", err, tc.err) + } + }) + } +} + +func TestEncodeFrameRejectsShortBuffer(t *testing.T) { + payload := []byte{0x01, 0x02, 0x03} + h := frameHeader{padLen: 8, totalChunks: 2, chunkIdx: 0} + out := make([]byte, 5) // need 5 + 8 + 3 = 16 + if _, err := encodeFrame(h, payload, out); !errors.Is(err, errFrameTruncated) { + t.Fatalf("got %v, want %v", err, errFrameTruncated) + } +} + +func TestDecodeFrameRejectsInvalid(t *testing.T) { + cases := []struct { + name string + in []byte + err error + }{ + {"empty", []byte{}, errFrameTruncated}, + {"header truncated", []byte{0x80, 0x55, 0x22, 0x00}, errFrameTruncated}, + {"not a fragment", []byte{0x00, 0x00, 0x22, 0x00, 0x00}, errFrameInvalid}, + {"totalChunks zero", []byte{0x80, 0x00, 0x00, 0x00, 0x00}, errFrameInvalid}, + {"totalChunks one", []byte{0x80, 0x00, 0x01, 0x00, 0x00}, errFrameInvalid}, + {"totalChunks nine", []byte{0x80, 0x00, 0x09, 0x00, 0x00}, errFrameInvalid}, + {"chunkIdx == totalChunks", []byte{0x80, 0x00, 0x44, 0x00, 0x00}, errFrameInvalid}, + {"padLen overrun", []byte{0x80, 0x00, 0x02, 0x00, 0x12, 0x01, 0x02}, errFrameTruncated}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, _, err := decodeFrame(tc.in); !errors.Is(err, tc.err) { + t.Fatalf("got %v, want %v", err, tc.err) + } + }) + } +} diff --git a/extras/obfs/gecko_test.go b/extras/obfs/gecko_test.go new file mode 100644 index 0000000..45be45c --- /dev/null +++ b/extras/obfs/gecko_test.go @@ -0,0 +1,719 @@ +package obfs + +import ( + "bytes" + "errors" + "fmt" + "math/rand" + "net" + "sync" + "sync/atomic" + "syscall" + "testing" + "time" +) + +// --- in-memory packet pipe --- + +// memEnd is one side of a bidirectional, lossy in-memory packet pipe. +type memEnd struct { + addr net.Addr + other *memEnd + + mu sync.Mutex + closed bool + done chan struct{} + inbox chan memPacket + + writeCount atomic.Int64 + dropFn func(idx int) bool // optional: return true to drop the n-th outgoing packet +} + +type memPacket struct { + src net.Addr + data []byte +} + +func newMemPipe(aAddr, bAddr net.Addr) (*memEnd, *memEnd) { + a := &memEnd{addr: aAddr, inbox: make(chan memPacket, 1024), done: make(chan struct{})} + b := &memEnd{addr: bAddr, inbox: make(chan memPacket, 1024), done: make(chan struct{})} + a.other = b + b.other = a + return a, b +} + +func (e *memEnd) WriteTo(p []byte, _ net.Addr) (int, error) { + idx := int(e.writeCount.Add(1) - 1) + if e.dropFn != nil && e.dropFn(idx) { + return len(p), nil + } + cp := make([]byte, len(p)) + copy(cp, p) + select { + case e.other.inbox <- memPacket{src: e.addr, data: cp}: + case <-e.other.done: + } + return len(p), nil +} + +func (e *memEnd) ReadFrom(p []byte) (int, net.Addr, error) { + select { + case pkt := <-e.inbox: + return copy(p, pkt.data), pkt.src, nil + case <-e.done: + return 0, nil, net.ErrClosed + } +} + +func (e *memEnd) Close() error { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return nil + } + e.closed = true + close(e.done) + return nil +} + +func (e *memEnd) LocalAddr() net.Addr { return e.addr } +func (e *memEnd) SetDeadline(time.Time) error { return nil } +func (e *memEnd) SetReadDeadline(time.Time) error { return nil } +func (e *memEnd) SetWriteDeadline(time.Time) error { return nil } + +// pendingInbox returns the count of buffered, undelivered packets on this end. +func (e *memEnd) pendingInbox() int { return len(e.inbox) } + +// --- helpers --- + +func mustWrapGecko(t *testing.T, conn net.PacketConn, password string) net.PacketConn { + t.Helper() + g, err := WrapPacketConnGecko(conn, GeckoOptions{Password: []byte(password)}) + if err != nil { + t.Fatalf("WrapPacketConnGecko: %v", err) + } + return g +} + +func makeAddrs() (a, b net.Addr) { + return &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1111}, + &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2222} +} + +// quicLong / quicShort produce realistic-looking QUIC payloads of n bytes +// with the appropriate top-bit on byte 0 to trigger / avoid fragmentation. +func quicLong(n int) []byte { + p := make([]byte, n) + rand.New(rand.NewSource(1)).Read(p) + p[0] = 0xc0 + return p +} + +func quicShort(n int) []byte { + p := make([]byte, n) + rand.New(rand.NewSource(2)).Read(p) + p[0] = 0x40 + return p +} + +// --- tests --- + +func TestGeckoRoundTripShortHeader(t *testing.T) { + aAddr, bAddr := makeAddrs() + a, b := newMemPipe(aAddr, bAddr) + defer a.Close() + defer b.Close() + + ga := mustWrapGecko(t, a, "test") + gb := mustWrapGecko(t, b, "test") + defer ga.Close() + defer gb.Close() + + payload := quicShort(400) + if _, err := ga.WriteTo(payload, bAddr); err != nil { + t.Fatalf("WriteTo: %v", err) + } + // Short-header packets should produce exactly one wire datagram. + if got := a.writeCount.Load(); got != 1 { + t.Fatalf("inner writes = %d, want 1", got) + } + + buf := make([]byte, 4096) + n, src, err := gb.ReadFrom(buf) + if err != nil { + t.Fatalf("ReadFrom: %v", err) + } + if !bytes.Equal(buf[:n], payload) { + t.Fatalf("payload mismatch") + } + if src.String() != aAddr.String() { + t.Fatalf("src = %v, want %v", src, aAddr) + } +} + +func TestGeckoRoundTripLongHeader(t *testing.T) { + aAddr, bAddr := makeAddrs() + a, b := newMemPipe(aAddr, bAddr) + defer a.Close() + defer b.Close() + + ga := mustWrapGecko(t, a, "test") + gb := mustWrapGecko(t, b, "test") + defer ga.Close() + defer gb.Close() + + payload := quicLong(1200) + if _, err := ga.WriteTo(payload, bAddr); err != nil { + t.Fatalf("WriteTo: %v", err) + } + chunks := a.writeCount.Load() + if chunks < geckoMinFragmentChunks || chunks > geckoMaxFragmentChunks { + t.Fatalf("inner writes = %d, want in [%d,%d]", chunks, geckoMinFragmentChunks, geckoMaxFragmentChunks) + } + + buf := make([]byte, 4096) + n, _, err := gb.ReadFrom(buf) + if err != nil { + t.Fatalf("ReadFrom: %v", err) + } + if !bytes.Equal(buf[:n], payload) { + t.Fatalf("payload mismatch") + } +} + +func TestGeckoRoundTripSmallLongHeader(t *testing.T) { + for _, size := range []int{1, 2, 5, 10, 15, 20, 25, 27, 30, 40, 64, 128} { + t.Run(fmt.Sprintf("size=%d", size), func(t *testing.T) { + aAddr, bAddr := makeAddrs() + a, b := newMemPipe(aAddr, bAddr) + defer a.Close() + defer b.Close() + + ga := mustWrapGecko(t, a, "test") + gb := mustWrapGecko(t, b, "test") + defer ga.Close() + defer gb.Close() + + payload := quicLong(size) + if _, err := ga.WriteTo(payload, bAddr); err != nil { + t.Fatalf("WriteTo: %v", err) + } + + buf := make([]byte, 4096) + n, _, err := gb.ReadFrom(buf) + if err != nil { + t.Fatalf("ReadFrom: %v", err) + } + if !bytes.Equal(buf[:n], payload) { + t.Fatalf("payload mismatch: got %d bytes, want %d", n, size) + } + }) + } +} + +func TestGeckoWriteFragmentedNeverPanics(t *testing.T) { + aAddr, bAddr := makeAddrs() + a, b := newMemPipe(aAddr, bAddr) + defer a.Close() + defer b.Close() + stop := make(chan struct{}) + defer close(stop) + drainInbox(b, stop) + + g := mustWrapGecko(t, a, "test").(*geckoPacketConn) + defer g.Close() + + for size := 1; size <= 64; size++ { + payload := quicLong(size) + if _, err := g.WriteTo(payload, bAddr); err != nil { + t.Fatalf("size=%d: WriteTo: %v", size, err) + } + } +} + +func TestGeckoReassemblesOutOfOrder(t *testing.T) { + aAddr, bAddr := makeAddrs() + a, b := newMemPipe(aAddr, bAddr) + defer a.Close() + defer b.Close() + + // We need exclusive access to b's inbox to reorder. Wrap a side directly, + // receive raw datagrams from b, shuffle them into a private pipe that + // feeds the receiver gecko. + ga := mustWrapGecko(t, a, "test") + defer ga.Close() + + payload := quicLong(900) + if _, err := ga.WriteTo(payload, bAddr); err != nil { + t.Fatalf("WriteTo: %v", err) + } + + // Drain b's inbox. + var pkts []memPacket + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) && a.writeCount.Load() > int64(len(pkts)) { + select { + case pkt := <-b.inbox: + pkts = append(pkts, pkt) + case <-time.After(50 * time.Millisecond): + } + } + if int64(len(pkts)) != a.writeCount.Load() { + t.Fatalf("captured %d/%d", len(pkts), a.writeCount.Load()) + } + + // Build a private pipe and feed packets in reverse order. + c, d := newMemPipe(aAddr, bAddr) + gd := mustWrapGecko(t, d, "test") + defer gd.Close() + defer c.Close() + go func() { + for i := len(pkts) - 1; i >= 0; i-- { + d.inbox <- pkts[i] + } + }() + + buf := make([]byte, 4096) + n, _, err := gd.ReadFrom(buf) + if err != nil { + t.Fatalf("ReadFrom: %v", err) + } + if !bytes.Equal(buf[:n], payload) { + t.Fatalf("payload mismatch after reorder") + } +} + +func TestGeckoExpiresIncompleteFragment(t *testing.T) { + aAddr, bAddr := makeAddrs() + a, b := newMemPipe(aAddr, bAddr) + defer a.Close() + defer b.Close() + + // Drop the first chunk so reassembly can never complete. + a.dropFn = func(idx int) bool { return idx == 0 } + + ga := mustWrapGecko(t, a, "test") + gb := mustWrapGecko(t, b, "test").(*geckoPacketConn) + defer ga.Close() + defer gb.Close() + + payload := quicLong(900) + if _, err := ga.WriteTo(payload, bAddr); err != nil { + t.Fatalf("WriteTo: %v", err) + } + + // Read all incoming packets in a goroutine; ReadFrom should never return + // because no message is complete. + done := make(chan struct{}) + go func() { + buf := make([]byte, 4096) + gb.ReadFrom(buf) + close(done) + }() + + // Wait until the reassembly entry exists, then fast-forward time. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + gb.mu.Lock() + n := len(gb.reassembly) + gb.mu.Unlock() + if n > 0 { + break + } + time.Sleep(5 * time.Millisecond) + } + gb.mu.Lock() + if len(gb.reassembly) == 0 { + gb.mu.Unlock() + t.Fatal("expected at least one reassembly entry") + } + gb.mu.Unlock() + + gb.gcExpired(time.Now().Add(geckoReassemblyTTL + time.Second)) + + gb.mu.Lock() + if len(gb.reassembly) != 0 { + gb.mu.Unlock() + t.Fatalf("reassembly map not empty after gc") + } + if len(gb.perSource) != 0 { + gb.mu.Unlock() + t.Fatalf("perSource map not empty after gc") + } + gb.mu.Unlock() + + // Cleanup: closing gb unblocks the reader goroutine. + gb.Close() + <-done +} + +func TestGeckoEnforcesPerSourceCap(t *testing.T) { + aAddr, bAddr := makeAddrs() + a, b := newMemPipe(aAddr, bAddr) + defer a.Close() + defer b.Close() + + // Drop every second chunk onward so reassembly never completes; the + // receiver accumulates partial entries. + a.dropFn = func(idx int) bool { return idx > 0 && idx%2 == 0 } + + ga := mustWrapGecko(t, a, "test") + gb := mustWrapGecko(t, b, "test").(*geckoPacketConn) + defer ga.Close() + defer gb.Close() + + go func() { + buf := make([]byte, 4096) + for { + if _, _, err := gb.ReadFrom(buf); err != nil { + return + } + } + }() + + for i := 0; i < geckoMaxPerSource+5; i++ { + if _, err := ga.WriteTo(quicLong(1200), bAddr); err != nil { + t.Fatalf("WriteTo %d: %v", i, err) + } + } + + // Wait for the receiver to settle. + time.Sleep(100 * time.Millisecond) + + gb.mu.Lock() + defer gb.mu.Unlock() + count := gb.perSource[aAddr.String()] + if count > geckoMaxPerSource { + t.Fatalf("perSource = %d, want <= %d", count, geckoMaxPerSource) + } +} + +func TestGeckoEvictsOldestOnGlobalCap(t *testing.T) { + g := newGeckoPacketConn(nil, geckoDefaultMinPacket, geckoDefaultMaxPacket) // not actually used for I/O + defer close(g.closeCh) + + // Manually fill the reassembly map past the cap with entries from + // distinct sources (so the per-source cap doesn't trigger first). + now := time.Now() + for i := 0; i < geckoMaxReassembly; i++ { + key := reassemblyKey{addr: fmt.Sprintf("src-%d", i), msgID: 1} + g.reassembly[key] = &reassemblyEntry{ + chunks: make([][]byte, 4), + total: 4, + deadline: now.Add(time.Duration(i) * time.Millisecond), + } + g.perSource[key.addr]++ + } + + // Trigger eviction. + g.mu.Lock() + g.evictOldestLocked() + g.mu.Unlock() + + if len(g.reassembly) != geckoMaxReassembly-1 { + t.Fatalf("after evict len = %d, want %d", len(g.reassembly), geckoMaxReassembly-1) + } + // The "oldest" was the one with the smallest deadline → src-0. + if _, ok := g.reassembly[reassemblyKey{addr: "src-0", msgID: 1}]; ok { + t.Fatal("oldest entry not evicted") + } + if g.perSource["src-0"] != 0 { + t.Fatalf("perSource[src-0] = %d after eviction, want 0", g.perSource["src-0"]) + } +} + +func TestGeckoBoundedUnderGarbageFlood(t *testing.T) { + aAddr, bAddr := makeAddrs() + a, b := newMemPipe(aAddr, bAddr) + defer a.Close() + defer b.Close() + + gb := mustWrapGecko(t, b, "test").(*geckoPacketConn) + defer gb.Close() + + go func() { + buf := make([]byte, 4096) + for { + if _, _, err := gb.ReadFrom(buf); err != nil { + return + } + } + }() + + // Inject 50k random datagrams from a moderate number of distinct sources. + rng := rand.New(rand.NewSource(42)) + for i := 0; i < 50_000; i++ { + size := 16 + rng.Intn(200) + junk := make([]byte, size) + rng.Read(junk) + src := &net.UDPAddr{IP: net.IPv4(10, 0, 0, byte(i%256)), Port: 1024 + i%4096} + select { + case b.inbox <- memPacket{src: src, data: junk}: + case <-time.After(time.Second): + t.Fatal("inbox blocked") + } + } + + // Let the receiver drain. + for i := 0; i < 50; i++ { + if a.pendingInbox() == 0 && b.pendingInbox() == 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + + gb.mu.Lock() + defer gb.mu.Unlock() + if len(gb.reassembly) > geckoMaxReassembly { + t.Fatalf("reassembly map size = %d, exceeds cap %d", len(gb.reassembly), geckoMaxReassembly) + } +} + +func TestGeckoUDPPassthrough(t *testing.T) { + udp, err := net.ListenUDP("udp", &net.UDPAddr{}) + if err != nil { + t.Fatal(err) + } + defer udp.Close() + + g, err := WrapPacketConnGecko(udp, GeckoOptions{Password: []byte("test")}) + if err != nil { + t.Fatal(err) + } + defer g.Close() + + type udpLike interface { + SyscallConn() (syscall.RawConn, error) + SetReadBuffer(int) error + SetWriteBuffer(int) error + } + u, ok := g.(udpLike) + if !ok { + t.Fatal("gecko conn does not expose UDP-flavor methods") + } + if rc, err := u.SyscallConn(); err != nil || rc == nil { + t.Fatalf("SyscallConn: %v %v", rc, err) + } + if err := u.SetReadBuffer(1 << 20); err != nil { + t.Fatalf("SetReadBuffer: %v", err) + } + if err := u.SetWriteBuffer(1 << 20); err != nil { + t.Fatalf("SetWriteBuffer: %v", err) + } +} + +func TestGeckoRequiresPassword(t *testing.T) { + if _, err := WrapPacketConnGecko(nil, GeckoOptions{}); err == nil { + t.Fatal("expected error for missing password") + } +} + +func TestGeckoRejectsInvalidPacketSize(t *testing.T) { + cases := []GeckoOptions{ + {Password: []byte("x"), MinPacketSize: 1000, MaxPacketSize: 500}, // min > max + {Password: []byte("x"), MinPacketSize: -1}, // min <= 0 + {Password: []byte("x"), MaxPacketSize: geckoBufferSize + 1}, // max too large + } + for i, opt := range cases { + if _, err := WrapPacketConnGecko(nil, opt); err == nil { + t.Fatalf("case %d: expected error", i) + } + } +} + +// TestGeckoPaddingWithinBounds verifies every fragmented wire datagram is +// padded into the configured [min, max] size band. +func TestGeckoPaddingWithinBounds(t *testing.T) { + const minSize, maxSize = 400, 900 + aAddr, bAddr := makeAddrs() + a, b := newMemPipe(aAddr, bAddr) + defer a.Close() + defer b.Close() + + ga, err := WrapPacketConnGecko(a, GeckoOptions{ + Password: []byte("test"), + MinPacketSize: minSize, + MaxPacketSize: maxSize, + }) + if err != nil { + t.Fatalf("WrapPacketConnGecko: %v", err) + } + defer ga.Close() + + for _, size := range []int{1, 50, 200, 600, 1200} { + if _, err := ga.WriteTo(quicLong(size), bAddr); err != nil { + t.Fatalf("WriteTo size=%d: %v", size, err) + } + } + for { + select { + case pkt := <-b.inbox: + if len(pkt.data) < minSize || len(pkt.data) > maxSize { + t.Fatalf("datagram size %d outside [%d, %d]", len(pkt.data), minSize, maxSize) + } + default: + return + } + } +} + +// Compile-time interface assertions. +var ( + _ net.PacketConn = (*geckoPacketConn)(nil) + _ udpLikePacketConn = (*geckoPacketConn)(nil) +) + +// Sanity: errors.ErrUnsupported is what we return when inner isn't UDP-like. +func TestGeckoNonUDPInnerReturnsUnsupported(t *testing.T) { + aAddr, bAddr := makeAddrs() + a, _ := newMemPipe(aAddr, bAddr) + defer a.Close() + + g := mustWrapGecko(t, a, "test").(*geckoPacketConn) + defer g.Close() + + if _, err := g.SyscallConn(); !errors.Is(err, errors.ErrUnsupported) { + t.Fatalf("SyscallConn err = %v, want ErrUnsupported", err) + } + if err := g.SetReadBuffer(1 << 20); !errors.Is(err, errors.ErrUnsupported) { + t.Fatalf("SetReadBuffer err = %v, want ErrUnsupported", err) + } + if err := g.SetWriteBuffer(1 << 20); !errors.Is(err, errors.ErrUnsupported) { + t.Fatalf("SetWriteBuffer err = %v, want ErrUnsupported", err) + } +} + +// --- benchmarks --- + +// drainInbox empties an inbox in a goroutine until done is closed; used in +// benchmarks so a full memEnd.inbox never blocks WriteTo. +func drainInbox(end *memEnd, done <-chan struct{}) { + go func() { + for { + select { + case <-done: + return + case <-end.inbox: + } + } + }() +} + +func BenchmarkGeckoWriteShortHeader(b *testing.B) { + aAddr, bAddr := makeAddrs() + a, bEnd := newMemPipe(aAddr, bAddr) + defer a.Close() + defer bEnd.Close() + stop := make(chan struct{}) + defer close(stop) + drainInbox(bEnd, stop) + + g, _ := WrapPacketConnGecko(a, GeckoOptions{Password: []byte("bench")}) + defer g.Close() + + payload := quicShort(400) + b.SetBytes(int64(len(payload))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := g.WriteTo(payload, bAddr); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkGeckoWriteLongHeader(b *testing.B) { + aAddr, bAddr := makeAddrs() + a, bEnd := newMemPipe(aAddr, bAddr) + defer a.Close() + defer bEnd.Close() + stop := make(chan struct{}) + defer close(stop) + drainInbox(bEnd, stop) + + g, _ := WrapPacketConnGecko(a, GeckoOptions{Password: []byte("bench")}) + defer g.Close() + + payload := quicLong(1200) + b.SetBytes(int64(len(payload))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := g.WriteTo(payload, bAddr); err != nil { + b.Fatal(err) + } + } +} + +// captureGeckoWire fragments payload through a sender Gecko conn and returns +// the resulting wire datagrams, for use as fixed input to read benchmarks. +func captureGeckoWire(b *testing.B, payload []byte) []memPacket { + b.Helper() + aAddr, bAddr := makeAddrs() + a, bEnd := newMemPipe(aAddr, bAddr) + defer a.Close() + defer bEnd.Close() + + ga, err := WrapPacketConnGecko(a, GeckoOptions{Password: []byte("bench")}) + if err != nil { + b.Fatal(err) + } + defer ga.Close() + if _, err := ga.WriteTo(payload, bAddr); err != nil { + b.Fatal(err) + } + + var wire []memPacket + for { + select { + case pkt := <-bEnd.inbox: + wire = append(wire, pkt) + default: + return wire + } + } +} + +func BenchmarkGeckoReadShortHeader(b *testing.B) { + wire := captureGeckoWire(b, quicShort(400)) + + _, recv := newMemPipe(makeAddrs()) + gb, err := WrapPacketConnGecko(recv, GeckoOptions{Password: []byte("bench")}) + if err != nil { + b.Fatal(err) + } + defer gb.Close() + + buf := make([]byte, 4096) + b.SetBytes(400) + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, pkt := range wire { + recv.inbox <- pkt + } + if _, _, err := gb.ReadFrom(buf); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkGeckoReadLongHeader(b *testing.B) { + wire := captureGeckoWire(b, quicLong(1200)) + + _, recv := newMemPipe(makeAddrs()) + gb, err := WrapPacketConnGecko(recv, GeckoOptions{Password: []byte("bench")}) + if err != nil { + b.Fatal(err) + } + defer gb.Close() + + buf := make([]byte, 4096) + b.SetBytes(1200) + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, pkt := range wire { + recv.inbox <- pkt + } + if _, _, err := gb.ReadFrom(buf); err != nil { + b.Fatal(err) + } + } +} diff --git a/extras/obfs/salamander.go b/extras/obfs/salamander.go index 260d8f9..8b4ce54 100644 --- a/extras/obfs/salamander.go +++ b/extras/obfs/salamander.go @@ -3,6 +3,7 @@ package obfs import ( "fmt" "math/rand" + "net" "sync" "time" @@ -15,14 +16,14 @@ const ( smKeyLen = blake2b.Size256 ) -var _ Obfuscator = (*SalamanderObfuscator)(nil) +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 +// 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 { +type salamanderObfuscator struct { PSK []byte RandSrc *rand.Rand @@ -30,21 +31,32 @@ type SalamanderObfuscator struct { keyInput []byte } -func NewSalamanderObfuscator(psk []byte) (*SalamanderObfuscator, error) { +func newSalamanderObfuscator(psk []byte) (*salamanderObfuscator, error) { if len(psk) < smPSKMinLen { return nil, ErrPSKTooShort } pskCopy := append([]byte(nil), psk...) keyInput := make([]byte, len(pskCopy)+smSaltLen) copy(keyInput, pskCopy) - return &SalamanderObfuscator{ + return &salamanderObfuscator{ PSK: pskCopy, RandSrc: rand.New(rand.NewSource(time.Now().UnixNano())), keyInput: keyInput, }, nil } -func (o *SalamanderObfuscator) Obfuscate(in, out []byte) int { +// WrapPacketConnSalamander wraps conn with Salamander obfuscation: each +// outbound packet is XOR'd with BLAKE2b-256(PSK || random salt) and the +// 8-byte salt is prepended on the wire. +func WrapPacketConnSalamander(conn net.PacketConn, psk []byte) (net.PacketConn, error) { + ob, err := newSalamanderObfuscator(psk) + if err != nil { + return nil, err + } + return wrapPacketConn(conn, ob), nil +} + +func (o *salamanderObfuscator) Obfuscate(in, out []byte) int { outLen := len(in) + smSaltLen if len(out) < outLen { return 0 @@ -59,7 +71,7 @@ func (o *SalamanderObfuscator) Obfuscate(in, out []byte) int { return outLen } -func (o *SalamanderObfuscator) Deobfuscate(in, out []byte) int { +func (o *salamanderObfuscator) Deobfuscate(in, out []byte) int { outLen := len(in) - smSaltLen if outLen <= 0 || len(out) < outLen { return 0 @@ -73,7 +85,7 @@ func (o *SalamanderObfuscator) Deobfuscate(in, out []byte) int { return outLen } -func (o *SalamanderObfuscator) keyLocked(salt []byte) [smKeyLen]byte { +func (o *salamanderObfuscator) keyLocked(salt []byte) [smKeyLen]byte { copy(o.keyInput[len(o.PSK):], salt[:smSaltLen]) return blake2b.Sum256(o.keyInput) } diff --git a/extras/obfs/salamander_test.go b/extras/obfs/salamander_test.go index 85eafdc..5cb8081 100644 --- a/extras/obfs/salamander_test.go +++ b/extras/obfs/salamander_test.go @@ -8,7 +8,7 @@ import ( ) func BenchmarkSalamanderObfuscator_Obfuscate(b *testing.B) { - o, _ := NewSalamanderObfuscator([]byte("average_password")) + o, _ := newSalamanderObfuscator([]byte("average_password")) in := make([]byte, 1200) _, _ = rand.Read(in) out := make([]byte, 2048) @@ -19,7 +19,7 @@ func BenchmarkSalamanderObfuscator_Obfuscate(b *testing.B) { } func BenchmarkSalamanderObfuscator_Deobfuscate(b *testing.B) { - o, _ := NewSalamanderObfuscator([]byte("average_password")) + o, _ := newSalamanderObfuscator([]byte("average_password")) in := make([]byte, 1200) _, _ = rand.Read(in) out := make([]byte, 2048) @@ -30,7 +30,7 @@ func BenchmarkSalamanderObfuscator_Deobfuscate(b *testing.B) { } func TestSalamanderObfuscator(t *testing.T) { - o, _ := NewSalamanderObfuscator([]byte("average_password")) + o, _ := newSalamanderObfuscator([]byte("average_password")) in := make([]byte, 1200) oOut := make([]byte, 2048) dOut := make([]byte, 2048)