feat: TLS 1.3 mimicry obfuscation, multi-mode (auto + plain), session recreation
Some checks are pending
Build master branch / build (push) Waiting to run
Tests / Test (push) Waiting to run

- tlsmimic: wrap QUIC packets in TLS 1.3 record headers
- multi: auto-detect Salamander/TLS-mimic/plain on the same port
- multi: random padding and jitter support for DPI evasion
- reconnect: periodic session recreation via MaxSessionDuration
- client/server: wire tlsmimic/auto/plain obfs types and config
- NEW_FEATURES: translated to English
This commit is contained in:
Niko Marmeladkov 2026-06-17 16:36:29 +03:00
parent da8366eb8a
commit 1e77bbe7b7
Signed by untrusted user who does not match committer: Niko
GPG key ID: E3B955F9442D44E3
9 changed files with 924 additions and 72 deletions

View file

@ -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`.

View file

@ -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))

View file

@ -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,

View file

@ -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
}

303
extras/obfs/multi.go Normal file
View file

@ -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
}

226
extras/obfs/multi_test.go Normal file
View file

@ -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)
}

118
extras/obfs/tlsmimic.go Normal file
View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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)
}