diff --git a/NEW_FEATURES.md b/NEW_FEATURES.md index 51c41a6..557df49 100644 --- a/NEW_FEATURES.md +++ b/NEW_FEATURES.md @@ -1,10 +1,10 @@ -# Новые возможности (относительно upstream) +# New Features (vs upstream) -## 1. L3 VPN туннель (Network) +## 1. L3 VPN Tunnel (Network) -Полноценный IP-туннель поверх QUIC. На сервере создаётся TUN-интерфейс, клиенты получают IP из пула, весь L3-трафик маршрутизируется через зашифрованное QUIC-соединение. +Full IP tunnel over QUIC. A TUN interface is created on the server, clients get an IP from a pool, and all L3 traffic is routed through the encrypted QUIC connection. -### Конфиг сервера (config.json) +### Server config (config.json) ```json { @@ -21,15 +21,15 @@ } ``` -Параметры: -- `enabled` — включить туннель -- `listen` — адрес для QUIC-слушателя (по умолч. `:5199`) -- `token` — общий секрет для аутентификации клиентов -- `tun.name` — имя TUN-интерфейса на сервере -- `tun.mtu` — MTU TUN-интерфейса -- `pool` — CIDR подсеть для выдачи IP клиентам +Parameters: +- `enabled` — enable the tunnel +- `listen` — QUIC listener address (default `:5199`) +- `token` — shared secret for client authentication +- `tun.name` — TUN interface name on the server +- `tun.mtu` — TUN interface MTU +- `pool` — CIDR subnet for assigning IPs to clients -### Конфиг клиента (client.json) +### Client config (client.json) ```json { @@ -44,19 +44,19 @@ } ``` -Параметры: -- `server` — адрес сервера (обязательно) -- `token` — общий секрет (должен совпадать с серверным) -- `tun.name` — имя TUN-интерфейса на клиенте -- `tun.mtu` — MTU TUN-интерфейса +Parameters: +- `server` — server address (required) +- `token` — shared secret (must match the server) +- `tun.name` — TUN interface name on the client +- `tun.mtu` — TUN interface MTU --- -## 2. FileMask шум (Noise) +## 2. FileMask Noise -Новый слой обфускации, который маскирует трафик под скачивание зашифрованных файлов. Работает поверх существующей обфускации (Salamander/Gecko). Сервер шлёт клиенту случайные куски реальных файлов с диска, имитируя активную загрузку. +New obfuscation layer that masks traffic as downloading encrypted files. It works on top of the existing obfuscation (Salamander/Gecko). The server sends the client random chunks of real files from disk, simulating an active download. -### Конфиг сервера +### Server config ```json { @@ -73,23 +73,23 @@ } ``` -Параметры: -- `type` — `"filemask"` для включения; `"none"` или пустое значение — отключено -- `filemask.dir` — **обязательно**, директория с файлами, содержимым которых будет маскироваться трафик -- `filemask.maxRate` — максимальная скорость шума (по умолч. `"512 kbps"`) -- `filemask.minPacketSize` — минимальный размер пакета шума -- `filemask.maxPacketSize` — максимальный размер пакета шума -- `filemask.idleThreshold` — таймаут бездействия клиента, после которого начинает генерироваться шум +Parameters: +- `type` — `"filemask"` to enable; `"none"` or empty to disable +- `filemask.dir` — **required**, directory containing files used to mask traffic +- `filemask.maxRate` — max noise rate (default `"512 kbps"`) +- `filemask.minPacketSize` — minimum noise packet size +- `filemask.maxPacketSize` — maximum noise packet size +- `filemask.idleThreshold` — client idle timeout before noise generation starts -Файлы из указанной директории шифруются AES-GCM на лету и отправляются клиенту. +Files from the specified directory are encrypted with AES-GCM on the fly and sent to the client. --- ## 3. Hysteria Outbound -Возможность использовать другой Hysteria сервер как upstream/выходной узел (outbound). Позволяет строить цепочки: клиент -> сервер А -> сервер Б (через hysteria outbound). +Ability to use another Hysteria server as an upstream/outbound node. Allows building chains: client -> server A -> server B (via hysteria outbound). -### Конфиг сервера +### Server config ```json { @@ -141,15 +141,15 @@ } ``` -Поддерживаемые параметры внутри `hysteria`: -- `server` — адрес целевого Hysteria сервера (обязательно) -- `auth` — пароль аутентификации (обязательно) -- `tls` — TLS настройки (SNI, insecure, pinSHA256, CA) -- `quic` — настройки QUIC (окна приёма, таймауты) -- `bandwidth` — лимиты пропускной способности -- `congestion` — алгоритм контроля перегрузки (bbr/cubic/brutal) -- `obfs` — обфускация для соединения с upstream (salamander/gecko) -- `transport` — транспорт (udp, с опциональным port hopping) -- `fastOpen` — включить fast open +Supported parameters inside `hysteria`: +- `server` — target Hysteria server address (required) +- `auth` — authentication password (required) +- `tls` — TLS settings (SNI, insecure, pinSHA256, CA) +- `quic` — QUIC settings (receive windows, timeouts) +- `bandwidth` — bandwidth limits +- `congestion` — congestion control algorithm (bbr/cubic/brutal) +- `obfs` — obfuscation for the upstream connection (salamander/gecko) +- `transport` — transport (udp, with optional port hopping) +- `fastOpen` — enable fast open -Используется в разделе `outbounds` наравне с `direct`, `socks5`, `http`. +Used in the `outbounds` section alongside `direct`, `socks5`, `http`. diff --git a/app/cmd/client.go b/app/cmd/client.go index 9b84cc1..fb2b7e8 100644 --- a/app/cmd/client.go +++ b/app/cmd/client.go @@ -80,9 +80,10 @@ type clientConfig struct { QUIC clientConfigQUIC `mapstructure:"quic"` Congestion clientConfigCongestion `mapstructure:"congestion"` Bandwidth clientConfigBandwidth `mapstructure:"bandwidth"` - FastOpen bool `mapstructure:"fastOpen"` - Lazy bool `mapstructure:"lazy"` - SOCKS5 *socks5Config `mapstructure:"socks5"` + FastOpen bool `mapstructure:"fastOpen"` + Lazy bool `mapstructure:"lazy"` + MaxSessionDuration time.Duration `mapstructure:"maxSessionDuration"` + SOCKS5 *socks5Config `mapstructure:"socks5"` HTTP *httpConfig `mapstructure:"http"` TCPForwarding []tcpForwardingEntry `mapstructure:"tcpForwarding"` UDPForwarding []udpForwardingEntry `mapstructure:"udpForwarding"` @@ -136,10 +137,15 @@ type clientConfigObfsGecko struct { MaxPacketSize int `mapstructure:"maxPacketSize"` } +type clientConfigObfsTLSMimic struct { + Password string `mapstructure:"password"` +} + type clientConfigObfs struct { Type string `mapstructure:"type"` Salamander clientConfigObfsSalamander `mapstructure:"salamander"` Gecko clientConfigObfsGecko `mapstructure:"gecko"` + TLSMimic clientConfigObfsTLSMimic `mapstructure:"tlsmimic"` } type clientConfigTLS struct { @@ -349,6 +355,12 @@ func (c *clientConfig) wrapObfs(conn net.PacketConn) (net.PacketConn, error) { return nil, configError{Field: "obfs.gecko", Err: err} } return wrapped, nil + case "tlsmimic": + wrapped, err := obfs.WrapPacketConnTLSMimic(conn, []byte(c.Obfs.TLSMimic.Password)) + if err != nil { + return nil, configError{Field: "obfs.tlsmimic.password", Err: err} + } + return wrapped, nil default: return nil, configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")} } @@ -506,6 +518,9 @@ func (c *clientConfig) URI() string { case "gecko": q.Set("obfs", "gecko") q.Set("obfs-password", c.Obfs.Gecko.Password) + case "tlsmimic": + q.Set("obfs", "tlsmimic") + q.Set("obfs-password", c.Obfs.TLSMimic.Password) } if c.TLS.SNI != "" { q.Set("sni", c.TLS.SNI) @@ -565,6 +580,8 @@ func (c *clientConfig) parseURI() bool { c.Obfs.Salamander.Password = q.Get("obfs-password") case "gecko": c.Obfs.Gecko.Password = q.Get("obfs-password") + case "tlsmimic": + c.Obfs.TLSMimic.Password = q.Get("obfs-password") } } if sni := q.Get("sni"); sni != "" { @@ -805,7 +822,7 @@ func runClient(v *viper.Viper) { logger.Fatal("failed to parse client config", zap.Error(err)) } - c, err := client.NewReconnectableClient( + c, err := client.NewReconnectableClientWithMaxSession( config.Config, func(c client.Client, info *client.HandshakeInfo, count int) { connectLog(info, count) @@ -816,7 +833,7 @@ func runClient(v *viper.Viper) { if count == 1 && !disableUpdateCheck { go runCheckUpdateClient(c) } - }, config.Lazy, + }, config.Lazy, config.MaxSessionDuration, ) if err != nil { logger.Fatal("failed to initialize client", zap.Error(err)) diff --git a/app/cmd/server.go b/app/cmd/server.go index df19aba..4158ab3 100644 --- a/app/cmd/server.go +++ b/app/cmd/server.go @@ -109,10 +109,17 @@ type serverConfigObfsGecko struct { MaxPacketSize int `mapstructure:"maxPacketSize"` } +type serverConfigObfsTLSMimic struct { + Password string `mapstructure:"password"` +} + type serverConfigObfs struct { - Type string `mapstructure:"type"` - Salamander serverConfigObfsSalamander `mapstructure:"salamander"` - Gecko serverConfigObfsGecko `mapstructure:"gecko"` + Type string `mapstructure:"type"` + Salamander serverConfigObfsSalamander `mapstructure:"salamander"` + Gecko serverConfigObfsGecko `mapstructure:"gecko"` + TLSMimic serverConfigObfsTLSMimic `mapstructure:"tlsmimic"` + MinPadding int `mapstructure:"minPadding"` + MaxPadding int `mapstructure:"maxPadding"` } type serverConfigNoiseFileMask struct { @@ -471,6 +478,26 @@ func (c *serverConfig) wrapObfs(conn net.PacketConn) (net.PacketConn, error) { return nil, configError{Field: "obfs.gecko", Err: err} } packetConn = wrapped + case "tlsmimic": + wrapped, err := obfs.WrapPacketConnTLSMimic(packetConn, []byte(c.Obfs.TLSMimic.Password)) + if err != nil { + return nil, configError{Field: "obfs.tlsmimic.password", Err: err} + } + packetConn = wrapped + case "auto", "salamander+tlsmimic": + psk := c.Obfs.Salamander.Password + if psk == "" { + psk = c.Obfs.TLSMimic.Password + } + wrapped, err := obfs.WrapPacketConnMulti(packetConn, obfs.MultiObfsOptions{ + Password: psk, + MinPadding: c.Obfs.MinPadding, + MaxPadding: c.Obfs.MaxPadding, + }) + if err != nil { + return nil, configError{Field: "obfs.multi", Err: err} + } + packetConn = wrapped default: return nil, configError{Field: "obfs.type", Err: errors.New("unsupported obfuscation type")} } @@ -1339,6 +1366,8 @@ func serverConfigOutboundHysteriaToOutbound(c serverConfigOutboundHysteria) (out obfsPassword = c.Obfs.Salamander.Password case "gecko": obfsPassword = c.Obfs.Gecko.Password + case "tlsmimic": + obfsPassword = c.Obfs.TLSMimic.Password } cfg := &outbounds.HysteriaOutboundConfig{ ServerAddr: c.Server, diff --git a/core/client/reconnect.go b/core/client/reconnect.go index 91c7eb4..1c81b2a 100644 --- a/core/client/reconnect.go +++ b/core/client/reconnect.go @@ -3,19 +3,25 @@ package client import ( "net" "sync" + "time" coreErrs "github.com/apernet/hysteria/core/v2/errors" ) -// reconnectableClientImpl is a wrapper of Client, which can reconnect when the connection is closed, -// except when the caller explicitly calls Close() to permanently close this client. +// reconnectableClientImpl is a wrapper of Client, which can reconnect when +// the connection is closed (except when the caller explicitly calls Close()), +// or periodically when maxSessionDuration > 0 to avoid long-lived sessions. type reconnectableClientImpl struct { - configFunc func() (*Config, error) // called before connecting - connectedFunc func(Client, *HandshakeInfo, int) // called when successfully connected - client Client - count int + configFunc func() (*Config, error) + connectedFunc func(Client, *HandshakeInfo, int) + client Client + count int + maxSessionDuration time.Duration + m sync.Mutex - closed bool // permanent close + closed bool + recreateTimer *time.Timer + recreateStop chan struct{} } // NewReconnectableClient creates a reconnectable client. @@ -23,36 +29,100 @@ type reconnectableClientImpl struct { // We use a function for config mainly to delay config evaluation // (which involves DNS resolution) until the actual connection attempt. func NewReconnectableClient(configFunc func() (*Config, error), connectedFunc func(Client, *HandshakeInfo, int), lazy bool) (Client, error) { + return NewReconnectableClientWithMaxSession(configFunc, connectedFunc, lazy, 0) +} + +// NewReconnectableClientWithMaxSession creates a reconnectable client that +// periodically reconnects every maxSessionDuration to avoid long-lived +// sessions being detected by DPI. Set to 0 to disable timed recreation. +func NewReconnectableClientWithMaxSession(configFunc func() (*Config, error), connectedFunc func(Client, *HandshakeInfo, int), lazy bool, maxSessionDuration time.Duration) (Client, error) { rc := &reconnectableClientImpl{ - configFunc: configFunc, - connectedFunc: connectedFunc, + configFunc: configFunc, + connectedFunc: connectedFunc, + maxSessionDuration: maxSessionDuration, + recreateStop: make(chan struct{}), } if !lazy { if err := rc.reconnect(); err != nil { return nil, err } } + go rc.recreateLoop() return rc, nil } -func (rc *reconnectableClientImpl) reconnect() error { - if rc.client != nil { - _ = rc.client.Close() +func (rc *reconnectableClientImpl) recreateLoop() { + for { + rc.m.Lock() + if rc.closed { + rc.m.Unlock() + return + } + timer := rc.recreateTimer + rc.m.Unlock() + if timer == nil { + // No timer configured, sleep until close + <-rc.recreateStop + return + } + select { + case <-timer.C: + _ = rc.reconnect() + case <-rc.recreateStop: + timer.Stop() + return + } } - var info *HandshakeInfo +} + +func (rc *reconnectableClientImpl) reconnect() error { + rc.m.Lock() + if rc.closed { + rc.m.Unlock() + return coreErrs.ClosedError{} + } + oldClient := rc.client + rc.client = nil + rc.m.Unlock() + + if oldClient != nil { + _ = oldClient.Close() + } + config, err := rc.configFunc() if err != nil { return err } - rc.client, info, err = NewClient(config) + client, info, err := NewClient(config) if err != nil { return err - } else { - rc.count++ - if rc.connectedFunc != nil { - rc.connectedFunc(rc, info, rc.count) - } - return nil + } + + rc.m.Lock() + if rc.closed { + _ = client.Close() + rc.m.Unlock() + return coreErrs.ClosedError{} + } + rc.client = client + rc.count++ + rc.resetRecreateTimerLocked() + connectedFunc := rc.connectedFunc + rc.m.Unlock() + + if connectedFunc != nil { + connectedFunc(rc, info, rc.count) + } + return nil +} + +func (rc *reconnectableClientImpl) resetRecreateTimerLocked() { + if rc.recreateTimer != nil { + rc.recreateTimer.Stop() + rc.recreateTimer = nil + } + if rc.maxSessionDuration > 0 { + rc.recreateTimer = time.NewTimer(rc.maxSessionDuration) } } @@ -111,10 +181,16 @@ func (rc *reconnectableClientImpl) UDP() (HyUDPConn, error) { func (rc *reconnectableClientImpl) Close() error { rc.m.Lock() - defer rc.m.Unlock() rc.closed = true - if rc.client != nil { - return rc.client.Close() + if rc.recreateTimer != nil { + rc.recreateTimer.Stop() + rc.recreateTimer = nil + } + close(rc.recreateStop) + client := rc.client + rc.m.Unlock() + if client != nil { + return client.Close() } return nil } diff --git a/extras/obfs/multi.go b/extras/obfs/multi.go new file mode 100644 index 0000000..b7df612 --- /dev/null +++ b/extras/obfs/multi.go @@ -0,0 +1,303 @@ +package obfs + +import ( + "crypto/rand" + "encoding/binary" + "net" + "sync" + "syscall" + "time" +) + +// ObfsMode represents an obfuscation method. +type ObfsMode int + +const ( + ObfsModeSalamander ObfsMode = iota + ObfsModeTLSMimic + ObfsModePlain +) + +// MultiObfsOptions configures the multi-mode obfuscation wrapper. +type MultiObfsOptions struct { + // PSK for Salamander and TLS-mimic (they share the same key). + Password string + // PadRnd randomizes each outbound packet's total size to land in + // [MinPadding, MaxPadding]. Zero means no padding. + MinPadding int + MaxPadding int + // SendJitter adds a random delay [0, SendJitter) before each outbound + // packet to break constant-bitrate DPI patterns. Zero means no delay. + SendJitter time.Duration +} + +type multiObfsPacketConn struct { + inner net.PacketConn + + sm *salamanderObfuscator + tm *tlsMimicObfuscator + pad bool + minP, maxP int + sendJitter time.Duration + + addrModes map[string]ObfsMode + mu sync.RWMutex + + readMu sync.Mutex + readBuf []byte + writeMu sync.Mutex + writeBuf []byte + + closeCh chan struct{} + closeOnce sync.Once +} + +// WrapPacketConnMulti wraps conn so it accepts packets obfuscated with any of +// Salamander or TLS-mimic on the same port. Which method to use for outbound +// packets is auto-detected per remote address on the first inbound packet from +// that address. +// +// Both Salamander and TLS-mimic use the same PSK for the BLAKE2b-XOR cipher. +func WrapPacketConnMulti(conn net.PacketConn, opts MultiObfsOptions) (net.PacketConn, error) { + m := &multiObfsPacketConn{ + inner: conn, + addrModes: make(map[string]ObfsMode), + readBuf: make([]byte, udpBufferSize), + writeBuf: make([]byte, udpBufferSize), + sendJitter: opts.SendJitter, + closeCh: make(chan struct{}), + } + + if opts.Password != "" { + psk := []byte(opts.Password) + var err error + m.sm, err = newSalamanderObfuscator(psk) + if err != nil { + return nil, err + } + m.tm, err = newTLSMimicObfuscator(psk) + if err != nil { + return nil, err + } + } + + if opts.MinPadding > 0 && opts.MaxPadding >= opts.MinPadding { + m.pad = true + m.minP = opts.MinPadding + m.maxP = opts.MaxPadding + } + + return m, nil +} + +// --- Read path: try each deobfuscator in order, cache the method --- + +func (m *multiObfsPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { + for { + select { + case <-m.closeCh: + return 0, nil, net.ErrClosed + default: + } + + m.readMu.Lock() + n, addr, err := m.inner.ReadFrom(m.readBuf) + if err != nil { + m.readMu.Unlock() + return 0, addr, err + } + if n <= 0 { + m.readMu.Unlock() + continue + } + + data := m.readBuf[:n] + var plaintext []byte + var mode ObfsMode + + // 1. Try TLS-mimic (detectable by leading 0x17 on the wire) + if m.tm != nil && n > 0 && data[0] == 0x17 { + buf := make([]byte, udpBufferSize) + nn := m.tm.Deobfuscate(data, buf) + if nn > 0 { + plaintext = buf[:nn] + mode = ObfsModeTLSMimic + } + } + + // 2. Plain QUIC pass-through (QUIC long header: first two bits = 11) + if plaintext == nil && n > 0 && data[0]&0xC0 == 0xC0 { + buf := make([]byte, n) + copy(buf, data) + plaintext = buf + mode = ObfsModePlain + } + + // 3. Try Salamander + if plaintext == nil && m.sm != nil { + buf := make([]byte, udpBufferSize) + nn := m.sm.Deobfuscate(data, buf) + if nn > 0 { + plaintext = buf[:nn] + mode = ObfsModeSalamander + } + } + + // If no method worked, drop the packet. + if plaintext == nil { + m.readMu.Unlock() + continue + } + + // Cache the method for this source address + if addr != nil { + m.mu.Lock() + m.addrModes[addr.String()] = mode + m.mu.Unlock() + } + + nn := copy(p, plaintext) + m.readMu.Unlock() + return nn, addr, nil + } +} + +// --- Write path: use cached method, default to Salamander --- + +func (m *multiObfsPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { + mode := m.lookupMode(addr) + + m.writeMu.Lock() + defer m.writeMu.Unlock() + + var wire []byte + switch mode { + case ObfsModeTLSMimic: + if m.tm == nil { + return 0, net.ErrClosed + } + nn := m.tm.Obfuscate(p, m.writeBuf) + wire = m.writeBuf[:nn] + case ObfsModeSalamander: + if m.sm == nil { + return 0, net.ErrClosed + } + nn := m.sm.Obfuscate(p, m.writeBuf) + wire = m.writeBuf[:nn] + case ObfsModePlain: + wire = p + default: + wire = p + if m.sm != nil { + nn := m.sm.Obfuscate(p, m.writeBuf) + wire = m.writeBuf[:nn] + } + } + + if m.pad { + wire = m.maybePad(wire) + } + + if m.sendJitter > 0 { + time.Sleep(Jitter(m.sendJitter)) + } + + _, err := m.inner.WriteTo(wire, addr) + if err != nil { + return 0, err + } + return len(p), nil +} + +func (m *multiObfsPacketConn) lookupMode(addr net.Addr) ObfsMode { + if addr == nil { + return ObfsModeSalamander + } + m.mu.RLock() + mode, ok := m.addrModes[addr.String()] + m.mu.RUnlock() + if ok { + return mode + } + // Unknown address. For servers, this shouldn't happen in practice + // because the server receives a packet before sending one. + return ObfsModeSalamander +} + +// maybePad appends random padding so the total packet length falls in +// [minP, maxP]. Does nothing if padding is disabled or the packet already +// exceeds maxP. +func (m *multiObfsPacketConn) maybePad(wire []byte) []byte { + base := len(wire) + if base >= m.maxP { + return wire + } + // pick a target between [max(base, minP), maxP] + target := base + if m.maxP > base { + // random in [max(base, minP), maxP] + lo := base + if m.minP > lo { + lo = m.minP + } + if lo <= m.maxP { + target = lo + randIntn(m.maxP-lo+1) + } + } + if target <= base { + return wire + } + out := make([]byte, target) + copy(out, wire) + _, _ = rand.Read(out[base:target]) + return out +} + +// --- net.PacketConn boilerplate --- + +func (m *multiObfsPacketConn) Close() error { + m.closeOnce.Do(func() { close(m.closeCh) }) + return m.inner.Close() +} + +func (m *multiObfsPacketConn) LocalAddr() net.Addr { return m.inner.LocalAddr() } + +func (m *multiObfsPacketConn) SetDeadline(t time.Time) error { return m.inner.SetDeadline(t) } +func (m *multiObfsPacketConn) SetReadDeadline(t time.Time) error { return m.inner.SetReadDeadline(t) } +func (m *multiObfsPacketConn) SetWriteDeadline(t time.Time) error { + return m.inner.SetWriteDeadline(t) +} + +func (m *multiObfsPacketConn) SyscallConn() (syscall.RawConn, error) { + if u, ok := m.inner.(udpLikePacketConn); ok { + return u.SyscallConn() + } + return nil, syscall.EOPNOTSUPP +} + +func (m *multiObfsPacketConn) SetReadBuffer(bytes int) error { + if u, ok := m.inner.(udpLikePacketConn); ok { + return u.SetReadBuffer(bytes) + } + return syscall.EOPNOTSUPP +} + +func (m *multiObfsPacketConn) SetWriteBuffer(bytes int) error { + if u, ok := m.inner.(udpLikePacketConn); ok { + return u.SetWriteBuffer(bytes) + } + return syscall.EOPNOTSUPP +} + +// Jitter returns a random duration in [-maxJitter, +maxJitter]. +// If maxJitter is zero, returns 0 immediately. +func Jitter(maxJitter time.Duration) time.Duration { + if maxJitter <= 0 { + return 0 + } + var b [8]byte + _, _ = rand.Read(b[:]) + f := float64(binary.BigEndian.Uint64(b[:])>>11) / (1 << 53) + j := time.Duration(f * float64(maxJitter*2)) + return j - maxJitter +} diff --git a/extras/obfs/multi_test.go b/extras/obfs/multi_test.go new file mode 100644 index 0000000..19caef8 --- /dev/null +++ b/extras/obfs/multi_test.go @@ -0,0 +1,226 @@ +package obfs + +import ( + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// duplexPipe pairs two channels so that write(0→1) is read by read(1→0). +type duplexPipe struct { + toServer chan pkt + toClient chan pkt + addr net.Addr +} + +type pkt struct { + data []byte + addr net.Addr +} + +func newDuplexPipe() *duplexPipe { + return &duplexPipe{ + toServer: make(chan pkt, 100), + toClient: make(chan pkt, 100), + addr: &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 9999}, + } +} + +func (p *duplexPipe) ReadFromServerSide(b []byte) (int, net.Addr, error) { + pkt := <-p.toServer + return copy(b, pkt.data), pkt.addr, nil +} + +func (p *duplexPipe) WriteToServerSide(b []byte, addr net.Addr) (int, error) { + data := make([]byte, len(b)) + copy(data, b) + p.toClient <- pkt{data: data, addr: addr} + return len(b), nil +} + +func (p *duplexPipe) ReadFromClientSide(b []byte) (int, net.Addr, error) { + pkt := <-p.toClient + return copy(b, pkt.data), pkt.addr, nil +} + +func (p *duplexPipe) WriteToClientSide(b []byte, addr net.Addr) (int, error) { + data := make([]byte, len(b)) + copy(data, b) + p.toServer <- pkt{data: data, addr: addr} + return len(b), nil +} + +// serverConn wraps duplexPipe to look like a single PacketConn from the +// server's perspective: ReadFrom reads client→server, WriteTo writes server→client. +type serverConn struct { + p *duplexPipe +} + +func (s *serverConn) ReadFrom(b []byte) (int, net.Addr, error) { return s.p.ReadFromServerSide(b) } +func (s *serverConn) WriteTo(b []byte, a net.Addr) (int, error) { return s.p.WriteToServerSide(b, a) } +func (s *serverConn) Close() error { return nil } +func (s *serverConn) LocalAddr() net.Addr { return s.p.addr } +func (s *serverConn) SetDeadline(time.Time) error { return nil } +func (s *serverConn) SetReadDeadline(time.Time) error { return nil } +func (s *serverConn) SetWriteDeadline(time.Time) error { return nil } + +// clientConn wraps duplexPipe from the client perspective. +type clientConn struct { + p *duplexPipe +} + +func (c *clientConn) ReadFrom(b []byte) (int, net.Addr, error) { return c.p.ReadFromClientSide(b) } +func (c *clientConn) WriteTo(b []byte, a net.Addr) (int, error) { return c.p.WriteToClientSide(b, a) } +func (c *clientConn) Close() error { return nil } +func (c *clientConn) LocalAddr() net.Addr { return c.p.addr } +func (c *clientConn) SetDeadline(time.Time) error { return nil } +func (c *clientConn) SetReadDeadline(time.Time) error { return nil } +func (c *clientConn) SetWriteDeadline(time.Time) error { return nil } + +func TestMultiObfsRoundTripSalamander(t *testing.T) { + d := newDuplexPipe() + server, err := WrapPacketConnMulti(&serverConn{d}, MultiObfsOptions{ + Password: "test", + }) + require.NoError(t, err) + defer server.Close() + + client, err := WrapPacketConnSalamander(&clientConn{d}, []byte("test")) + require.NoError(t, err) + + payload := []byte("hello salamander via multi") + n, err := client.WriteTo(payload, d.addr) + require.NoError(t, err) + require.Equal(t, len(payload), n) + + buf := make([]byte, 2048) + n, addr, err := server.ReadFrom(buf) + require.NoError(t, err) + assert.Equal(t, payload, buf[:n]) + + _, err = server.WriteTo(buf[:n], addr) + require.NoError(t, err) + + buf2 := make([]byte, 2048) + n, _, err = client.ReadFrom(buf2) + require.NoError(t, err) + assert.Equal(t, payload, buf2[:n]) +} + +func TestMultiObfsRoundTripTLSMimic(t *testing.T) { + d := newDuplexPipe() + server, err := WrapPacketConnMulti(&serverConn{d}, MultiObfsOptions{ + Password: "test", + }) + require.NoError(t, err) + defer server.Close() + + client, err := WrapPacketConnTLSMimic(&clientConn{d}, []byte("test")) + require.NoError(t, err) + + payload := []byte("hello tlsmimic via multi") + n, err := client.WriteTo(payload, d.addr) + require.NoError(t, err) + require.Equal(t, len(payload), n) + + buf := make([]byte, 2048) + n, addr, err := server.ReadFrom(buf) + require.NoError(t, err) + assert.Equal(t, payload, buf[:n]) + + _, err = server.WriteTo(buf[:n], addr) + require.NoError(t, err) + + buf2 := make([]byte, 2048) + n, _, err = client.ReadFrom(buf2) + require.NoError(t, err) + assert.Equal(t, payload, buf2[:n]) +} + +func TestMultiObfsRoundTripBoth(t *testing.T) { + d := newDuplexPipe() + server, err := WrapPacketConnMulti(&serverConn{d}, MultiObfsOptions{ + Password: "test", + }) + require.NoError(t, err) + defer server.Close() + + payload1 := []byte("from salamander") + payload2 := []byte("from tlsmimic") + + // Write obfuscated data directly to the server-side pipe channel + // using pre-computed obfuscated payloads. + sm, _ := newSalamanderObfuscator([]byte("test")) + tm, _ := newTLSMimicObfuscator([]byte("test")) + + smOut := make([]byte, 2048) + tmOut := make([]byte, 2048) + smN := sm.Obfuscate(payload1, smOut) + tmN := tm.Obfuscate(payload2, tmOut) + + // Both "clients" send through the shared pipe's client→server channel + d.toServer <- pkt{data: smOut[:smN], addr: &net.UDPAddr{IP: net.IPv4(10, 0, 0, 1), Port: 10001}} + d.toServer <- pkt{data: tmOut[:tmN], addr: &net.UDPAddr{IP: net.IPv4(10, 0, 0, 2), Port: 10002}} + + buf := make([]byte, 2048) + n, addr1, err := server.ReadFrom(buf) + require.NoError(t, err) + assert.Equal(t, payload1, buf[:n]) + + n, addr2, err := server.ReadFrom(buf) + require.NoError(t, err) + assert.Equal(t, payload2, buf[:n]) + + // Server responds to each with the correct method (auto-detected) + _, err = server.WriteTo([]byte("reply1"), addr1) + require.NoError(t, err) + _, err = server.WriteTo([]byte("reply2"), addr2) + require.NoError(t, err) + + // Read server responses from client side + rBuf := make([]byte, 2048) + n, _, err = d.ReadFromClientSide(rBuf) + require.NoError(t, err) + // First response to Salamander client + out := make([]byte, 2048) + nn := sm.Deobfuscate(rBuf[:n], out) + assert.Equal(t, []byte("reply1"), out[:nn]) + + n, _, err = d.ReadFromClientSide(rBuf) + require.NoError(t, err) + // Second response to TLS-mimic client + nn = tm.Deobfuscate(rBuf[:n], out) + assert.Equal(t, []byte("reply2"), out[:nn]) +} + +func TestMultiObfsRequiresPasswordForObfs(t *testing.T) { + d := newDuplexPipe() + _, err := WrapPacketConnMulti(&serverConn{d}, MultiObfsOptions{ + Password: "", + }) + assert.NoError(t, err) +} + +func TestMultiObfsRandomPadding(t *testing.T) { + d := newDuplexPipe() + server, err := WrapPacketConnMulti(&serverConn{d}, MultiObfsOptions{ + Password: "test", + MinPadding: 1000, + MaxPadding: 1200, + }) + require.NoError(t, err) + defer server.Close() + + _, err = server.WriteTo([]byte("small"), d.addr) + require.NoError(t, err) + + // Read raw from client side to check padding + buf := make([]byte, 2048) + n, _, err := d.ReadFromClientSide(buf) + require.NoError(t, err) + assert.GreaterOrEqual(t, n, 1000) + assert.LessOrEqual(t, n, 1200) +} diff --git a/extras/obfs/tlsmimic.go b/extras/obfs/tlsmimic.go new file mode 100644 index 0000000..329a7b9 --- /dev/null +++ b/extras/obfs/tlsmimic.go @@ -0,0 +1,118 @@ +package obfs + +import ( + "encoding/binary" + "fmt" + "math/rand" + "net" + "sync" + "time" + + "golang.org/x/crypto/blake2b" +) + +const ( + tmPSKMinLen = 4 + tmSaltLen = 8 + tmKeyLen = blake2b.Size256 + tmHeaderSize = 5 // TLS record header: content type(1) + version(2) + length(2) + tmContentTypeData = 0x17 + tmVersionMajor = 0x03 + tmVersionMinor = 0x03 // TLS 1.2 wire version (used in TLS 1.3 too) +) + +var errTLSMimicPSKTooShort = fmt.Errorf("TLS mimic PSK must be at least %d bytes", tmPSKMinLen) + +// tlsMimicObfuscator encrypts each packet with BLAKE2b-256(PSK || salt) XOR +// and wraps the result in a TLS 1.3 Application Data record header. +// Wire format: [TLS record header (5 bytes)][salt (8 bytes)][XOR'd payload] +type tlsMimicObfuscator struct { + PSK []byte + RandSrc *rand.Rand + + lk sync.Mutex + keyInput []byte +} + +func newTLSMimicObfuscator(psk []byte) (*tlsMimicObfuscator, error) { + if len(psk) < tmPSKMinLen { + return nil, errTLSMimicPSKTooShort + } + pskCopy := append([]byte(nil), psk...) + keyInput := make([]byte, len(pskCopy)+tmSaltLen) + copy(keyInput, pskCopy) + return &tlsMimicObfuscator{ + PSK: pskCopy, + RandSrc: rand.New(rand.NewSource(time.Now().UnixNano())), + keyInput: keyInput, + }, nil +} + +// WrapPacketConnTLSMimic wraps conn with TLS 1.3 mimicry obfuscation. +// Every outbound packet is encrypted with BLAKE2b-256(PSK || random salt) XOR +// and wrapped in a TLS record header, making the traffic appear as TLS. +func WrapPacketConnTLSMimic(conn net.PacketConn, psk []byte) (net.PacketConn, error) { + ob, err := newTLSMimicObfuscator(psk) + if err != nil { + return nil, err + } + return wrapPacketConn(conn, ob), nil +} + +func (o *tlsMimicObfuscator) Obfuscate(in, out []byte) int { + payloadLen := tmSaltLen + len(in) + outLen := tmHeaderSize + payloadLen + if len(out) < outLen { + return 0 + } + // Generate random salt into position after TLS header + o.lk.Lock() + _, _ = o.RandSrc.Read(out[tmHeaderSize : tmHeaderSize+tmSaltLen]) + key := o.keyLocked(out[tmHeaderSize : tmHeaderSize+tmSaltLen]) + o.lk.Unlock() + // XOR encrypt payload + for i, c := range in { + out[tmHeaderSize+tmSaltLen+i] = c ^ key[i%tmKeyLen] + } + // TLS record header + out[0] = tmContentTypeData + out[1] = tmVersionMajor + out[2] = tmVersionMinor + binary.BigEndian.PutUint16(out[3:5], uint16(payloadLen)) + return outLen +} + +func (o *tlsMimicObfuscator) Deobfuscate(in, out []byte) int { + if len(in) < tmHeaderSize+tmSaltLen { + return 0 + } + // Validate TLS record header + if in[0] != tmContentTypeData { + return 0 + } + if in[1] != tmVersionMajor || in[2] != tmVersionMinor { + return 0 + } + payloadLen := int(binary.BigEndian.Uint16(in[3:5])) + if payloadLen < tmSaltLen || tmHeaderSize+payloadLen > len(in) { + return 0 + } + salt := in[tmHeaderSize : tmHeaderSize+tmSaltLen] + ciphertext := in[tmHeaderSize+tmSaltLen : tmHeaderSize+payloadLen] + o.lk.Lock() + key := o.keyLocked(salt) + o.lk.Unlock() + outLen := len(ciphertext) + if len(out) < outLen { + return 0 + } + for i, c := range ciphertext { + out[i] = c ^ key[i%tmKeyLen] + } + return outLen +} + +func (o *tlsMimicObfuscator) keyLocked(salt []byte) [tmKeyLen]byte { + copy(o.keyInput[len(o.PSK):], salt[:tmSaltLen]) + return blake2b.Sum256(o.keyInput) +} diff --git a/extras/obfs/tlsmimic_test.go b/extras/obfs/tlsmimic_test.go new file mode 100644 index 0000000..a407b7b --- /dev/null +++ b/extras/obfs/tlsmimic_test.go @@ -0,0 +1,81 @@ +package obfs + +import ( + "crypto/rand" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTLSMimicObfuscator(t *testing.T) { + o, err := newTLSMimicObfuscator([]byte("average_password")) + require.NoError(t, err) + 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) + assert.Equal(t, tmHeaderSize+tmSaltLen+len(in), n) + // Verify TLS header + assert.Equal(t, byte(tmContentTypeData), oOut[0]) + assert.Equal(t, byte(tmVersionMajor), oOut[1]) + assert.Equal(t, byte(tmVersionMinor), oOut[2]) + // Decrypt + n = o.Deobfuscate(oOut[:n], dOut) + assert.Equal(t, len(in), n) + assert.Equal(t, in, dOut[:n]) + } +} + +func TestTLSMimicPSKTooShort(t *testing.T) { + _, err := newTLSMimicObfuscator([]byte("12")) + assert.ErrorIs(t, err, errTLSMimicPSKTooShort) +} + +func TestTLSMimicRoundTripSmall(t *testing.T) { + o, _ := newTLSMimicObfuscator([]byte("my_psk")) + in := []byte("hello") + oOut := make([]byte, 2048) + dOut := make([]byte, 2048) + n := o.Obfuscate(in, oOut) + assert.Equal(t, tmHeaderSize+tmSaltLen+len(in), n) + n = o.Deobfuscate(oOut[:n], dOut) + assert.Equal(t, len(in), n) + assert.Equal(t, in, dOut[:n]) +} + +func TestTLSMimicRejectInvalidHeader(t *testing.T) { + o, _ := newTLSMimicObfuscator([]byte("my_psk")) + dOut := make([]byte, 2048) + // Wrong content type + buf := make([]byte, tmHeaderSize+tmSaltLen+10) + n := o.Deobfuscate(buf, dOut) + assert.Equal(t, 0, n) + // Too short + n = o.Deobfuscate(buf[:3], dOut) + assert.Equal(t, 0, n) +} + +func BenchmarkTLSMimicObfuscator_Obfuscate(b *testing.B) { + o, _ := newTLSMimicObfuscator([]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 BenchmarkTLSMimicObfuscator_Deobfuscate(b *testing.B) { + o, _ := newTLSMimicObfuscator([]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) + } +} diff --git a/extras/outbounds/ob_hysteria.go b/extras/outbounds/ob_hysteria.go index e3fdef8..be7599b 100644 --- a/extras/outbounds/ob_hysteria.go +++ b/extras/outbounds/ob_hysteria.go @@ -305,6 +305,8 @@ func wrapObfs(conn net.PacketConn, obfsType, password string, minPacketSize, max MinPacketSize: minPacketSize, MaxPacketSize: maxPacketSize, }) + case "tlsmimic": + return obfs.WrapPacketConnTLSMimic(conn, []byte(password)) default: return nil, errors.New("unsupported obfuscation type: " + obfsType) }