Update On Tue Mar 10 20:02:08 CET 2026

This commit is contained in:
github-action[bot]
2026-03-10 20:02:08 +01:00
parent f74ef888c9
commit b129b604f6
118 changed files with 8763 additions and 4236 deletions
+1
View File
@@ -1294,3 +1294,4 @@ Update On Fri Mar 6 20:05:10 CET 2026
Update On Sat Mar 7 19:46:34 CET 2026
Update On Sun Mar 8 19:47:17 CET 2026
Update On Mon Mar 9 20:06:07 CET 2026
Update On Tue Mar 10 20:01:59 CET 2026
+10 -10
View File
@@ -27,8 +27,7 @@ type Trojan struct {
hexPassword [trojan.KeyLength]byte
// for gun mux
gunConfig *gun.Config
gunTransport *gun.TransportWrap
gunTransport *gun.Transport
realityConfig *tlsC.RealityConfig
echConfig *ech.Config
@@ -178,7 +177,7 @@ func (t *Trojan) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Con
var c net.Conn
// gun transport
if t.gunTransport != nil {
c, err = gun.StreamGunWithTransport(t.gunTransport, t.gunConfig)
c, err = t.gunTransport.Dial()
} else {
c, err = t.dialer.DialContext(ctx, "tcp", t.addr)
}
@@ -206,7 +205,7 @@ func (t *Trojan) ListenPacketContext(ctx context.Context, metadata *C.Metadata)
var c net.Conn
// grpc transport
if t.gunTransport != nil {
c, err = gun.StreamGunWithTransport(t.gunTransport, t.gunConfig)
c, err = t.gunTransport.Dial()
} else {
c, err = t.dialer.DialContext(ctx, "tcp", t.addr)
}
@@ -317,13 +316,14 @@ func NewTrojan(option TrojanOption) (*Trojan, error) {
Reality: t.realityConfig,
}
t.gunTransport = gun.NewHTTP2Client(dialFn, tlsConfig)
t.gunConfig = &gun.Config{
ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.SNI,
gunConfig := &gun.Config{
ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.SNI,
PingInterval: option.GrpcOpts.PingInterval,
}
t.gunTransport = gun.NewTransport(dialFn, tlsConfig, gunConfig)
}
return t, nil
+8 -10
View File
@@ -33,8 +33,7 @@ type Vless struct {
encryption *encryption.ClientInstance
// for gun mux
gunConfig *gun.Config
gunTransport *gun.TransportWrap
gunTransport *gun.Transport
realityConfig *tlsC.RealityConfig
echConfig *ech.Config
@@ -234,7 +233,7 @@ func (v *Vless) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn
var c net.Conn
// gun transport
if v.gunTransport != nil {
c, err = gun.StreamGunWithTransport(v.gunTransport, v.gunConfig)
c, err = v.gunTransport.Dial()
} else {
c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
}
@@ -260,7 +259,7 @@ func (v *Vless) ListenPacketContext(ctx context.Context, metadata *C.Metadata) (
var c net.Conn
// gun transport
if v.gunTransport != nil {
c, err = gun.StreamGunWithTransport(v.gunTransport, v.gunConfig)
c, err = v.gunTransport.Dial()
} else {
c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
}
@@ -431,9 +430,10 @@ func NewVless(option VlessOption) (*Vless, error) {
}
gunConfig := &gun.Config{
ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.ServerName,
ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.ServerName,
PingInterval: option.GrpcOpts.PingInterval,
}
if option.ServerName == "" {
gunConfig.Host = v.addr
@@ -457,9 +457,7 @@ func NewVless(option VlessOption) (*Vless, error) {
}
}
v.gunConfig = gunConfig
v.gunTransport = gun.NewHTTP2Client(dialFn, tlsConfig)
v.gunTransport = gun.NewTransport(dialFn, tlsConfig, gunConfig)
}
return v, nil
+9 -10
View File
@@ -34,8 +34,7 @@ type Vmess struct {
option *VmessOption
// for gun mux
gunConfig *gun.Config
gunTransport *gun.TransportWrap
gunTransport *gun.Transport
realityConfig *tlsC.RealityConfig
echConfig *ech.Config
@@ -86,6 +85,7 @@ type HTTP2Options struct {
type GrpcOptions struct {
GrpcServiceName string `proxy:"grpc-service-name,omitempty"`
GrpcUserAgent string `proxy:"grpc-user-agent,omitempty"`
PingInterval int `proxy:"ping-interval,omitempty"`
}
type WSOptions struct {
@@ -295,7 +295,7 @@ func (v *Vmess) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn
var c net.Conn
// gun transport
if v.gunTransport != nil {
c, err = gun.StreamGunWithTransport(v.gunTransport, v.gunConfig)
c, err = v.gunTransport.Dial()
} else {
c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
}
@@ -318,7 +318,7 @@ func (v *Vmess) ListenPacketContext(ctx context.Context, metadata *C.Metadata) (
var c net.Conn
// gun transport
if v.gunTransport != nil {
c, err = gun.StreamGunWithTransport(v.gunTransport, v.gunConfig)
c, err = v.gunTransport.Dial()
} else {
c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
}
@@ -437,9 +437,10 @@ func NewVmess(option VmessOption) (*Vmess, error) {
}
gunConfig := &gun.Config{
ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.ServerName,
ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.ServerName,
PingInterval: option.GrpcOpts.PingInterval,
}
if option.ServerName == "" {
gunConfig.Host = v.addr
@@ -463,9 +464,7 @@ func NewVmess(option VmessOption) (*Vmess, error) {
}
}
v.gunConfig = gunConfig
v.gunTransport = gun.NewHTTP2Client(dialFn, tlsConfig)
v.gunTransport = gun.NewTransport(dialFn, tlsConfig, gunConfig)
}
return v, nil
+2 -2
View File
@@ -18,8 +18,8 @@ var (
type healthCheckSchema struct {
Enable bool `provider:"enable"`
URL string `provider:"url"`
Interval int `provider:"interval"`
URL string `provider:"url,omitempty"`
Interval int `provider:"interval,omitempty"`
TestTimeout int `provider:"timeout,omitempty"`
Lazy bool `provider:"lazy,omitempty"`
ExpectedStatus string `provider:"expected-status,omitempty"`
+3
View File
@@ -669,6 +669,7 @@ proxies: # socks5
grpc-opts:
grpc-service-name: "example"
# grpc-user-agent: "grpc-go/1.36.0"
# ping-interval: 0 # 默认关闭,单位为秒
# ip-version: ipv4
# vless
@@ -759,6 +760,7 @@ proxies: # socks5
grpc-opts:
grpc-service-name: "grpc"
# grpc-user-agent: "grpc-go/1.36.0"
# ping-interval: 0 # 默认关闭,单位为秒
reality-opts:
public-key: CrrQSjAG_YkHLwvM2M-7XkKJilgL5upBKCp0od0tLhE
@@ -830,6 +832,7 @@ proxies: # socks5
grpc-opts:
grpc-service-name: "example"
# grpc-user-agent: "grpc-go/1.36.0"
# ping-interval: 0 # 默认关闭,单位为秒
- name: trojan-ws
server: server
+23 -20
View File
@@ -59,9 +59,10 @@ type Conn struct {
}
type Config struct {
ServiceName string
UserAgent string
Host string
ServiceName string
UserAgent string
Host string
PingInterval int
}
func (g *Conn) initReader() {
@@ -246,7 +247,7 @@ func (g *Conn) SetDeadline(t time.Time) error {
return nil
}
func NewHTTP2Client(dialFn DialFn, tlsConfig *vmess.TLSConfig) *TransportWrap {
func NewTransport(dialFn DialFn, tlsConfig *vmess.TLSConfig, gunCfg *Config) *Transport {
dialFunc := func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
ctx, cancel := context.WithTimeout(ctx, C.DefaultTLSTimeout)
defer cancel()
@@ -288,14 +289,16 @@ func NewHTTP2Client(dialFn DialFn, tlsConfig *vmess.TLSConfig) *TransportWrap {
DialTLSContext: dialFunc,
AllowHTTP: false,
DisableCompression: true,
ReadIdleTimeout: time.Duration(gunCfg.PingInterval) * time.Second, // If zero, no health check is performed
PingTimeout: 0,
}
ctx, cancel := context.WithCancel(context.Background())
wrap := &TransportWrap{
Http2Transport: transport,
ctx: ctx,
cancel: cancel,
wrap := &Transport{
transport: transport,
cfg: gunCfg,
ctx: ctx,
cancel: cancel,
}
return wrap
}
@@ -307,18 +310,18 @@ func ServiceNameToPath(serviceName string) string {
return "/" + serviceName + "/Tun"
}
func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, error) {
func (t *Transport) Dial() (net.Conn, error) {
serviceName := "GunService"
if cfg.ServiceName != "" {
serviceName = cfg.ServiceName
if t.cfg.ServiceName != "" {
serviceName = t.cfg.ServiceName
}
path := ServiceNameToPath(serviceName)
reader, writer := io.Pipe()
header := defaultHeader.Clone()
if cfg.UserAgent != "" {
header.Set("User-Agent", cfg.UserAgent)
if t.cfg.UserAgent != "" {
header.Set("User-Agent", t.cfg.UserAgent)
}
request := &http.Request{
@@ -326,17 +329,17 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er
Body: reader,
URL: &url.URL{
Scheme: "https",
Host: cfg.Host,
Host: t.cfg.Host,
Path: path,
// for unescape path
Opaque: "//" + cfg.Host + path,
Opaque: "//" + t.cfg.Host + path,
},
Proto: "HTTP/2",
ProtoMajor: 2,
ProtoMinor: 0,
Header: header,
}
request = request.WithContext(transport.ctx)
request = request.WithContext(t.ctx)
conn := &Conn{
initFn: func() (io.ReadCloser, NetAddr, error) {
@@ -348,7 +351,7 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er
},
}
request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace))
response, err := transport.RoundTrip(request)
response, err := t.transport.RoundTrip(request)
if err != nil {
return nil, nAddr, err
}
@@ -361,13 +364,13 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er
return conn, nil
}
func StreamGunWithConn(conn net.Conn, tlsConfig *vmess.TLSConfig, cfg *Config) (net.Conn, error) {
func StreamGunWithConn(conn net.Conn, tlsConfig *vmess.TLSConfig, gunCfg *Config) (net.Conn, error) {
dialFn := func(ctx context.Context, network, addr string) (net.Conn, error) {
return conn, nil
}
transport := NewHTTP2Client(dialFn, tlsConfig)
c, err := StreamGunWithTransport(transport, cfg)
transport := NewTransport(dialFn, tlsConfig, gunCfg)
c, err := transport.Dial()
if err != nil {
return nil, err
}
+7 -6
View File
@@ -10,17 +10,18 @@ import (
"github.com/metacubex/http"
)
type TransportWrap struct {
*http.Http2Transport
type Transport struct {
transport *http.Http2Transport
cfg *Config
ctx context.Context
cancel context.CancelFunc
closeOnce sync.Once
}
func (tw *TransportWrap) Close() error {
tw.closeOnce.Do(func() {
tw.cancel()
CloseTransport(tw.Http2Transport)
func (t *Transport) Close() error {
t.closeOnce.Do(func() {
t.cancel()
CloseHttp2Transport(t.transport)
})
return nil
}
+1 -1
View File
@@ -44,7 +44,7 @@ func closeClientConn(cc *http.Http2ClientConn) { // like forceCloseConn() in htt
_ = cc.Close()
}
func CloseTransport(tr *http.Http2Transport) {
func CloseHttp2Transport(tr *http.Http2Transport) {
connPool := transportConnPool(tr)
p := (*clientConnPool)((*efaceWords)(unsafe.Pointer(&connPool)).data)
p.mu.Lock()
+115 -37
View File
@@ -5,6 +5,7 @@ import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
@@ -12,6 +13,7 @@ import (
"io"
"net"
"sync"
"sync/atomic"
"golang.org/x/crypto/chacha20poly1305"
)
@@ -55,13 +57,15 @@ type RecordConn struct {
recvAEADEpoch uint32
// Send direction state.
sendEpoch uint32
sendSeq uint64
sendBytes int64
sendEpoch uint32
sendSeq uint64
sendBytes int64
sendEpochUpdates uint32
// Receive direction state.
recvEpoch uint32
recvSeq uint64
recvEpoch uint32
recvSeq uint64
recvInitialized bool
readBuf bytes.Buffer
@@ -105,6 +109,9 @@ func NewRecordConn(conn net.Conn, method string, baseSend, baseRecv []byte) (*Re
}
rc := &RecordConn{Conn: conn, method: method}
rc.keys = recordKeys{baseSend: cloneBytes(baseSend), baseRecv: cloneBytes(baseRecv)}
if err := rc.resetTrafficState(); err != nil {
return nil, err
}
return rc, nil
}
@@ -127,11 +134,9 @@ func (c *RecordConn) Rekey(baseSend, baseRecv []byte) error {
defer c.writeMu.Unlock()
c.keys = recordKeys{baseSend: cloneBytes(baseSend), baseRecv: cloneBytes(baseRecv)}
c.sendEpoch = 0
c.sendSeq = 0
c.sendBytes = 0
c.recvEpoch = 0
c.recvSeq = 0
if err := c.resetTrafficState(); err != nil {
return err
}
c.readBuf.Reset()
c.sendAEAD = nil
@@ -141,6 +146,21 @@ func (c *RecordConn) Rekey(baseSend, baseRecv []byte) error {
return nil
}
func (c *RecordConn) resetTrafficState() error {
sendEpoch, sendSeq, err := randomRecordCounters()
if err != nil {
return fmt.Errorf("initialize record counters: %w", err)
}
c.sendEpoch = sendEpoch
c.sendSeq = sendSeq
c.sendBytes = 0
c.sendEpochUpdates = 0
c.recvEpoch = 0
c.recvSeq = 0
c.recvInitialized = false
return nil
}
func normalizeAEADMethod(method string) string {
switch method {
case "", "chacha20-poly1305":
@@ -166,6 +186,44 @@ func cloneBytes(b []byte) []byte {
return append([]byte(nil), b...)
}
func randomRecordCounters() (uint32, uint64, error) {
epoch, err := randomNonZeroUint32()
if err != nil {
return 0, 0, err
}
seq, err := randomNonZeroUint64()
if err != nil {
return 0, 0, err
}
return epoch, seq, nil
}
func randomNonZeroUint32() (uint32, error) {
var b [4]byte
for {
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
return 0, err
}
v := binary.BigEndian.Uint32(b[:])
if v != 0 && v != ^uint32(0) {
return v, nil
}
}
}
func randomNonZeroUint64() (uint64, error) {
var b [8]byte
for {
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
return 0, err
}
v := binary.BigEndian.Uint64(b[:])
if v != 0 && v != ^uint64(0) {
return v, nil
}
}
}
func (c *RecordConn) newAEADFor(base []byte, epoch uint32) (cipher.AEAD, error) {
if c.method == "none" {
return nil, nil
@@ -209,17 +267,49 @@ func deriveEpochKey(base []byte, epoch uint32, method string) []byte {
return mac.Sum(nil)
}
func (c *RecordConn) maybeBumpSendEpochLocked(addedPlain int) {
if KeyUpdateAfterBytes <= 0 || c.method == "none" {
return
func (c *RecordConn) maybeBumpSendEpochLocked(addedPlain int) error {
ku := atomic.LoadInt64(&KeyUpdateAfterBytes)
if ku <= 0 || c.method == "none" {
return nil
}
c.sendBytes += int64(addedPlain)
threshold := KeyUpdateAfterBytes * int64(c.sendEpoch+1)
threshold := ku * int64(c.sendEpochUpdates+1)
if c.sendBytes < threshold {
return
return nil
}
c.sendEpoch++
c.sendSeq = 0
c.sendEpochUpdates++
nextSeq, err := randomNonZeroUint64()
if err != nil {
return fmt.Errorf("rotate record seq: %w", err)
}
c.sendSeq = nextSeq
return nil
}
func (c *RecordConn) validateRecvPosition(epoch uint32, seq uint64) error {
if !c.recvInitialized {
return nil
}
if epoch < c.recvEpoch {
return fmt.Errorf("replayed epoch: got %d want >=%d", epoch, c.recvEpoch)
}
if epoch == c.recvEpoch && seq != c.recvSeq {
return fmt.Errorf("out of order: epoch=%d got=%d want=%d", epoch, seq, c.recvSeq)
}
if epoch > c.recvEpoch {
const maxJump = 8
if epoch-c.recvEpoch > maxJump {
return fmt.Errorf("epoch jump too large: got=%d want<=%d", epoch-c.recvEpoch, maxJump)
}
}
return nil
}
func (c *RecordConn) markRecvPosition(epoch uint32, seq uint64) {
c.recvEpoch = epoch
c.recvSeq = seq + 1
c.recvInitialized = true
}
func (c *RecordConn) Write(p []byte) (int, error) {
@@ -282,7 +372,9 @@ func (c *RecordConn) Write(p []byte) (int, error) {
}
total += n
c.maybeBumpSendEpochLocked(n)
if err := c.maybeBumpSendEpochLocked(n); err != nil {
return total, err
}
}
return total, nil
}
@@ -324,31 +416,17 @@ func (c *RecordConn) Read(p []byte) (int, error) {
epoch := binary.BigEndian.Uint32(header[:4])
seq := binary.BigEndian.Uint64(header[4:])
if epoch < c.recvEpoch {
return 0, fmt.Errorf("replayed epoch: got %d want >=%d", epoch, c.recvEpoch)
}
if epoch == c.recvEpoch && seq != c.recvSeq {
return 0, fmt.Errorf("out of order: epoch=%d got=%d want=%d", epoch, seq, c.recvSeq)
}
if epoch > c.recvEpoch {
const maxJump = 8
if epoch-c.recvEpoch > maxJump {
return 0, fmt.Errorf("epoch jump too large: got=%d want<=%d", epoch-c.recvEpoch, maxJump)
}
c.recvEpoch = epoch
c.recvSeq = 0
if seq != 0 {
return 0, fmt.Errorf("out of order: epoch advanced to %d but seq=%d", epoch, seq)
}
if err := c.validateRecvPosition(epoch, seq); err != nil {
return 0, err
}
if c.recvAEAD == nil || c.recvAEADEpoch != c.recvEpoch {
a, err := c.newAEADFor(c.keys.baseRecv, c.recvEpoch)
if c.recvAEAD == nil || c.recvAEADEpoch != epoch {
a, err := c.newAEADFor(c.keys.baseRecv, epoch)
if err != nil {
return 0, err
}
c.recvAEAD = a
c.recvAEADEpoch = c.recvEpoch
c.recvAEADEpoch = epoch
}
aead := c.recvAEAD
@@ -356,7 +434,7 @@ func (c *RecordConn) Read(p []byte) (int, error) {
if err != nil {
return 0, fmt.Errorf("decryption failed: epoch=%d seq=%d: %w", epoch, seq, err)
}
c.recvSeq++
c.markRecvPosition(epoch, seq)
c.readBuf.Write(plaintext)
return c.readBuf.Read(p)
@@ -0,0 +1,86 @@
package crypto
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"io"
"net"
"testing"
"time"
)
type captureConn struct {
bytes.Buffer
}
func (c *captureConn) Read(_ []byte) (int, error) { return 0, io.EOF }
func (c *captureConn) Write(p []byte) (int, error) { return c.Buffer.Write(p) }
func (c *captureConn) Close() error { return nil }
func (c *captureConn) LocalAddr() net.Addr { return nil }
func (c *captureConn) RemoteAddr() net.Addr { return nil }
func (c *captureConn) SetDeadline(time.Time) error { return nil }
func (c *captureConn) SetReadDeadline(time.Time) error { return nil }
func (c *captureConn) SetWriteDeadline(time.Time) error { return nil }
type replayConn struct {
reader *bytes.Reader
}
func (c *replayConn) Read(p []byte) (int, error) { return c.reader.Read(p) }
func (c *replayConn) Write(p []byte) (int, error) { return len(p), nil }
func (c *replayConn) Close() error { return nil }
func (c *replayConn) LocalAddr() net.Addr { return nil }
func (c *replayConn) RemoteAddr() net.Addr { return nil }
func (c *replayConn) SetDeadline(time.Time) error { return nil }
func (c *replayConn) SetReadDeadline(time.Time) error { return nil }
func (c *replayConn) SetWriteDeadline(time.Time) error { return nil }
func TestRecordConn_FirstFrameUsesRandomizedCounters(t *testing.T) {
pskSend := sha256.Sum256([]byte("record-send"))
pskRecv := sha256.Sum256([]byte("record-recv"))
raw := &captureConn{}
writer, err := NewRecordConn(raw, "chacha20-poly1305", pskSend[:], pskRecv[:])
if err != nil {
t.Fatalf("new writer: %v", err)
}
if writer.sendEpoch == 0 || writer.sendSeq == 0 {
t.Fatalf("expected non-zero randomized counters, got epoch=%d seq=%d", writer.sendEpoch, writer.sendSeq)
}
want := []byte("record prefix camouflage")
if _, err := writer.Write(want); err != nil {
t.Fatalf("write: %v", err)
}
wire := raw.Bytes()
if len(wire) < 2+recordHeaderSize {
t.Fatalf("short frame: %d", len(wire))
}
bodyLen := int(binary.BigEndian.Uint16(wire[:2]))
if bodyLen != len(wire)-2 {
t.Fatalf("body len mismatch: got %d want %d", bodyLen, len(wire)-2)
}
epoch := binary.BigEndian.Uint32(wire[2:6])
seq := binary.BigEndian.Uint64(wire[6:14])
if epoch == 0 || seq == 0 {
t.Fatalf("wire header still starts from zero: epoch=%d seq=%d", epoch, seq)
}
reader, err := NewRecordConn(&replayConn{reader: bytes.NewReader(wire)}, "chacha20-poly1305", pskRecv[:], pskSend[:])
if err != nil {
t.Fatalf("new reader: %v", err)
}
got := make([]byte, len(want))
if _, err := io.ReadFull(reader, got); err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("plaintext mismatch: got %q want %q", got, want)
}
}
@@ -0,0 +1,345 @@
package sudoku
import (
"bytes"
"crypto/ecdh"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"time"
"github.com/metacubex/mihomo/transport/sudoku/crypto"
httpmaskobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/httpmask"
sudokuobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku"
)
const earlyKIPHandshakeTTL = 60 * time.Second
type EarlyCodecConfig struct {
PSK string
AEAD string
EnablePureDownlink bool
PaddingMin int
PaddingMax int
}
type EarlyClientState struct {
RequestPayload []byte
cfg EarlyCodecConfig
table *sudokuobfs.Table
nonce [kipHelloNonceSize]byte
ephemeral *ecdh.PrivateKey
sessionC2S []byte
sessionS2C []byte
responseSet bool
}
type EarlyServerState struct {
ResponsePayload []byte
UserHash string
cfg EarlyCodecConfig
table *sudokuobfs.Table
sessionC2S []byte
sessionS2C []byte
}
type ReplayAllowFunc func(userHash string, nonce [kipHelloNonceSize]byte, now time.Time) bool
type earlyMemoryConn struct {
reader *bytes.Reader
write bytes.Buffer
}
func newEarlyMemoryConn(readBuf []byte) *earlyMemoryConn {
return &earlyMemoryConn{reader: bytes.NewReader(readBuf)}
}
func (c *earlyMemoryConn) Read(p []byte) (int, error) {
if c == nil || c.reader == nil {
return 0, net.ErrClosed
}
return c.reader.Read(p)
}
func (c *earlyMemoryConn) Write(p []byte) (int, error) {
if c == nil {
return 0, net.ErrClosed
}
return c.write.Write(p)
}
func (c *earlyMemoryConn) Close() error { return nil }
func (c *earlyMemoryConn) LocalAddr() net.Addr { return earlyDummyAddr("local") }
func (c *earlyMemoryConn) RemoteAddr() net.Addr { return earlyDummyAddr("remote") }
func (c *earlyMemoryConn) SetDeadline(time.Time) error { return nil }
func (c *earlyMemoryConn) SetReadDeadline(time.Time) error { return nil }
func (c *earlyMemoryConn) SetWriteDeadline(time.Time) error { return nil }
func (c *earlyMemoryConn) Written() []byte { return append([]byte(nil), c.write.Bytes()...) }
type earlyDummyAddr string
func (a earlyDummyAddr) Network() string { return string(a) }
func (a earlyDummyAddr) String() string { return string(a) }
func buildEarlyClientObfsConn(raw net.Conn, cfg EarlyCodecConfig, table *sudokuobfs.Table) net.Conn {
base := sudokuobfs.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
if cfg.EnablePureDownlink {
return base
}
packed := sudokuobfs.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
return newDirectionalConn(raw, packed, base)
}
func buildEarlyServerObfsConn(raw net.Conn, cfg EarlyCodecConfig, table *sudokuobfs.Table) net.Conn {
uplink := sudokuobfs.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
if cfg.EnablePureDownlink {
return uplink
}
packed := sudokuobfs.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
return newDirectionalConn(raw, uplink, packed, packed.Flush)
}
func NewEarlyClientState(cfg EarlyCodecConfig, table *sudokuobfs.Table, userHash [kipHelloUserHashSize]byte, feats uint32) (*EarlyClientState, error) {
if table == nil {
return nil, fmt.Errorf("nil table")
}
curve := ecdh.X25519()
ephemeral, err := curve.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("ecdh generate failed: %w", err)
}
var nonce [kipHelloNonceSize]byte
if _, err := rand.Read(nonce[:]); err != nil {
return nil, fmt.Errorf("nonce generate failed: %w", err)
}
var clientPub [kipHelloPubSize]byte
copy(clientPub[:], ephemeral.PublicKey().Bytes())
hello := &KIPClientHello{
Timestamp: time.Now(),
UserHash: userHash,
Nonce: nonce,
ClientPub: clientPub,
Features: feats,
}
mem := newEarlyMemoryConn(nil)
obfsConn := buildEarlyClientObfsConn(mem, cfg, table)
pskC2S, pskS2C := derivePSKDirectionalBases(cfg.PSK)
rc, err := crypto.NewRecordConn(obfsConn, cfg.AEAD, pskC2S, pskS2C)
if err != nil {
return nil, fmt.Errorf("client early crypto setup failed: %w", err)
}
if err := WriteKIPMessage(rc, KIPTypeClientHello, hello.EncodePayload()); err != nil {
return nil, fmt.Errorf("write early client hello failed: %w", err)
}
return &EarlyClientState{
RequestPayload: mem.Written(),
cfg: cfg,
table: table,
nonce: nonce,
ephemeral: ephemeral,
}, nil
}
func (s *EarlyClientState) ProcessResponse(payload []byte) error {
if s == nil {
return fmt.Errorf("nil client state")
}
mem := newEarlyMemoryConn(payload)
obfsConn := buildEarlyClientObfsConn(mem, s.cfg, s.table)
pskC2S, pskS2C := derivePSKDirectionalBases(s.cfg.PSK)
rc, err := crypto.NewRecordConn(obfsConn, s.cfg.AEAD, pskC2S, pskS2C)
if err != nil {
return fmt.Errorf("client early crypto setup failed: %w", err)
}
msg, err := ReadKIPMessage(rc)
if err != nil {
return fmt.Errorf("read early server hello failed: %w", err)
}
if msg.Type != KIPTypeServerHello {
return fmt.Errorf("unexpected early handshake message: %d", msg.Type)
}
sh, err := DecodeKIPServerHelloPayload(msg.Payload)
if err != nil {
return fmt.Errorf("decode early server hello failed: %w", err)
}
if sh.Nonce != s.nonce {
return fmt.Errorf("early handshake nonce mismatch")
}
shared, err := x25519SharedSecret(s.ephemeral, sh.ServerPub[:])
if err != nil {
return fmt.Errorf("ecdh failed: %w", err)
}
s.sessionC2S, s.sessionS2C, err = deriveSessionDirectionalBases(s.cfg.PSK, shared, s.nonce)
if err != nil {
return fmt.Errorf("derive session keys failed: %w", err)
}
s.responseSet = true
return nil
}
func (s *EarlyClientState) WrapConn(raw net.Conn) (net.Conn, error) {
if s == nil {
return nil, fmt.Errorf("nil client state")
}
if !s.responseSet {
return nil, fmt.Errorf("early handshake not completed")
}
obfsConn := buildEarlyClientObfsConn(raw, s.cfg, s.table)
rc, err := crypto.NewRecordConn(obfsConn, s.cfg.AEAD, s.sessionC2S, s.sessionS2C)
if err != nil {
return nil, fmt.Errorf("setup client session crypto failed: %w", err)
}
return rc, nil
}
func (s *EarlyClientState) Ready() bool {
return s != nil && s.responseSet
}
func NewHTTPMaskClientEarlyHandshake(cfg EarlyCodecConfig, table *sudokuobfs.Table, userHash [kipHelloUserHashSize]byte, feats uint32) (*httpmaskobfs.ClientEarlyHandshake, error) {
state, err := NewEarlyClientState(cfg, table, userHash, feats)
if err != nil {
return nil, err
}
return &httpmaskobfs.ClientEarlyHandshake{
RequestPayload: state.RequestPayload,
HandleResponse: state.ProcessResponse,
Ready: state.Ready,
WrapConn: state.WrapConn,
}, nil
}
func ProcessEarlyClientPayload(cfg EarlyCodecConfig, tables []*sudokuobfs.Table, payload []byte, allowReplay ReplayAllowFunc) (*EarlyServerState, error) {
if len(payload) == 0 {
return nil, fmt.Errorf("empty early payload")
}
if len(tables) == 0 {
return nil, fmt.Errorf("no tables configured")
}
var firstErr error
for _, table := range tables {
state, err := processEarlyClientPayloadForTable(cfg, table, payload, allowReplay)
if err == nil {
return state, nil
}
if firstErr == nil {
firstErr = err
}
}
if firstErr == nil {
firstErr = fmt.Errorf("early handshake probe failed")
}
return nil, firstErr
}
func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, table *sudokuobfs.Table, payload []byte, allowReplay ReplayAllowFunc) (*EarlyServerState, error) {
mem := newEarlyMemoryConn(payload)
obfsConn := buildEarlyServerObfsConn(mem, cfg, table)
pskC2S, pskS2C := derivePSKDirectionalBases(cfg.PSK)
rc, err := crypto.NewRecordConn(obfsConn, cfg.AEAD, pskS2C, pskC2S)
if err != nil {
return nil, err
}
msg, err := ReadKIPMessage(rc)
if err != nil {
return nil, err
}
if msg.Type != KIPTypeClientHello {
return nil, fmt.Errorf("unexpected handshake message: %d", msg.Type)
}
ch, err := DecodeKIPClientHelloPayload(msg.Payload)
if err != nil {
return nil, err
}
if absInt64(time.Now().Unix()-ch.Timestamp.Unix()) > int64(earlyKIPHandshakeTTL.Seconds()) {
return nil, fmt.Errorf("time skew/replay")
}
userHash := hex.EncodeToString(ch.UserHash[:])
if allowReplay != nil && !allowReplay(userHash, ch.Nonce, time.Now()) {
return nil, fmt.Errorf("replay detected")
}
curve := ecdh.X25519()
serverEphemeral, err := curve.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("ecdh generate failed: %w", err)
}
shared, err := x25519SharedSecret(serverEphemeral, ch.ClientPub[:])
if err != nil {
return nil, fmt.Errorf("ecdh failed: %w", err)
}
sessionC2S, sessionS2C, err := deriveSessionDirectionalBases(cfg.PSK, shared, ch.Nonce)
if err != nil {
return nil, fmt.Errorf("derive session keys failed: %w", err)
}
var serverPub [kipHelloPubSize]byte
copy(serverPub[:], serverEphemeral.PublicKey().Bytes())
serverHello := &KIPServerHello{
Nonce: ch.Nonce,
ServerPub: serverPub,
SelectedFeats: ch.Features & KIPFeatAll,
}
respMem := newEarlyMemoryConn(nil)
respObfs := buildEarlyServerObfsConn(respMem, cfg, table)
respConn, err := crypto.NewRecordConn(respObfs, cfg.AEAD, pskS2C, pskC2S)
if err != nil {
return nil, fmt.Errorf("server early crypto setup failed: %w", err)
}
if err := WriteKIPMessage(respConn, KIPTypeServerHello, serverHello.EncodePayload()); err != nil {
return nil, fmt.Errorf("write early server hello failed: %w", err)
}
return &EarlyServerState{
ResponsePayload: respMem.Written(),
UserHash: userHash,
cfg: cfg,
table: table,
sessionC2S: sessionC2S,
sessionS2C: sessionS2C,
}, nil
}
func (s *EarlyServerState) WrapConn(raw net.Conn) (net.Conn, error) {
if s == nil {
return nil, fmt.Errorf("nil server state")
}
obfsConn := buildEarlyServerObfsConn(raw, s.cfg, s.table)
rc, err := crypto.NewRecordConn(obfsConn, s.cfg.AEAD, s.sessionS2C, s.sessionC2S)
if err != nil {
return nil, fmt.Errorf("setup server session crypto failed: %w", err)
}
return rc, nil
}
func NewHTTPMaskServerEarlyHandshake(cfg EarlyCodecConfig, tables []*sudokuobfs.Table, allowReplay ReplayAllowFunc) *httpmaskobfs.TunnelServerEarlyHandshake {
return &httpmaskobfs.TunnelServerEarlyHandshake{
Prepare: func(payload []byte) (*httpmaskobfs.PreparedServerEarlyHandshake, error) {
state, err := ProcessEarlyClientPayload(cfg, tables, payload, allowReplay)
if err != nil {
return nil, err
}
return &httpmaskobfs.PreparedServerEarlyHandshake{
ResponsePayload: state.ResponsePayload,
WrapConn: state.WrapConn,
UserHash: state.UserHash,
}, nil
},
}
}
+3
View File
@@ -337,6 +337,9 @@ func ServerHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, *Handshak
if err := cfg.Validate(); err != nil {
return nil, nil, fmt.Errorf("invalid config: %w", err)
}
if userHash, ok := httpmask.EarlyHandshakeUserHash(rawConn); ok {
return rawConn, &HandshakeMeta{UserHash: userHash}, nil
}
handshakeTimeout := time.Duration(cfg.HandshakeTimeoutSeconds) * time.Second
if handshakeTimeout <= 0 {
+48 -8
View File
@@ -14,6 +14,30 @@ type HTTPMaskTunnelServer struct {
ts *httpmask.TunnelServer
}
func newHTTPMaskEarlyCodecConfig(cfg *ProtocolConfig, psk string) EarlyCodecConfig {
return EarlyCodecConfig{
PSK: psk,
AEAD: cfg.AEADMethod,
EnablePureDownlink: cfg.EnablePureDownlink,
PaddingMin: cfg.PaddingMin,
PaddingMax: cfg.PaddingMax,
}
}
func newClientHTTPMaskEarlyHandshake(cfg *ProtocolConfig) (*httpmask.ClientEarlyHandshake, error) {
table, err := pickClientTable(cfg)
if err != nil {
return nil, err
}
return NewHTTPMaskClientEarlyHandshake(
newHTTPMaskEarlyCodecConfig(cfg, ClientAEADSeed(cfg.Key)),
table,
kipUserHashFromKey(cfg.Key),
KIPFeatAll,
)
}
func NewHTTPMaskTunnelServer(cfg *ProtocolConfig) *HTTPMaskTunnelServer {
return newHTTPMaskTunnelServer(cfg, false)
}
@@ -35,6 +59,11 @@ func newHTTPMaskTunnelServer(cfg *ProtocolConfig, passThroughOnReject bool) *HTT
Mode: cfg.HTTPMaskMode,
PathRoot: cfg.HTTPMaskPathRoot,
AuthKey: ServerAEADSeed(cfg.Key),
EarlyHandshake: NewHTTPMaskServerEarlyHandshake(
newHTTPMaskEarlyCodecConfig(cfg, ServerAEADSeed(cfg.Key)),
cfg.tableCandidates(),
globalHandshakeReplay.allow,
),
// When upstream fallback is enabled, preserve rejected HTTP requests for the caller.
PassThroughOnReject: passThroughOnReject,
})
@@ -101,14 +130,25 @@ func DialHTTPMaskTunnel(ctx context.Context, serverAddress string, cfg *Protocol
default:
return nil, fmt.Errorf("http-mask-mode=%q does not use http tunnel", cfg.HTTPMaskMode)
}
var (
earlyHandshake *httpmask.ClientEarlyHandshake
err error
)
if upgrade != nil {
earlyHandshake, err = newClientHTTPMaskEarlyHandshake(cfg)
if err != nil {
return nil, err
}
}
return httpmask.DialTunnel(ctx, serverAddress, httpmask.TunnelDialOptions{
Mode: cfg.HTTPMaskMode,
TLSEnabled: cfg.HTTPMaskTLSEnabled,
HostOverride: cfg.HTTPMaskHost,
PathRoot: cfg.HTTPMaskPathRoot,
AuthKey: ClientAEADSeed(cfg.Key),
Upgrade: upgrade,
Multiplex: cfg.HTTPMaskMultiplex,
DialContext: dial,
Mode: cfg.HTTPMaskMode,
TLSEnabled: cfg.HTTPMaskTLSEnabled,
HostOverride: cfg.HTTPMaskHost,
PathRoot: cfg.HTTPMaskPathRoot,
AuthKey: ClientAEADSeed(cfg.Key),
EarlyHandshake: earlyHandshake,
Upgrade: upgrade,
Multiplex: cfg.HTTPMaskMultiplex,
DialContext: dial,
})
}
@@ -389,6 +389,68 @@ func TestHTTPMaskTunnel_WS_TCPRoundTrip(t *testing.T) {
}
}
func TestHTTPMaskTunnel_EarlyHandshake_TCPRoundTrip(t *testing.T) {
modes := []string{"stream", "poll", "ws"}
for _, mode := range modes {
t.Run(mode, func(t *testing.T) {
key := "tunnel-early-" + mode
target := "1.1.1.1:80"
serverCfg := newTunnelTestTable(t, key)
serverCfg.HTTPMaskMode = mode
addr, stop, errCh := startTunnelServer(t, serverCfg, func(s *ServerSession) error {
if s.Type != SessionTypeTCP {
return fmt.Errorf("unexpected session type: %v", s.Type)
}
if s.Target != target {
return fmt.Errorf("target mismatch: %s", s.Target)
}
_, _ = s.Conn.Write([]byte("ok"))
return nil
})
defer stop()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
clientCfg := *serverCfg
clientCfg.ServerAddress = addr
handshakeCfg := clientCfg
handshakeCfg.DisableHTTPMask = true
tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext, func(raw net.Conn) (net.Conn, error) {
return ClientHandshake(raw, &handshakeCfg)
})
if err != nil {
t.Fatalf("dial tunnel: %v", err)
}
defer tunnelConn.Close()
addrBuf, err := EncodeAddress(target)
if err != nil {
t.Fatalf("encode addr: %v", err)
}
if err := WriteKIPMessage(tunnelConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write addr: %v", err)
}
buf := make([]byte, 2)
if _, err := io.ReadFull(tunnelConn, buf); err != nil {
t.Fatalf("read: %v", err)
}
if string(buf) != "ok" {
t.Fatalf("unexpected payload: %q", buf)
}
stop()
for err := range errCh {
t.Fatalf("server error: %v", err)
}
})
}
}
func TestHTTPMaskTunnel_Validation(t *testing.T) {
cfg := DefaultConfig()
cfg.Key = "k"
@@ -0,0 +1,174 @@
package httpmask
import (
"encoding/base64"
"errors"
"fmt"
"net"
"net/url"
"strings"
)
const (
tunnelEarlyDataQueryKey = "ed"
tunnelEarlyDataHeader = "X-Sudoku-Early"
)
type ClientEarlyHandshake struct {
RequestPayload []byte
HandleResponse func(payload []byte) error
Ready func() bool
WrapConn func(raw net.Conn) (net.Conn, error)
}
type TunnelServerEarlyHandshake struct {
Prepare func(payload []byte) (*PreparedServerEarlyHandshake, error)
}
type PreparedServerEarlyHandshake struct {
ResponsePayload []byte
WrapConn func(raw net.Conn) (net.Conn, error)
UserHash string
}
type earlyHandshakeMeta interface {
HTTPMaskEarlyHandshakeUserHash() string
}
type earlyHandshakeConn struct {
net.Conn
userHash string
}
func (c *earlyHandshakeConn) HTTPMaskEarlyHandshakeUserHash() string {
if c == nil {
return ""
}
return c.userHash
}
func wrapEarlyHandshakeConn(conn net.Conn, userHash string) net.Conn {
if conn == nil {
return nil
}
return &earlyHandshakeConn{Conn: conn, userHash: userHash}
}
func EarlyHandshakeUserHash(conn net.Conn) (string, bool) {
if conn == nil {
return "", false
}
v, ok := conn.(earlyHandshakeMeta)
if !ok {
return "", false
}
return v.HTTPMaskEarlyHandshakeUserHash(), true
}
type authorizeResponse struct {
token string
earlyPayload []byte
}
func isTunnelTokenByte(c byte) bool {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' ||
c == '_'
}
func parseAuthorizeResponse(body []byte) (*authorizeResponse, error) {
s := strings.TrimSpace(string(body))
idx := strings.Index(s, "token=")
if idx < 0 {
return nil, errors.New("missing token")
}
s = s[idx+len("token="):]
if s == "" {
return nil, errors.New("empty token")
}
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if isTunnelTokenByte(c) {
b.WriteByte(c)
continue
}
break
}
token := b.String()
if token == "" {
return nil, errors.New("empty token")
}
out := &authorizeResponse{token: token}
if earlyLine := findAuthorizeField(body, "ed="); earlyLine != "" {
decoded, err := base64.RawURLEncoding.DecodeString(earlyLine)
if err != nil {
return nil, fmt.Errorf("decode early authorize payload failed: %w", err)
}
out.earlyPayload = decoded
}
return out, nil
}
func findAuthorizeField(body []byte, prefix string) string {
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, prefix) {
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
}
}
return ""
}
func setEarlyDataQuery(rawURL string, payload []byte) (string, error) {
if len(payload) == 0 {
return rawURL, nil
}
u, err := url.Parse(rawURL)
if err != nil {
return "", err
}
q := u.Query()
q.Set(tunnelEarlyDataQueryKey, base64.RawURLEncoding.EncodeToString(payload))
u.RawQuery = q.Encode()
return u.String(), nil
}
func parseEarlyDataQuery(u *url.URL) ([]byte, error) {
if u == nil {
return nil, nil
}
val := strings.TrimSpace(u.Query().Get(tunnelEarlyDataQueryKey))
if val == "" {
return nil, nil
}
return base64.RawURLEncoding.DecodeString(val)
}
func applyEarlyHandshakeOrUpgrade(raw net.Conn, opts TunnelDialOptions) (net.Conn, error) {
out := raw
if opts.EarlyHandshake != nil && opts.EarlyHandshake.WrapConn != nil && (opts.EarlyHandshake.Ready == nil || opts.EarlyHandshake.Ready()) {
wrapped, err := opts.EarlyHandshake.WrapConn(raw)
if err != nil {
return nil, err
}
if wrapped != nil {
out = wrapped
}
return out, nil
}
if opts.Upgrade != nil {
wrapped, err := opts.Upgrade(raw)
if err != nil {
return nil, err
}
if wrapped != nil {
out = wrapped
}
}
return out, nil
}
@@ -72,6 +72,10 @@ type TunnelDialOptions struct {
// AuthKey enables short-term HMAC auth for HTTP tunnel requests (anti-probing).
// When set (non-empty), each HTTP request carries an Authorization bearer token derived from AuthKey.
AuthKey string
// EarlyHandshake folds the protocol handshake into the HTTP/WS setup round trip.
// When the server accepts the early payload, DialTunnel returns a conn that is already post-handshake.
// When the server does not echo early data, DialTunnel falls back to Upgrade.
EarlyHandshake *ClientEarlyHandshake
// Upgrade optionally wraps the raw tunnel conn and/or writes a small prelude before DialTunnel returns.
// It is called with the raw tunnel conn; if it returns a non-nil conn, that conn is returned by DialTunnel.
Upgrade func(raw net.Conn) (net.Conn, error)
@@ -225,30 +229,11 @@ func canonicalHeaderHost(urlHost, scheme string) string {
}
func parseTunnelToken(body []byte) (string, error) {
s := strings.TrimSpace(string(body))
idx := strings.Index(s, "token=")
if idx < 0 {
return "", errors.New("missing token")
resp, err := parseAuthorizeResponse(body)
if err != nil {
return "", err
}
s = s[idx+len("token="):]
if s == "" {
return "", errors.New("empty token")
}
// Token is base64.RawURLEncoding (A-Z a-z 0-9 - _). Strip any trailing bytes (e.g. from CDN compression).
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
b.WriteByte(c)
continue
}
break
}
token := b.String()
if token == "" {
return "", errors.New("empty token")
}
return token, nil
return resp.token, nil
}
type httpClientTarget struct {
@@ -353,6 +338,13 @@ func dialSessionWithClient(ctx context.Context, client *http.Client, target http
auth := newTunnelAuth(opts.AuthKey, 0)
authorizeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/session")}).String()
if opts.EarlyHandshake != nil && len(opts.EarlyHandshake.RequestPayload) > 0 {
var err error
authorizeURL, err = setEarlyDataQuery(authorizeURL, opts.EarlyHandshake.RequestPayload)
if err != nil {
return nil, err
}
}
var bodyBytes []byte
for attempt := 0; ; attempt++ {
@@ -410,13 +402,19 @@ func dialSessionWithClient(ctx context.Context, client *http.Client, target http
break
}
token, err := parseTunnelToken(bodyBytes)
authResp, err := parseAuthorizeResponse(bodyBytes)
if err != nil {
return nil, fmt.Errorf("%s authorize failed: %q", mode, strings.TrimSpace(string(bodyBytes)))
}
token := authResp.token
if token == "" {
return nil, fmt.Errorf("%s authorize empty token", mode)
}
if opts.EarlyHandshake != nil && len(authResp.earlyPayload) > 0 && opts.EarlyHandshake.HandleResponse != nil {
if err := opts.EarlyHandshake.HandleResponse(authResp.earlyPayload); err != nil {
return nil, err
}
}
pushURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/api/v1/upload"), RawQuery: "token=" + url.QueryEscape(token)}).String()
pullURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/stream"), RawQuery: "token=" + url.QueryEscape(token)}).String()
@@ -671,16 +669,10 @@ func dialStreamSplitWithClient(ctx context.Context, client *http.Client, target
if c == nil {
return nil, fmt.Errorf("failed to build stream split conn")
}
outConn := net.Conn(c)
if opts.Upgrade != nil {
upgraded, err := opts.Upgrade(c)
if err != nil {
_ = c.Close()
return nil, err
}
if upgraded != nil {
outConn = upgraded
}
outConn, err := applyEarlyHandshakeOrUpgrade(c, opts)
if err != nil {
_ = c.Close()
return nil, err
}
return outConn, nil
}
@@ -694,16 +686,10 @@ func dialStreamSplit(ctx context.Context, serverAddress string, opts TunnelDialO
if c == nil {
return nil, fmt.Errorf("failed to build stream split conn")
}
outConn := net.Conn(c)
if opts.Upgrade != nil {
upgraded, err := opts.Upgrade(c)
if err != nil {
_ = c.Close()
return nil, err
}
if upgraded != nil {
outConn = upgraded
}
outConn, err := applyEarlyHandshakeOrUpgrade(c, opts)
if err != nil {
_ = c.Close()
return nil, err
}
return outConn, nil
}
@@ -1120,16 +1106,10 @@ func dialPollWithClient(ctx context.Context, client *http.Client, target httpCli
if c == nil {
return nil, fmt.Errorf("failed to build poll conn")
}
outConn := net.Conn(c)
if opts.Upgrade != nil {
upgraded, err := opts.Upgrade(c)
if err != nil {
_ = c.Close()
return nil, err
}
if upgraded != nil {
outConn = upgraded
}
outConn, err := applyEarlyHandshakeOrUpgrade(c, opts)
if err != nil {
_ = c.Close()
return nil, err
}
return outConn, nil
}
@@ -1143,16 +1123,10 @@ func dialPoll(ctx context.Context, serverAddress string, opts TunnelDialOptions)
if c == nil {
return nil, fmt.Errorf("failed to build poll conn")
}
outConn := net.Conn(c)
if opts.Upgrade != nil {
upgraded, err := opts.Upgrade(c)
if err != nil {
_ = c.Close()
return nil, err
}
if upgraded != nil {
outConn = upgraded
}
outConn, err := applyEarlyHandshakeOrUpgrade(c, opts)
if err != nil {
_ = c.Close()
return nil, err
}
return outConn, nil
}
@@ -1528,6 +1502,8 @@ type TunnelServerOptions struct {
PullReadTimeout time.Duration
// SessionTTL is a best-effort TTL to prevent leaked sessions. 0 uses a conservative default.
SessionTTL time.Duration
// EarlyHandshake optionally folds the protocol handshake into the initial HTTP/WS round trip.
EarlyHandshake *TunnelServerEarlyHandshake
}
type TunnelServer struct {
@@ -1538,6 +1514,7 @@ type TunnelServer struct {
pullReadTimeout time.Duration
sessionTTL time.Duration
earlyHandshake *TunnelServerEarlyHandshake
mu sync.Mutex
sessions map[string]*tunnelSession
@@ -1570,6 +1547,7 @@ func NewTunnelServer(opts TunnelServerOptions) *TunnelServer {
passThroughOnReject: opts.PassThroughOnReject,
pullReadTimeout: timeout,
sessionTTL: ttl,
earlyHandshake: opts.EarlyHandshake,
sessions: make(map[string]*tunnelSession),
}
}
@@ -1925,9 +1903,12 @@ func (s *TunnelServer) handleStream(rawConn net.Conn, req *httpRequestHeader, he
switch strings.ToUpper(req.method) {
case http.MethodGet:
// Stream split-session: GET /session (no token) => token + start tunnel on a server-side pipe.
if token == "" && path == "/session" {
return s.sessionAuthorize(rawConn)
earlyPayload, err := parseEarlyDataQuery(u)
if err != nil {
return rejectOrReply(http.StatusBadRequest, "bad request")
}
return s.sessionAuthorize(rawConn, earlyPayload)
}
// Stream split-session: GET /stream?token=... => downlink poll.
if token != "" && path == "/stream" {
@@ -2045,10 +2026,18 @@ func writeSimpleHTTPResponse(w io.Writer, code int, body string) error {
func writeTokenHTTPResponse(w io.Writer, token string) error {
token = strings.TrimRight(token, "\r\n")
// Use application/octet-stream to avoid CDN auto-compression (e.g. brotli) breaking clients that expect a plain token string.
return writeTokenHTTPResponseWithEarlyData(w, token, nil)
}
func writeTokenHTTPResponseWithEarlyData(w io.Writer, token string, earlyPayload []byte) error {
token = strings.TrimRight(token, "\r\n")
body := "token=" + token
if len(earlyPayload) > 0 {
body += "\ned=" + base64.RawURLEncoding.EncodeToString(earlyPayload)
}
_, err := io.WriteString(w,
fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nCache-Control: no-store\r\nPragma: no-cache\r\nContent-Length: %d\r\nConnection: close\r\n\r\ntoken=%s",
len("token=")+len(token), token))
fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nCache-Control: no-store\r\nPragma: no-cache\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
len(body), body))
return err
}
@@ -2088,7 +2077,11 @@ func (s *TunnelServer) handlePoll(rawConn net.Conn, req *httpRequestHeader, head
switch strings.ToUpper(req.method) {
case http.MethodGet:
if token == "" && path == "/session" {
return s.sessionAuthorize(rawConn)
earlyPayload, err := parseEarlyDataQuery(u)
if err != nil {
return rejectOrReply(http.StatusBadRequest, "bad request")
}
return s.sessionAuthorize(rawConn, earlyPayload)
}
if token != "" && path == "/stream" {
if s.passThroughOnReject && !s.sessionHas(token) {
@@ -2128,7 +2121,7 @@ func (s *TunnelServer) handlePoll(rawConn net.Conn, req *httpRequestHeader, head
}
}
func (s *TunnelServer) sessionAuthorize(rawConn net.Conn) (HandleResult, net.Conn, error) {
func (s *TunnelServer) sessionAuthorize(rawConn net.Conn, earlyPayload []byte) (HandleResult, net.Conn, error) {
token, err := newSessionToken()
if err != nil {
_ = writeSimpleHTTPResponse(rawConn, http.StatusInternalServerError, "internal error")
@@ -2137,6 +2130,37 @@ func (s *TunnelServer) sessionAuthorize(rawConn net.Conn) (HandleResult, net.Con
}
c1, c2 := newHalfPipe()
outConn := net.Conn(c1)
var responsePayload []byte
var userHash string
if len(earlyPayload) > 0 && s.earlyHandshake != nil && s.earlyHandshake.Prepare != nil {
prepared, err := s.earlyHandshake.Prepare(earlyPayload)
if err != nil {
_ = c1.Close()
_ = c2.Close()
if s.passThroughOnReject {
return HandlePassThrough, newRejectedPreBufferedConn(rawConn, nil), nil
}
_ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found")
_ = rawConn.Close()
return HandleDone, nil, nil
}
responsePayload = prepared.ResponsePayload
userHash = prepared.UserHash
if prepared.WrapConn != nil {
wrapped, err := prepared.WrapConn(c1)
if err != nil {
_ = c1.Close()
_ = c2.Close()
_ = writeSimpleHTTPResponse(rawConn, http.StatusInternalServerError, "internal error")
_ = rawConn.Close()
return HandleDone, nil, nil
}
if wrapped != nil {
outConn = wrapEarlyHandshakeConn(wrapped, userHash)
}
}
}
s.mu.Lock()
s.sessions[token] = &tunnelSession{conn: c2, lastActive: time.Now()}
@@ -2144,9 +2168,9 @@ func (s *TunnelServer) sessionAuthorize(rawConn net.Conn) (HandleResult, net.Con
go s.reapLater(token)
_ = writeTokenHTTPResponse(rawConn, token)
_ = writeTokenHTTPResponseWithEarlyData(rawConn, token, responsePayload)
_ = rawConn.Close()
return HandleStartTunnel, c1, nil
return HandleStartTunnel, outConn, nil
}
func newSessionToken() (string, error) {
@@ -2,6 +2,7 @@ package httpmask
import (
"context"
"encoding/base64"
"fmt"
"io"
mrand "math/rand"
@@ -115,6 +116,16 @@ func dialWS(ctx context.Context, serverAddress string, opts TunnelDialOptions) (
Host: urlHost,
Path: joinPathRoot(opts.PathRoot, "/ws"),
}
if opts.EarlyHandshake != nil && len(opts.EarlyHandshake.RequestPayload) > 0 {
rawURL, err := setEarlyDataQuery(u.String(), opts.EarlyHandshake.RequestPayload)
if err != nil {
return nil, err
}
u, err = url.Parse(rawURL)
if err != nil {
return nil, err
}
}
header := make(stdhttp.Header)
applyWSHeaders(header, headerHost)
@@ -132,6 +143,16 @@ func dialWS(ctx context.Context, serverAddress string, opts TunnelDialOptions) (
d := ws.Dialer{
Host: headerHost,
Header: ws.HandshakeHeaderHTTP(header),
OnHeader: func(key, value []byte) error {
if !strings.EqualFold(string(key), tunnelEarlyDataHeader) || opts.EarlyHandshake == nil || opts.EarlyHandshake.HandleResponse == nil {
return nil
}
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(value)))
if err != nil {
return err
}
return opts.EarlyHandshake.HandleResponse(decoded)
},
NetDial: func(dialCtx context.Context, network, addr string) (net.Conn, error) {
if addr == urlHost {
addr = dialAddr
@@ -161,16 +182,10 @@ func dialWS(ctx context.Context, serverAddress string, opts TunnelDialOptions) (
}
wsConn := newWSStreamConn(conn, ws.StateClientSide)
if opts.Upgrade == nil {
return wsConn, nil
}
upgraded, err := opts.Upgrade(wsConn)
upgraded, err := applyEarlyHandshakeOrUpgrade(wsConn, opts)
if err != nil {
_ = wsConn.Close()
return nil, err
}
if upgraded != nil {
return upgraded, nil
}
return wsConn, nil
return upgraded, nil
}
@@ -1,6 +1,7 @@
package httpmask
import (
"encoding/base64"
"net"
"net/http"
"net/url"
@@ -63,15 +64,46 @@ func (s *TunnelServer) handleWS(rawConn net.Conn, req *httpRequestHeader, header
return rejectOrReply(http.StatusNotFound, "not found")
}
earlyPayload, err := parseEarlyDataQuery(u)
if err != nil {
return rejectOrReply(http.StatusBadRequest, "bad request")
}
var prepared *PreparedServerEarlyHandshake
if len(earlyPayload) > 0 && s.earlyHandshake != nil && s.earlyHandshake.Prepare != nil {
prepared, err = s.earlyHandshake.Prepare(earlyPayload)
if err != nil {
return rejectOrReply(http.StatusNotFound, "not found")
}
}
prefix := make([]byte, 0, len(headerBytes)+len(buffered))
prefix = append(prefix, headerBytes...)
prefix = append(prefix, buffered...)
wsConnRaw := newPreBufferedConn(rawConn, prefix)
if _, err := ws.Upgrade(wsConnRaw); err != nil {
upgrader := ws.Upgrader{}
if prepared != nil && len(prepared.ResponsePayload) > 0 {
upgrader.OnBeforeUpgrade = func() (ws.HandshakeHeader, error) {
h := http.Header{}
h.Set(tunnelEarlyDataHeader, base64.RawURLEncoding.EncodeToString(prepared.ResponsePayload))
return ws.HandshakeHeaderHTTP(h), nil
}
}
if _, err := upgrader.Upgrade(wsConnRaw); err != nil {
_ = rawConn.Close()
return HandleDone, nil, nil
}
return HandleStartTunnel, newWSStreamConn(wsConnRaw, ws.StateServerSide), nil
outConn := net.Conn(newWSStreamConn(wsConnRaw, ws.StateServerSide))
if prepared != nil && prepared.WrapConn != nil {
wrapped, err := prepared.WrapConn(outConn)
if err != nil {
_ = outConn.Close()
return HandleDone, nil, nil
}
if wrapped != nil {
outConn = wrapEarlyHandshakeConn(wrapped, prepared.UserHash)
}
}
return HandleStartTunnel, outConn, nil
}
+110 -82
View File
@@ -11,35 +11,35 @@ import (
)
const (
// 每次从 RNG 获取批量随机数的缓存大小,减少 RNG 函数调用开销
RngBatchSize = 128
packedProtectedPrefixBytes = 14
)
// 1. 使用 12字节->16组 的块处理优化 Write (减少循环开销)
// 2. 使用整数阈值随机概率判断 Padding,与纯 Sudoku 保持流量特征一致
// 3. Read 使用 copy 移动避免底层数组泄漏
// PackedConn encodes traffic with the packed Sudoku layout while preserving
// the same padding model as the regular connection.
type PackedConn struct {
net.Conn
table *Table
reader *bufio.Reader
// 读缓冲
// Read-side buffers.
rawBuf []byte
pendingData []byte // 解码后尚未被 Read 取走的字节
pendingData []byte
// 写缓冲与状态
// Write-side state.
writeMu sync.Mutex
writeBuf []byte
bitBuf uint64 // 暂存的位数据
bitCount int // 暂存的位数
bitBuf uint64
bitCount int
// 读状态
// Read-side bit accumulator.
readBitBuf uint64
readBits int
// 随机数与填充控制 - 使用整数阈值随机,与 Conn 一致
// Padding selection matches Conn's threshold-based model.
rng *rand.Rand
paddingThreshold uint64 // 与 Conn 保持一致的随机概率模型
paddingThreshold uint64
padMarker byte
padPool []byte
}
@@ -95,7 +95,6 @@ func NewPackedConn(c net.Conn, table *Table, pMin, pMax int) *PackedConn {
return pc
}
// maybeAddPadding 内联辅助:根据概率阈值插入 padding
func (pc *PackedConn) maybeAddPadding(out []byte) []byte {
if shouldPad(pc.rng, pc.paddingThreshold) {
out = append(out, pc.getPaddingByte())
@@ -103,7 +102,73 @@ func (pc *PackedConn) maybeAddPadding(out []byte) []byte {
return out
}
// Write 极致优化版 - 批量处理 12 字节
func (pc *PackedConn) appendGroup(out []byte, group byte) []byte {
out = pc.maybeAddPadding(out)
return append(out, pc.encodeGroup(group))
}
func (pc *PackedConn) appendForcedPadding(out []byte) []byte {
return append(out, pc.getPaddingByte())
}
func (pc *PackedConn) nextProtectedPrefixGap() int {
return 1 + pc.rng.Intn(2)
}
func (pc *PackedConn) writeProtectedPrefix(out []byte, p []byte) ([]byte, int) {
if len(p) == 0 {
return out, 0
}
limit := len(p)
if limit > packedProtectedPrefixBytes {
limit = packedProtectedPrefixBytes
}
for padCount := 0; padCount < 1+pc.rng.Intn(2); padCount++ {
out = pc.appendForcedPadding(out)
}
gap := pc.nextProtectedPrefixGap()
effective := 0
for i := 0; i < limit; i++ {
pc.bitBuf = (pc.bitBuf << 8) | uint64(p[i])
pc.bitCount += 8
for pc.bitCount >= 6 {
pc.bitCount -= 6
group := byte(pc.bitBuf >> pc.bitCount)
if pc.bitCount == 0 {
pc.bitBuf = 0
} else {
pc.bitBuf &= (1 << pc.bitCount) - 1
}
out = pc.appendGroup(out, group&0x3F)
}
effective++
if effective >= gap {
out = pc.appendForcedPadding(out)
effective = 0
gap = pc.nextProtectedPrefixGap()
}
}
return out, limit
}
func (pc *PackedConn) drainPendingData(dst []byte) int {
n := copy(dst, pc.pendingData)
if n == len(pc.pendingData) {
pc.pendingData = pc.pendingData[:0]
return n
}
remaining := len(pc.pendingData) - n
copy(pc.pendingData, pc.pendingData[n:])
pc.pendingData = pc.pendingData[:remaining]
return n
}
func (pc *PackedConn) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
@@ -112,20 +177,19 @@ func (pc *PackedConn) Write(p []byte) (int, error) {
pc.writeMu.Lock()
defer pc.writeMu.Unlock()
// 1. 预分配内存,避免 append 导致的多次扩容
// 预估:原数据 * 1.5 (4/3 + padding 余量)
needed := len(p)*3/2 + 32
if cap(pc.writeBuf) < needed {
pc.writeBuf = make([]byte, 0, needed)
}
out := pc.writeBuf[:0]
i := 0
var prefixN int
out, prefixN = pc.writeProtectedPrefix(out, p)
i := prefixN
n := len(p)
// 2. 头部对齐处理 (Slow Path)
for pc.bitCount > 0 && i < n {
out = pc.maybeAddPadding(out)
b := p[i]
i++
pc.bitBuf = (pc.bitBuf << 8) | uint64(b)
@@ -138,14 +202,11 @@ func (pc *PackedConn) Write(p []byte) (int, error) {
} else {
pc.bitBuf &= (1 << pc.bitCount) - 1
}
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(group&0x3F))
out = pc.appendGroup(out, group&0x3F)
}
}
// 3. 极速批量处理 (Fast Path) - 每次处理 12 字节 → 生成 16 个编码组
for i+11 < n {
// 处理 4 组,每组 3 字节
for batch := 0; batch < 4; batch++ {
b1, b2, b3 := p[i], p[i+1], p[i+2]
i += 3
@@ -155,19 +216,13 @@ func (pc *PackedConn) Write(p []byte) (int, error) {
g3 := ((b2 & 0x0F) << 2) | ((b3 >> 6) & 0x03)
g4 := b3 & 0x3F
// 每个组之前都有概率插入 padding
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g1))
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g2))
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g3))
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g4))
out = pc.appendGroup(out, g1)
out = pc.appendGroup(out, g2)
out = pc.appendGroup(out, g3)
out = pc.appendGroup(out, g4)
}
}
// 4. 处理剩余的 3 字节块
for i+2 < n {
b1, b2, b3 := p[i], p[i+1], p[i+2]
i += 3
@@ -177,17 +232,12 @@ func (pc *PackedConn) Write(p []byte) (int, error) {
g3 := ((b2 & 0x0F) << 2) | ((b3 >> 6) & 0x03)
g4 := b3 & 0x3F
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g1))
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g2))
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g3))
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g4))
out = pc.appendGroup(out, g1)
out = pc.appendGroup(out, g2)
out = pc.appendGroup(out, g3)
out = pc.appendGroup(out, g4)
}
// 5. 尾部处理 (Tail Path) - 处理剩余的 1 或 2 个字节
for ; i < n; i++ {
b := p[i]
pc.bitBuf = (pc.bitBuf << 8) | uint64(b)
@@ -200,35 +250,28 @@ func (pc *PackedConn) Write(p []byte) (int, error) {
} else {
pc.bitBuf &= (1 << pc.bitCount) - 1
}
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(group&0x3F))
out = pc.appendGroup(out, group&0x3F)
}
}
// 6. 处理残留位
if pc.bitCount > 0 {
out = pc.maybeAddPadding(out)
group := byte(pc.bitBuf << (6 - pc.bitCount))
pc.bitBuf = 0
pc.bitCount = 0
out = append(out, pc.encodeGroup(group&0x3F))
out = pc.appendGroup(out, group&0x3F)
out = append(out, pc.padMarker)
}
// 尾部可能添加 padding
out = pc.maybeAddPadding(out)
// 发送数据
if len(out) > 0 {
_, err := pc.Conn.Write(out)
pc.writeBuf = out[:0]
return len(p), err
return len(p), writeFull(pc.Conn, out)
}
pc.writeBuf = out[:0]
return len(p), nil
}
// Flush 处理最后不足 6 bit 的情况
func (pc *PackedConn) Flush() error {
pc.writeMu.Lock()
defer pc.writeMu.Unlock()
@@ -243,38 +286,34 @@ func (pc *PackedConn) Flush() error {
out = append(out, pc.padMarker)
}
// 尾部随机添加 padding
out = pc.maybeAddPadding(out)
if len(out) > 0 {
_, err := pc.Conn.Write(out)
pc.writeBuf = out[:0]
return err
return writeFull(pc.Conn, out)
}
return nil
}
// Read 优化版:减少切片操作,避免内存泄漏
func (pc *PackedConn) Read(p []byte) (int, error) {
// 1. 优先返回待处理区的数据
if len(pc.pendingData) > 0 {
n := copy(p, pc.pendingData)
if n == len(pc.pendingData) {
pc.pendingData = pc.pendingData[:0]
} else {
// 优化:移动剩余数据到数组头部,避免切片指向中间导致内存泄漏
remaining := len(pc.pendingData) - n
copy(pc.pendingData, pc.pendingData[n:])
pc.pendingData = pc.pendingData[:remaining]
func writeFull(w io.Writer, b []byte) error {
for len(b) > 0 {
n, err := w.Write(b)
if err != nil {
return err
}
return n, nil
b = b[n:]
}
return nil
}
func (pc *PackedConn) Read(p []byte) (int, error) {
if len(pc.pendingData) > 0 {
return pc.drainPendingData(p), nil
}
// 2. 循环读取直到解出数据或出错
for {
nr, rErr := pc.reader.Read(pc.rawBuf)
if nr > 0 {
// 缓存频繁访问的变量
rBuf := pc.readBitBuf
rBits := pc.readBits
padMarker := pc.padMarker
@@ -324,24 +363,13 @@ func (pc *PackedConn) Read(p []byte) (int, error) {
}
}
// 3. 返回解码后的数据 - 优化:避免底层数组泄漏
n := copy(p, pc.pendingData)
if n == len(pc.pendingData) {
pc.pendingData = pc.pendingData[:0]
} else {
remaining := len(pc.pendingData) - n
copy(pc.pendingData, pc.pendingData[n:])
pc.pendingData = pc.pendingData[:remaining]
}
return n, nil
return pc.drainPendingData(p), nil
}
// getPaddingByte 从 Pool 中随机取 Padding 字节
func (pc *PackedConn) getPaddingByte() byte {
return pc.padPool[pc.rng.Intn(len(pc.padPool))]
}
// encodeGroup 编码 6-bit 组
func (pc *PackedConn) encodeGroup(group byte) byte {
return pc.table.layout.encodeGroup(group)
}
@@ -0,0 +1,91 @@
package sudoku
import (
"bytes"
"io"
"math/rand"
"net"
"testing"
"time"
)
type mockConn struct {
readBuf []byte
writeBuf []byte
}
func (c *mockConn) Read(p []byte) (int, error) {
if len(c.readBuf) == 0 {
return 0, io.EOF
}
n := copy(p, c.readBuf)
c.readBuf = c.readBuf[n:]
return n, nil
}
func (c *mockConn) Write(p []byte) (int, error) {
c.writeBuf = append(c.writeBuf, p...)
return len(p), nil
}
func (c *mockConn) Close() error { return nil }
func (c *mockConn) LocalAddr() net.Addr { return nil }
func (c *mockConn) RemoteAddr() net.Addr { return nil }
func (c *mockConn) SetDeadline(time.Time) error { return nil }
func (c *mockConn) SetReadDeadline(time.Time) error { return nil }
func (c *mockConn) SetWriteDeadline(time.Time) error { return nil }
func TestPackedConn_ProtectedPrefixPadding(t *testing.T) {
table := NewTable("packed-prefix-seed", "prefer_ascii")
mock := &mockConn{}
writer := NewPackedConn(mock, table, 0, 0)
writer.rng = rand.New(rand.NewSource(1))
payload := bytes.Repeat([]byte{0}, 32)
if _, err := writer.Write(payload); err != nil {
t.Fatalf("write: %v", err)
}
wire := append([]byte(nil), mock.writeBuf...)
if len(wire) < 20 {
t.Fatalf("wire too short: %d", len(wire))
}
firstHint := -1
nonHintCount := 0
maxHintRun := 0
currentHintRun := 0
for i, b := range wire[:20] {
if table.layout.isHint(b) {
if firstHint == -1 {
firstHint = i
}
currentHintRun++
if currentHintRun > maxHintRun {
maxHintRun = currentHintRun
}
continue
}
nonHintCount++
currentHintRun = 0
}
if firstHint < 1 || firstHint > 2 {
t.Fatalf("expected 1-2 leading padding bytes, first hint index=%d", firstHint)
}
if nonHintCount < 6 {
t.Fatalf("expected dense prefix padding, got only %d non-hint bytes in first 20", nonHintCount)
}
if maxHintRun > 3 {
t.Fatalf("prefix still exposes long hint run: %d", maxHintRun)
}
reader := NewPackedConn(&mockConn{readBuf: wire}, table, 0, 0)
decoded := make([]byte, len(payload))
if _, err := io.ReadFull(reader, decoded); err != nil {
t.Fatalf("read back: %v", err)
}
if !bytes.Equal(decoded, payload) {
t.Fatalf("roundtrip mismatch")
}
}
@@ -11,7 +11,7 @@ func forceCloseAllConnections(roundTripper RoundTripper) {
roundTripper.CloseIdleConnections()
switch tr := roundTripper.(type) {
case *http.Http2Transport:
gun.CloseTransport(tr)
gun.CloseHttp2Transport(tr)
case *http3.Transport:
_ = tr.Close()
}
+10
View File
@@ -144,6 +144,11 @@ jobs:
with:
node-version: 24
- name: Install Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- uses: Swatinem/rust-cache@v2
name: Cache Rust dependencies
with:
@@ -231,6 +236,11 @@ jobs:
with:
node-version: 24
- name: Install Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- uses: Swatinem/rust-cache@v2
name: Cache Rust dependencies
with:
+2 -2
View File
@@ -3,7 +3,6 @@
"inlang.vs-code-extension",
"editorconfig.editorconfig",
"vadimcn.vscode-lldb",
"bungcip.better-toml",
"denoland.vscode-deno",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors",
@@ -11,6 +10,7 @@
"syler.sass-indented",
"stylelint.vscode-stylelint",
"bradlc.vscode-tailwindcss",
"oxc.oxc-vscode"
"oxc.oxc-vscode",
"tamasfe.even-better-toml"
]
}
+20 -4
View File
@@ -780,14 +780,30 @@ pub trait AppWindow {
let state = match current_monitor {
Some(_) => {
let maximized = win.is_maximized()?;
let fullscreen = win.is_fullscreen()?;
let is_minimized = win.is_minimized()?;
let size = win.inner_size()?;
// During system shutdown, Windows sends resize events with 0x0 dimensions.
// Skip saving in this case to preserve the last valid window state.
if size.width == 0 || size.height == 0 {
if !maximized && !fullscreen && !is_minimized {
tracing::debug!(
"skipping window state save: invalid size {}x{} in normal state",
size.width,
size.height
);
return Ok(());
}
}
let mut state = WindowState {
maximized: win.is_maximized()?,
fullscreen: win.is_fullscreen()?,
maximized,
fullscreen,
..WindowState::default()
};
let is_minimized = win.is_minimized()?;
let size = win.inner_size()?;
if size.width > 0 && size.height > 0 && !state.maximized && !is_minimized {
state.width = size.width;
state.height = size.height;
+10 -16
View File
@@ -11,11 +11,13 @@
},
"dependencies": {
"@dnd-kit/core": "6.3.1",
"@dnd-kit/helpers": "^0.3.2",
"@dnd-kit/react": "^0.3.2",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@emotion/styled": "11.14.1",
"@hookform/resolvers": "5.2.2",
"@inlang/paraglide-js": "2.13.2",
"@inlang/paraglide-js": "2.14.0",
"@juggle/resize-observer": "3.4.0",
"@material/material-color-utilities": "0.4.0",
"@mui/icons-material": "7.3.9",
@@ -25,16 +27,7 @@
"@nyanpasu/interface": "workspace:^",
"@nyanpasu/ui": "workspace:^",
"@paper-design/shaders-react": "0.0.71",
"@radix-ui/react-context-menu": "2.2.16",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tooltip": "1.2.8",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tailwindcss/postcss": "4.2.1",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.21",
@@ -50,12 +43,13 @@
"country-emoji": "1.5.6",
"dayjs": "1.11.19",
"framer-motion": "12.35.2",
"i18next": "25.8.14",
"i18next": "25.8.17",
"jotai": "2.18.1",
"json-schema": "0.4.0",
"material-react-table": "3.2.1",
"monaco-editor": "0.55.1",
"mui-color-input": "7.0.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-error-boundary": "6.0.0",
@@ -75,12 +69,12 @@
"@csstools/normalize.css": "12.1.1",
"@emotion/babel-plugin": "11.13.5",
"@emotion/react": "11.14.0",
"@iconify/json": "2.2.447",
"@iconify/json": "2.2.448",
"@monaco-editor/react": "4.7.0",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-router": "1.166.3",
"@tanstack/react-router-devtools": "1.166.3",
"@tanstack/router-plugin": "1.166.3",
"@tanstack/react-router": "1.166.6",
"@tanstack/react-router-devtools": "1.166.6",
"@tanstack/router-plugin": "1.166.6",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-dialog": "2.6.0",
"@tauri-apps/plugin-fs": "2.4.5",
@@ -1,9 +1,9 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { AnimatePresence, motion } from 'framer-motion'
import { Slot } from 'radix-ui'
import { lazy, Suspense, useCallback } from 'react'
import { chains } from '@/utils/chain'
import { cn } from '@nyanpasu/ui'
import { Slot, Slottable } from '@radix-ui/react-slot'
import { CircularProgress } from './progress'
import { useRipple } from './ripple'
@@ -134,7 +134,7 @@ export const Button = ({
onClick,
...props
}: ButtonProps) => {
const Comp = asChild ? Slot : 'button'
const Comp = asChild ? Slot.Root : 'button'
const ripple = useRipple()
@@ -161,7 +161,7 @@ export const Button = ({
data-loading={String(Boolean(loading))}
{...props}
>
<Slottable>{children}</Slottable>
<Slot.Slottable>{children}</Slot.Slottable>
<AnimatePresence initial={false}>
{loading && (
@@ -1,7 +1,7 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import { createContext, HTMLAttributes, useContext } from 'react'
import { cn } from '@nyanpasu/ui'
import { Slot } from '@radix-ui/react-slot'
export const cardVariants = cva('rounded-3xl text-on-surface overflow-hidden', {
variants: {
@@ -104,7 +104,7 @@ export const Card = ({
className,
...props
}: CardProps) => {
const Comp = asChild ? Slot : 'div'
const Comp = asChild ? Slot.Root : 'div'
return (
<CardContext.Provider
@@ -136,7 +136,7 @@ export const CardContent = ({
asChild,
...props
}: CardContentProps) => {
const Comp = asChild ? Slot : 'div'
const Comp = asChild ? Slot.Root : 'div'
return <Comp className={cn(cardContentVariants(), className)} {...props} />
}
@@ -1,9 +1,9 @@
import ArrowRight from '~icons/material-symbols/arrow-right-rounded'
import Check from '~icons/material-symbols/check-rounded'
import { AnimatePresence, motion } from 'framer-motion'
import { ContextMenu as ContextMenuPrimitive } from 'radix-ui'
import { ComponentProps, createContext, useContext } from 'react'
import { cn } from '@nyanpasu/ui'
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
const MotionContent = ({
@@ -3,9 +3,9 @@ import Check from '~icons/material-symbols/check-rounded'
import RadioChecked from '~icons/material-symbols/radio-button-checked'
import Radio from '~icons/material-symbols/radio-button-unchecked'
import { AnimatePresence, motion } from 'framer-motion'
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
import { ComponentProps, createContext, useContext } from 'react'
import { cn } from '@nyanpasu/ui'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
const MotionContent = ({
@@ -1,8 +1,7 @@
import { AnimatePresence, motion } from 'framer-motion'
import { Dialog as DialogPrimitive, Slot } from 'radix-ui'
import { ComponentProps, createContext, useContext, useId } from 'react'
import { cn } from '@nyanpasu/ui'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { Slot, Slottable } from '@radix-ui/react-slot'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
import { Button, type ButtonProps } from './button'
@@ -37,7 +36,7 @@ export function ModalTrigger({
}: ComponentProps<typeof DialogPrimitive.Trigger>) {
const { layoutId } = useModalContext()
const Comp = asChild ? Slot : 'button'
const Comp = asChild ? Slot.Root : 'button'
return (
<DialogPrimitive.Trigger
@@ -47,7 +46,7 @@ export function ModalTrigger({
data-layout-id={layoutId}
>
<Comp className={cn('relative', className)}>
<Slottable>{children}</Slottable>
<Slot.Slottable>{children}</Slot.Slottable>
<div
className="@container-[size] absolute inset-0 -z-10 flex items-center justify-center"
@@ -1,7 +1,7 @@
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
import * as React from 'react'
import { createContext, useContext, useMemo, useRef, useState } from 'react'
import { createContext, useContext, useRef, useState } from 'react'
import { cn } from '@nyanpasu/ui'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
interface ScrollAreaContextValue {
isScrolling: boolean
@@ -179,7 +179,7 @@ export function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
}: React.ComponentProps<typeof ScrollAreaPrimitive.Scrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
@@ -2,6 +2,7 @@ import ArrowDropDown from '~icons/material-symbols/arrow-drop-down-rounded'
import Check from '~icons/material-symbols/check-rounded'
import { cva, type VariantProps } from 'class-variance-authority'
import { AnimatePresence, motion } from 'framer-motion'
import { Select as SelectPrimitive } from 'radix-ui'
import {
ComponentProps,
createContext,
@@ -12,7 +13,6 @@ import {
} from 'react'
import { chains } from '@/utils/chain'
import { cn } from '@nyanpasu/ui'
import * as SelectPrimitive from '@radix-ui/react-select'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
export const selectTriggerVariants = cva(
@@ -1,6 +1,6 @@
import { Separator as SeparatorPrimitive } from 'radix-ui'
import { ComponentProps } from 'react'
import { cn } from '@nyanpasu/ui'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
export function Separator({
className,
@@ -1,6 +1,6 @@
import { Switch as SwitchPrimitives } from 'radix-ui'
import React, { ComponentProps } from 'react'
import { cn } from '@nyanpasu/ui'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { CircularProgress } from './progress'
export const Switch = ({
@@ -1,6 +1,6 @@
import { Tooltip as TooltipPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@nyanpasu/ui'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
export function TooltipProvider({
delayDuration = 0,
@@ -28,7 +28,11 @@ import { m } from '@/paraglide/messages'
import { formatError } from '@/utils'
import { message } from '@/utils/notification'
import { zodResolver } from '@hookform/resolvers/zod'
import { LocalProfileBuilder, useProfile } from '@nyanpasu/interface'
import {
LocalProfileBuilder,
ProfileTemplate,
useProfile,
} from '@nyanpasu/interface'
import { useLocation } from '@tanstack/react-router'
import AnimatedErrorItem from '../../_modules/error-item'
import { Action, Route as IndexRoute } from '../index'
@@ -68,6 +72,8 @@ export default function LocalProfileButton({ children }: PropsWithChildren) {
const [open, setOpen] = useState(false)
const [profileContent, setProfileContent] = useState<string | null>(null)
useEffect(() => {
if (action === Action.ImportLocalProfile) {
// if the current path is the index page, open the modal immediately
@@ -85,6 +91,7 @@ export default function LocalProfileButton({ children }: PropsWithChildren) {
clearTimeout(timeout)
}
}
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps
}, [action])
const form = useForm<z.infer<typeof formSchema>>({
@@ -103,7 +110,7 @@ export default function LocalProfileButton({ children }: PropsWithChildren) {
type: 'local',
...data,
},
fileData: null,
fileData: profileContent || ProfileTemplate.profile,
},
})
@@ -206,7 +213,9 @@ export default function LocalProfileButton({ children }: PropsWithChildren) {
onChange={(name) => {
form.setValue('desc', name)
}}
onFileRead={(value) => field.onChange(value)}
onFileRead={(value) => {
setProfileContent(value)
}}
disabled={blockTask.isPending}
>
<FileDropZonePlaceholder className="flex flex-col items-center justify-center gap-2">
@@ -232,7 +241,7 @@ export default function LocalProfileButton({ children }: PropsWithChildren) {
<div className="text-on-surface max-w-full truncate text-sm font-medium">
{m.profile_import_local_file_size_label({
size: filesize(form.watch('file')?.length ?? 0),
size: filesize(profileContent?.length ?? 0),
})}
</div>
</FileDropZoneFileSelected>
@@ -36,7 +36,7 @@ export default function ProfilesHeader() {
<div
className={cn(
'flex items-center gap-2 p-4',
'sticky top-0 z-10',
'sticky top-0 z-50',
'backdrop-blur-xl',
)}
>
@@ -1,7 +1,8 @@
import DeleteForeverOutlineRounded from '~icons/material-symbols/delete-forever-outline-rounded'
import DragClickRounded from '~icons/material-symbols/drag-click-rounded'
import { AnimatePresence, motion } from 'framer-motion'
import { ComponentProps } from 'react'
import { isEqual } from 'lodash-es'
import { ComponentProps, useRef } from 'react'
import {
RegisterContextMenu,
RegisterContextMenuContent,
@@ -14,6 +15,9 @@ import { ContextMenuItem } from '@/components/ui/context-menu'
import { LinearProgress } from '@/components/ui/progress'
import TextMarquee from '@/components/ui/text-marquee'
import { m } from '@/paraglide/messages'
import { move } from '@dnd-kit/helpers'
import { DragDropProvider } from '@dnd-kit/react'
import { useSortable } from '@dnd-kit/react/sortable'
import { hexFromArgb } from '@material/material-color-utilities'
import { Profile, useProfile } from '@nyanpasu/interface'
import { cn } from '@nyanpasu/ui'
@@ -38,7 +42,13 @@ const Chip = ({ children, className, ...props }: ComponentProps<'span'>) => {
)
}
const GridViewProfile = ({ profile }: { profile: Profile }) => {
const GridViewProfile = ({
profile,
index,
}: {
profile: Profile
index: number
}) => {
const { type } = IndexRoute.useParams()
const activeProfile = useActiveProfile(profile)
@@ -50,84 +60,95 @@ const GridViewProfile = ({ profile }: { profile: Profile }) => {
const { themePalette } = useExperimentalThemeContext()
const cardRef = useRef<HTMLDivElement>(null)
const { isDragging: _isDragging } = useSortable({
id: profile.uid,
index,
element: cardRef.current,
})
return (
<RegisterContextMenu>
<RegisterContextMenuTrigger asChild>
<Card
data-slot="profile-card"
className="relative flex flex-col justify-between"
asChild
>
<AnimatePresence initial={false}>
{isPending && (
<motion.div
data-slot="profile-card-mask"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={cn(
'bg-primary/10 absolute inset-0 z-50 backdrop-blur-3xl',
'flex flex-col items-center justify-center gap-2',
)}
>
<LinearProgress className="w-2/3 max-w-60" indeterminate />
<div ref={cardRef}>
<AnimatePresence initial={false}>
{isPending && (
<motion.div
data-slot="profile-card-mask"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={cn(
'bg-primary/10 absolute inset-0 z-50 backdrop-blur-3xl',
'flex flex-col items-center justify-center gap-2',
)}
>
<LinearProgress className="w-2/3 max-w-60" indeterminate />
<p className="text-on-surface-variant text-xs">
{m.profile_pending_mask_message()}
</p>
</motion.div>
)}
</AnimatePresence>
{activeProfile.isActive && (
<MeshGradient
className="absolute inset-0 size-full opacity-30"
colors={Object.values(themePalette.schemes.light).map((color) =>
hexFromArgb(color),
<p className="text-on-surface-variant text-xs">
{m.profile_pending_mask_message()}
</p>
</motion.div>
)}
distortion={0.5}
swirl={0.1}
grainMixer={0}
grainOverlay={0}
speed={1 / 3}
/>
)}
<CardHeader
className="flex items-center justify-between gap-2"
data-slot="profile-card-title"
>
<TextMarquee className="z-10 min-w-0 flex-1">
{profile.name}
</TextMarquee>
</AnimatePresence>
{activeProfile.isActive && (
<Chip className="shrink-0">{m.profile_is_active_label()}</Chip>
<MeshGradient
className="absolute inset-0 size-full opacity-30"
colors={Object.values(themePalette.schemes.light).map((color) =>
hexFromArgb(color),
)}
distortion={0.5}
swirl={0.1}
grainMixer={0}
grainOverlay={0}
speed={1 / 3}
/>
)}
</CardHeader>
<CardContent>
<div className="z-10" data-slot="profile-card-type">
{isRemote ? (
<Chip>{m.profile_remote_label()}</Chip>
) : (
<Chip>{m.profile_local_label()}</Chip>
<CardHeader
className="flex items-center justify-between gap-2"
data-slot="profile-card-title"
>
<TextMarquee className="z-10 min-w-0 flex-1">
{profile.name}
</TextMarquee>
{activeProfile.isActive && (
<Chip className="shrink-0">{m.profile_is_active_label()}</Chip>
)}
</div>
</CardContent>
</CardHeader>
<CardFooter>
<Button className="flex items-center justify-center" asChild>
<Link
to="/main/profiles/$type/detail/$uid"
params={{
type,
uid: profile.uid,
}}
>
{m.profile_view_details_title()}
</Link>
</Button>
</CardFooter>
<CardContent>
<div className="z-10" data-slot="profile-card-type">
{isRemote ? (
<Chip>{m.profile_remote_label()}</Chip>
) : (
<Chip>{m.profile_local_label()}</Chip>
)}
</div>
</CardContent>
<CardFooter>
<Button className="flex items-center justify-center" asChild>
<Link
to="/main/profiles/$type/detail/$uid"
params={{
type,
uid: profile.uid,
}}
>
{m.profile_view_details_title()}
</Link>
</Button>
</CardFooter>
</div>
</Card>
</RegisterContextMenuTrigger>
@@ -183,6 +204,7 @@ export default function ProfilesList({
const {
query: { data: profiles },
sort,
} = useProfile()
// Type guard: restrict type to the allowed PROFILE_TYPES keys
@@ -226,21 +248,39 @@ export default function ProfilesList({
'sm:min-h-[calc(100vh-40px-48px)]',
)}
>
<div
className={cn(
'grid gap-2',
'md:grid-cols-2',
'lg:grid-cols-3',
'dxl:grid-cols-4',
className,
)}
data-slot="profiles-navigate"
{...props}
<DragDropProvider
onDragEnd={(event) => {
const currentUids = filteredProfiles.map((profile) => profile.uid)
const nextUids = move(currentUids, event)
if (isEqual(currentUids, nextUids)) {
return
}
sort.mutate(nextUids)
}}
>
{filteredProfiles.map((profile) => (
<GridViewProfile key={profile.uid} profile={profile} />
))}
</div>
<div
className={cn(
'grid gap-2',
'md:grid-cols-2',
'lg:grid-cols-3',
'dxl:grid-cols-4',
className,
)}
data-slot="profiles-navigate"
{...props}
>
{filteredProfiles.map((profile, index) => (
<GridViewProfile
key={profile.uid}
profile={profile}
index={index}
/>
))}
</div>
</DragDropProvider>
<div className="flex-1" />
</div>
@@ -48,7 +48,7 @@ export function SettingsTitle({
const id = useId()
const showTopTitle = offset.top > 60
const showTopTitle = offset.top > 40
return (
<>
+3 -3
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 1,
"latest": {
"mihomo": "v1.19.20",
"mihomo_alpha": "alpha-4c35436",
"mihomo": "v1.19.21",
"mihomo_alpha": "alpha-e28fe24",
"clash_rs": "v0.9.4",
"clash_premium": "2023-09-05-gdcc8d87",
"clash_rs_alpha": "latest"
@@ -69,5 +69,5 @@
"linux-armv7hf": "clash-armv7-unknown-linux-gnueabihf"
}
},
"updated_at": "2026-03-08T22:22:02.470Z"
"updated_at": "2026-03-09T22:22:50.198Z"
}
+4 -4
View File
@@ -69,9 +69,9 @@
"dedent": "1.7.2",
"globals": "17.4.0",
"knip": "5.86.0",
"lint-staged": "16.3.2",
"lint-staged": "16.3.3",
"npm-run-all2": "8.0.4",
"oxlint": "1.51.0",
"oxlint": "1.52.0",
"postcss": "8.5.8",
"postcss-html": "1.8.1",
"postcss-import": "16.1.1",
@@ -85,13 +85,13 @@
"stylelint-config-recess-order": "7.6.1",
"stylelint-config-standard": "40.0.0",
"stylelint-declaration-block-no-ignored-properties": "3.0.0",
"stylelint-order": "7.0.1",
"stylelint-order": "8.0.0",
"stylelint-scss": "7.0.0",
"tailwindcss": "4.2.1",
"tsx": "4.21.0",
"typescript": "5.9.3"
},
"packageManager": "pnpm@10.31.0",
"packageManager": "pnpm@10.32.0",
"engines": {
"node": "24.14.0"
},
+994 -191
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -27,8 +27,7 @@ type Trojan struct {
hexPassword [trojan.KeyLength]byte
// for gun mux
gunConfig *gun.Config
gunTransport *gun.TransportWrap
gunTransport *gun.Transport
realityConfig *tlsC.RealityConfig
echConfig *ech.Config
@@ -178,7 +177,7 @@ func (t *Trojan) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Con
var c net.Conn
// gun transport
if t.gunTransport != nil {
c, err = gun.StreamGunWithTransport(t.gunTransport, t.gunConfig)
c, err = t.gunTransport.Dial()
} else {
c, err = t.dialer.DialContext(ctx, "tcp", t.addr)
}
@@ -206,7 +205,7 @@ func (t *Trojan) ListenPacketContext(ctx context.Context, metadata *C.Metadata)
var c net.Conn
// grpc transport
if t.gunTransport != nil {
c, err = gun.StreamGunWithTransport(t.gunTransport, t.gunConfig)
c, err = t.gunTransport.Dial()
} else {
c, err = t.dialer.DialContext(ctx, "tcp", t.addr)
}
@@ -317,13 +316,14 @@ func NewTrojan(option TrojanOption) (*Trojan, error) {
Reality: t.realityConfig,
}
t.gunTransport = gun.NewHTTP2Client(dialFn, tlsConfig)
t.gunConfig = &gun.Config{
ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.SNI,
gunConfig := &gun.Config{
ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.SNI,
PingInterval: option.GrpcOpts.PingInterval,
}
t.gunTransport = gun.NewTransport(dialFn, tlsConfig, gunConfig)
}
return t, nil
+8 -10
View File
@@ -33,8 +33,7 @@ type Vless struct {
encryption *encryption.ClientInstance
// for gun mux
gunConfig *gun.Config
gunTransport *gun.TransportWrap
gunTransport *gun.Transport
realityConfig *tlsC.RealityConfig
echConfig *ech.Config
@@ -234,7 +233,7 @@ func (v *Vless) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn
var c net.Conn
// gun transport
if v.gunTransport != nil {
c, err = gun.StreamGunWithTransport(v.gunTransport, v.gunConfig)
c, err = v.gunTransport.Dial()
} else {
c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
}
@@ -260,7 +259,7 @@ func (v *Vless) ListenPacketContext(ctx context.Context, metadata *C.Metadata) (
var c net.Conn
// gun transport
if v.gunTransport != nil {
c, err = gun.StreamGunWithTransport(v.gunTransport, v.gunConfig)
c, err = v.gunTransport.Dial()
} else {
c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
}
@@ -431,9 +430,10 @@ func NewVless(option VlessOption) (*Vless, error) {
}
gunConfig := &gun.Config{
ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.ServerName,
ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.ServerName,
PingInterval: option.GrpcOpts.PingInterval,
}
if option.ServerName == "" {
gunConfig.Host = v.addr
@@ -457,9 +457,7 @@ func NewVless(option VlessOption) (*Vless, error) {
}
}
v.gunConfig = gunConfig
v.gunTransport = gun.NewHTTP2Client(dialFn, tlsConfig)
v.gunTransport = gun.NewTransport(dialFn, tlsConfig, gunConfig)
}
return v, nil
+9 -10
View File
@@ -34,8 +34,7 @@ type Vmess struct {
option *VmessOption
// for gun mux
gunConfig *gun.Config
gunTransport *gun.TransportWrap
gunTransport *gun.Transport
realityConfig *tlsC.RealityConfig
echConfig *ech.Config
@@ -86,6 +85,7 @@ type HTTP2Options struct {
type GrpcOptions struct {
GrpcServiceName string `proxy:"grpc-service-name,omitempty"`
GrpcUserAgent string `proxy:"grpc-user-agent,omitempty"`
PingInterval int `proxy:"ping-interval,omitempty"`
}
type WSOptions struct {
@@ -295,7 +295,7 @@ func (v *Vmess) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn
var c net.Conn
// gun transport
if v.gunTransport != nil {
c, err = gun.StreamGunWithTransport(v.gunTransport, v.gunConfig)
c, err = v.gunTransport.Dial()
} else {
c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
}
@@ -318,7 +318,7 @@ func (v *Vmess) ListenPacketContext(ctx context.Context, metadata *C.Metadata) (
var c net.Conn
// gun transport
if v.gunTransport != nil {
c, err = gun.StreamGunWithTransport(v.gunTransport, v.gunConfig)
c, err = v.gunTransport.Dial()
} else {
c, err = v.dialer.DialContext(ctx, "tcp", v.addr)
}
@@ -437,9 +437,10 @@ func NewVmess(option VmessOption) (*Vmess, error) {
}
gunConfig := &gun.Config{
ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.ServerName,
ServiceName: option.GrpcOpts.GrpcServiceName,
UserAgent: option.GrpcOpts.GrpcUserAgent,
Host: option.ServerName,
PingInterval: option.GrpcOpts.PingInterval,
}
if option.ServerName == "" {
gunConfig.Host = v.addr
@@ -463,9 +464,7 @@ func NewVmess(option VmessOption) (*Vmess, error) {
}
}
v.gunConfig = gunConfig
v.gunTransport = gun.NewHTTP2Client(dialFn, tlsConfig)
v.gunTransport = gun.NewTransport(dialFn, tlsConfig, gunConfig)
}
return v, nil
+2 -2
View File
@@ -18,8 +18,8 @@ var (
type healthCheckSchema struct {
Enable bool `provider:"enable"`
URL string `provider:"url"`
Interval int `provider:"interval"`
URL string `provider:"url,omitempty"`
Interval int `provider:"interval,omitempty"`
TestTimeout int `provider:"timeout,omitempty"`
Lazy bool `provider:"lazy,omitempty"`
ExpectedStatus string `provider:"expected-status,omitempty"`
+3
View File
@@ -669,6 +669,7 @@ proxies: # socks5
grpc-opts:
grpc-service-name: "example"
# grpc-user-agent: "grpc-go/1.36.0"
# ping-interval: 0 # 默认关闭,单位为秒
# ip-version: ipv4
# vless
@@ -759,6 +760,7 @@ proxies: # socks5
grpc-opts:
grpc-service-name: "grpc"
# grpc-user-agent: "grpc-go/1.36.0"
# ping-interval: 0 # 默认关闭,单位为秒
reality-opts:
public-key: CrrQSjAG_YkHLwvM2M-7XkKJilgL5upBKCp0od0tLhE
@@ -830,6 +832,7 @@ proxies: # socks5
grpc-opts:
grpc-service-name: "example"
# grpc-user-agent: "grpc-go/1.36.0"
# ping-interval: 0 # 默认关闭,单位为秒
- name: trojan-ws
server: server
+23 -20
View File
@@ -59,9 +59,10 @@ type Conn struct {
}
type Config struct {
ServiceName string
UserAgent string
Host string
ServiceName string
UserAgent string
Host string
PingInterval int
}
func (g *Conn) initReader() {
@@ -246,7 +247,7 @@ func (g *Conn) SetDeadline(t time.Time) error {
return nil
}
func NewHTTP2Client(dialFn DialFn, tlsConfig *vmess.TLSConfig) *TransportWrap {
func NewTransport(dialFn DialFn, tlsConfig *vmess.TLSConfig, gunCfg *Config) *Transport {
dialFunc := func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
ctx, cancel := context.WithTimeout(ctx, C.DefaultTLSTimeout)
defer cancel()
@@ -288,14 +289,16 @@ func NewHTTP2Client(dialFn DialFn, tlsConfig *vmess.TLSConfig) *TransportWrap {
DialTLSContext: dialFunc,
AllowHTTP: false,
DisableCompression: true,
ReadIdleTimeout: time.Duration(gunCfg.PingInterval) * time.Second, // If zero, no health check is performed
PingTimeout: 0,
}
ctx, cancel := context.WithCancel(context.Background())
wrap := &TransportWrap{
Http2Transport: transport,
ctx: ctx,
cancel: cancel,
wrap := &Transport{
transport: transport,
cfg: gunCfg,
ctx: ctx,
cancel: cancel,
}
return wrap
}
@@ -307,18 +310,18 @@ func ServiceNameToPath(serviceName string) string {
return "/" + serviceName + "/Tun"
}
func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, error) {
func (t *Transport) Dial() (net.Conn, error) {
serviceName := "GunService"
if cfg.ServiceName != "" {
serviceName = cfg.ServiceName
if t.cfg.ServiceName != "" {
serviceName = t.cfg.ServiceName
}
path := ServiceNameToPath(serviceName)
reader, writer := io.Pipe()
header := defaultHeader.Clone()
if cfg.UserAgent != "" {
header.Set("User-Agent", cfg.UserAgent)
if t.cfg.UserAgent != "" {
header.Set("User-Agent", t.cfg.UserAgent)
}
request := &http.Request{
@@ -326,17 +329,17 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er
Body: reader,
URL: &url.URL{
Scheme: "https",
Host: cfg.Host,
Host: t.cfg.Host,
Path: path,
// for unescape path
Opaque: "//" + cfg.Host + path,
Opaque: "//" + t.cfg.Host + path,
},
Proto: "HTTP/2",
ProtoMajor: 2,
ProtoMinor: 0,
Header: header,
}
request = request.WithContext(transport.ctx)
request = request.WithContext(t.ctx)
conn := &Conn{
initFn: func() (io.ReadCloser, NetAddr, error) {
@@ -348,7 +351,7 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er
},
}
request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace))
response, err := transport.RoundTrip(request)
response, err := t.transport.RoundTrip(request)
if err != nil {
return nil, nAddr, err
}
@@ -361,13 +364,13 @@ func StreamGunWithTransport(transport *TransportWrap, cfg *Config) (net.Conn, er
return conn, nil
}
func StreamGunWithConn(conn net.Conn, tlsConfig *vmess.TLSConfig, cfg *Config) (net.Conn, error) {
func StreamGunWithConn(conn net.Conn, tlsConfig *vmess.TLSConfig, gunCfg *Config) (net.Conn, error) {
dialFn := func(ctx context.Context, network, addr string) (net.Conn, error) {
return conn, nil
}
transport := NewHTTP2Client(dialFn, tlsConfig)
c, err := StreamGunWithTransport(transport, cfg)
transport := NewTransport(dialFn, tlsConfig, gunCfg)
c, err := transport.Dial()
if err != nil {
return nil, err
}
+7 -6
View File
@@ -10,17 +10,18 @@ import (
"github.com/metacubex/http"
)
type TransportWrap struct {
*http.Http2Transport
type Transport struct {
transport *http.Http2Transport
cfg *Config
ctx context.Context
cancel context.CancelFunc
closeOnce sync.Once
}
func (tw *TransportWrap) Close() error {
tw.closeOnce.Do(func() {
tw.cancel()
CloseTransport(tw.Http2Transport)
func (t *Transport) Close() error {
t.closeOnce.Do(func() {
t.cancel()
CloseHttp2Transport(t.transport)
})
return nil
}
+1 -1
View File
@@ -44,7 +44,7 @@ func closeClientConn(cc *http.Http2ClientConn) { // like forceCloseConn() in htt
_ = cc.Close()
}
func CloseTransport(tr *http.Http2Transport) {
func CloseHttp2Transport(tr *http.Http2Transport) {
connPool := transportConnPool(tr)
p := (*clientConnPool)((*efaceWords)(unsafe.Pointer(&connPool)).data)
p.mu.Lock()
+115 -37
View File
@@ -5,6 +5,7 @@ import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
@@ -12,6 +13,7 @@ import (
"io"
"net"
"sync"
"sync/atomic"
"golang.org/x/crypto/chacha20poly1305"
)
@@ -55,13 +57,15 @@ type RecordConn struct {
recvAEADEpoch uint32
// Send direction state.
sendEpoch uint32
sendSeq uint64
sendBytes int64
sendEpoch uint32
sendSeq uint64
sendBytes int64
sendEpochUpdates uint32
// Receive direction state.
recvEpoch uint32
recvSeq uint64
recvEpoch uint32
recvSeq uint64
recvInitialized bool
readBuf bytes.Buffer
@@ -105,6 +109,9 @@ func NewRecordConn(conn net.Conn, method string, baseSend, baseRecv []byte) (*Re
}
rc := &RecordConn{Conn: conn, method: method}
rc.keys = recordKeys{baseSend: cloneBytes(baseSend), baseRecv: cloneBytes(baseRecv)}
if err := rc.resetTrafficState(); err != nil {
return nil, err
}
return rc, nil
}
@@ -127,11 +134,9 @@ func (c *RecordConn) Rekey(baseSend, baseRecv []byte) error {
defer c.writeMu.Unlock()
c.keys = recordKeys{baseSend: cloneBytes(baseSend), baseRecv: cloneBytes(baseRecv)}
c.sendEpoch = 0
c.sendSeq = 0
c.sendBytes = 0
c.recvEpoch = 0
c.recvSeq = 0
if err := c.resetTrafficState(); err != nil {
return err
}
c.readBuf.Reset()
c.sendAEAD = nil
@@ -141,6 +146,21 @@ func (c *RecordConn) Rekey(baseSend, baseRecv []byte) error {
return nil
}
func (c *RecordConn) resetTrafficState() error {
sendEpoch, sendSeq, err := randomRecordCounters()
if err != nil {
return fmt.Errorf("initialize record counters: %w", err)
}
c.sendEpoch = sendEpoch
c.sendSeq = sendSeq
c.sendBytes = 0
c.sendEpochUpdates = 0
c.recvEpoch = 0
c.recvSeq = 0
c.recvInitialized = false
return nil
}
func normalizeAEADMethod(method string) string {
switch method {
case "", "chacha20-poly1305":
@@ -166,6 +186,44 @@ func cloneBytes(b []byte) []byte {
return append([]byte(nil), b...)
}
func randomRecordCounters() (uint32, uint64, error) {
epoch, err := randomNonZeroUint32()
if err != nil {
return 0, 0, err
}
seq, err := randomNonZeroUint64()
if err != nil {
return 0, 0, err
}
return epoch, seq, nil
}
func randomNonZeroUint32() (uint32, error) {
var b [4]byte
for {
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
return 0, err
}
v := binary.BigEndian.Uint32(b[:])
if v != 0 && v != ^uint32(0) {
return v, nil
}
}
}
func randomNonZeroUint64() (uint64, error) {
var b [8]byte
for {
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
return 0, err
}
v := binary.BigEndian.Uint64(b[:])
if v != 0 && v != ^uint64(0) {
return v, nil
}
}
}
func (c *RecordConn) newAEADFor(base []byte, epoch uint32) (cipher.AEAD, error) {
if c.method == "none" {
return nil, nil
@@ -209,17 +267,49 @@ func deriveEpochKey(base []byte, epoch uint32, method string) []byte {
return mac.Sum(nil)
}
func (c *RecordConn) maybeBumpSendEpochLocked(addedPlain int) {
if KeyUpdateAfterBytes <= 0 || c.method == "none" {
return
func (c *RecordConn) maybeBumpSendEpochLocked(addedPlain int) error {
ku := atomic.LoadInt64(&KeyUpdateAfterBytes)
if ku <= 0 || c.method == "none" {
return nil
}
c.sendBytes += int64(addedPlain)
threshold := KeyUpdateAfterBytes * int64(c.sendEpoch+1)
threshold := ku * int64(c.sendEpochUpdates+1)
if c.sendBytes < threshold {
return
return nil
}
c.sendEpoch++
c.sendSeq = 0
c.sendEpochUpdates++
nextSeq, err := randomNonZeroUint64()
if err != nil {
return fmt.Errorf("rotate record seq: %w", err)
}
c.sendSeq = nextSeq
return nil
}
func (c *RecordConn) validateRecvPosition(epoch uint32, seq uint64) error {
if !c.recvInitialized {
return nil
}
if epoch < c.recvEpoch {
return fmt.Errorf("replayed epoch: got %d want >=%d", epoch, c.recvEpoch)
}
if epoch == c.recvEpoch && seq != c.recvSeq {
return fmt.Errorf("out of order: epoch=%d got=%d want=%d", epoch, seq, c.recvSeq)
}
if epoch > c.recvEpoch {
const maxJump = 8
if epoch-c.recvEpoch > maxJump {
return fmt.Errorf("epoch jump too large: got=%d want<=%d", epoch-c.recvEpoch, maxJump)
}
}
return nil
}
func (c *RecordConn) markRecvPosition(epoch uint32, seq uint64) {
c.recvEpoch = epoch
c.recvSeq = seq + 1
c.recvInitialized = true
}
func (c *RecordConn) Write(p []byte) (int, error) {
@@ -282,7 +372,9 @@ func (c *RecordConn) Write(p []byte) (int, error) {
}
total += n
c.maybeBumpSendEpochLocked(n)
if err := c.maybeBumpSendEpochLocked(n); err != nil {
return total, err
}
}
return total, nil
}
@@ -324,31 +416,17 @@ func (c *RecordConn) Read(p []byte) (int, error) {
epoch := binary.BigEndian.Uint32(header[:4])
seq := binary.BigEndian.Uint64(header[4:])
if epoch < c.recvEpoch {
return 0, fmt.Errorf("replayed epoch: got %d want >=%d", epoch, c.recvEpoch)
}
if epoch == c.recvEpoch && seq != c.recvSeq {
return 0, fmt.Errorf("out of order: epoch=%d got=%d want=%d", epoch, seq, c.recvSeq)
}
if epoch > c.recvEpoch {
const maxJump = 8
if epoch-c.recvEpoch > maxJump {
return 0, fmt.Errorf("epoch jump too large: got=%d want<=%d", epoch-c.recvEpoch, maxJump)
}
c.recvEpoch = epoch
c.recvSeq = 0
if seq != 0 {
return 0, fmt.Errorf("out of order: epoch advanced to %d but seq=%d", epoch, seq)
}
if err := c.validateRecvPosition(epoch, seq); err != nil {
return 0, err
}
if c.recvAEAD == nil || c.recvAEADEpoch != c.recvEpoch {
a, err := c.newAEADFor(c.keys.baseRecv, c.recvEpoch)
if c.recvAEAD == nil || c.recvAEADEpoch != epoch {
a, err := c.newAEADFor(c.keys.baseRecv, epoch)
if err != nil {
return 0, err
}
c.recvAEAD = a
c.recvAEADEpoch = c.recvEpoch
c.recvAEADEpoch = epoch
}
aead := c.recvAEAD
@@ -356,7 +434,7 @@ func (c *RecordConn) Read(p []byte) (int, error) {
if err != nil {
return 0, fmt.Errorf("decryption failed: epoch=%d seq=%d: %w", epoch, seq, err)
}
c.recvSeq++
c.markRecvPosition(epoch, seq)
c.readBuf.Write(plaintext)
return c.readBuf.Read(p)
@@ -0,0 +1,86 @@
package crypto
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"io"
"net"
"testing"
"time"
)
type captureConn struct {
bytes.Buffer
}
func (c *captureConn) Read(_ []byte) (int, error) { return 0, io.EOF }
func (c *captureConn) Write(p []byte) (int, error) { return c.Buffer.Write(p) }
func (c *captureConn) Close() error { return nil }
func (c *captureConn) LocalAddr() net.Addr { return nil }
func (c *captureConn) RemoteAddr() net.Addr { return nil }
func (c *captureConn) SetDeadline(time.Time) error { return nil }
func (c *captureConn) SetReadDeadline(time.Time) error { return nil }
func (c *captureConn) SetWriteDeadline(time.Time) error { return nil }
type replayConn struct {
reader *bytes.Reader
}
func (c *replayConn) Read(p []byte) (int, error) { return c.reader.Read(p) }
func (c *replayConn) Write(p []byte) (int, error) { return len(p), nil }
func (c *replayConn) Close() error { return nil }
func (c *replayConn) LocalAddr() net.Addr { return nil }
func (c *replayConn) RemoteAddr() net.Addr { return nil }
func (c *replayConn) SetDeadline(time.Time) error { return nil }
func (c *replayConn) SetReadDeadline(time.Time) error { return nil }
func (c *replayConn) SetWriteDeadline(time.Time) error { return nil }
func TestRecordConn_FirstFrameUsesRandomizedCounters(t *testing.T) {
pskSend := sha256.Sum256([]byte("record-send"))
pskRecv := sha256.Sum256([]byte("record-recv"))
raw := &captureConn{}
writer, err := NewRecordConn(raw, "chacha20-poly1305", pskSend[:], pskRecv[:])
if err != nil {
t.Fatalf("new writer: %v", err)
}
if writer.sendEpoch == 0 || writer.sendSeq == 0 {
t.Fatalf("expected non-zero randomized counters, got epoch=%d seq=%d", writer.sendEpoch, writer.sendSeq)
}
want := []byte("record prefix camouflage")
if _, err := writer.Write(want); err != nil {
t.Fatalf("write: %v", err)
}
wire := raw.Bytes()
if len(wire) < 2+recordHeaderSize {
t.Fatalf("short frame: %d", len(wire))
}
bodyLen := int(binary.BigEndian.Uint16(wire[:2]))
if bodyLen != len(wire)-2 {
t.Fatalf("body len mismatch: got %d want %d", bodyLen, len(wire)-2)
}
epoch := binary.BigEndian.Uint32(wire[2:6])
seq := binary.BigEndian.Uint64(wire[6:14])
if epoch == 0 || seq == 0 {
t.Fatalf("wire header still starts from zero: epoch=%d seq=%d", epoch, seq)
}
reader, err := NewRecordConn(&replayConn{reader: bytes.NewReader(wire)}, "chacha20-poly1305", pskRecv[:], pskSend[:])
if err != nil {
t.Fatalf("new reader: %v", err)
}
got := make([]byte, len(want))
if _, err := io.ReadFull(reader, got); err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("plaintext mismatch: got %q want %q", got, want)
}
}
+345
View File
@@ -0,0 +1,345 @@
package sudoku
import (
"bytes"
"crypto/ecdh"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"time"
"github.com/metacubex/mihomo/transport/sudoku/crypto"
httpmaskobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/httpmask"
sudokuobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku"
)
const earlyKIPHandshakeTTL = 60 * time.Second
type EarlyCodecConfig struct {
PSK string
AEAD string
EnablePureDownlink bool
PaddingMin int
PaddingMax int
}
type EarlyClientState struct {
RequestPayload []byte
cfg EarlyCodecConfig
table *sudokuobfs.Table
nonce [kipHelloNonceSize]byte
ephemeral *ecdh.PrivateKey
sessionC2S []byte
sessionS2C []byte
responseSet bool
}
type EarlyServerState struct {
ResponsePayload []byte
UserHash string
cfg EarlyCodecConfig
table *sudokuobfs.Table
sessionC2S []byte
sessionS2C []byte
}
type ReplayAllowFunc func(userHash string, nonce [kipHelloNonceSize]byte, now time.Time) bool
type earlyMemoryConn struct {
reader *bytes.Reader
write bytes.Buffer
}
func newEarlyMemoryConn(readBuf []byte) *earlyMemoryConn {
return &earlyMemoryConn{reader: bytes.NewReader(readBuf)}
}
func (c *earlyMemoryConn) Read(p []byte) (int, error) {
if c == nil || c.reader == nil {
return 0, net.ErrClosed
}
return c.reader.Read(p)
}
func (c *earlyMemoryConn) Write(p []byte) (int, error) {
if c == nil {
return 0, net.ErrClosed
}
return c.write.Write(p)
}
func (c *earlyMemoryConn) Close() error { return nil }
func (c *earlyMemoryConn) LocalAddr() net.Addr { return earlyDummyAddr("local") }
func (c *earlyMemoryConn) RemoteAddr() net.Addr { return earlyDummyAddr("remote") }
func (c *earlyMemoryConn) SetDeadline(time.Time) error { return nil }
func (c *earlyMemoryConn) SetReadDeadline(time.Time) error { return nil }
func (c *earlyMemoryConn) SetWriteDeadline(time.Time) error { return nil }
func (c *earlyMemoryConn) Written() []byte { return append([]byte(nil), c.write.Bytes()...) }
type earlyDummyAddr string
func (a earlyDummyAddr) Network() string { return string(a) }
func (a earlyDummyAddr) String() string { return string(a) }
func buildEarlyClientObfsConn(raw net.Conn, cfg EarlyCodecConfig, table *sudokuobfs.Table) net.Conn {
base := sudokuobfs.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
if cfg.EnablePureDownlink {
return base
}
packed := sudokuobfs.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
return newDirectionalConn(raw, packed, base)
}
func buildEarlyServerObfsConn(raw net.Conn, cfg EarlyCodecConfig, table *sudokuobfs.Table) net.Conn {
uplink := sudokuobfs.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false)
if cfg.EnablePureDownlink {
return uplink
}
packed := sudokuobfs.NewPackedConn(raw, table, cfg.PaddingMin, cfg.PaddingMax)
return newDirectionalConn(raw, uplink, packed, packed.Flush)
}
func NewEarlyClientState(cfg EarlyCodecConfig, table *sudokuobfs.Table, userHash [kipHelloUserHashSize]byte, feats uint32) (*EarlyClientState, error) {
if table == nil {
return nil, fmt.Errorf("nil table")
}
curve := ecdh.X25519()
ephemeral, err := curve.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("ecdh generate failed: %w", err)
}
var nonce [kipHelloNonceSize]byte
if _, err := rand.Read(nonce[:]); err != nil {
return nil, fmt.Errorf("nonce generate failed: %w", err)
}
var clientPub [kipHelloPubSize]byte
copy(clientPub[:], ephemeral.PublicKey().Bytes())
hello := &KIPClientHello{
Timestamp: time.Now(),
UserHash: userHash,
Nonce: nonce,
ClientPub: clientPub,
Features: feats,
}
mem := newEarlyMemoryConn(nil)
obfsConn := buildEarlyClientObfsConn(mem, cfg, table)
pskC2S, pskS2C := derivePSKDirectionalBases(cfg.PSK)
rc, err := crypto.NewRecordConn(obfsConn, cfg.AEAD, pskC2S, pskS2C)
if err != nil {
return nil, fmt.Errorf("client early crypto setup failed: %w", err)
}
if err := WriteKIPMessage(rc, KIPTypeClientHello, hello.EncodePayload()); err != nil {
return nil, fmt.Errorf("write early client hello failed: %w", err)
}
return &EarlyClientState{
RequestPayload: mem.Written(),
cfg: cfg,
table: table,
nonce: nonce,
ephemeral: ephemeral,
}, nil
}
func (s *EarlyClientState) ProcessResponse(payload []byte) error {
if s == nil {
return fmt.Errorf("nil client state")
}
mem := newEarlyMemoryConn(payload)
obfsConn := buildEarlyClientObfsConn(mem, s.cfg, s.table)
pskC2S, pskS2C := derivePSKDirectionalBases(s.cfg.PSK)
rc, err := crypto.NewRecordConn(obfsConn, s.cfg.AEAD, pskC2S, pskS2C)
if err != nil {
return fmt.Errorf("client early crypto setup failed: %w", err)
}
msg, err := ReadKIPMessage(rc)
if err != nil {
return fmt.Errorf("read early server hello failed: %w", err)
}
if msg.Type != KIPTypeServerHello {
return fmt.Errorf("unexpected early handshake message: %d", msg.Type)
}
sh, err := DecodeKIPServerHelloPayload(msg.Payload)
if err != nil {
return fmt.Errorf("decode early server hello failed: %w", err)
}
if sh.Nonce != s.nonce {
return fmt.Errorf("early handshake nonce mismatch")
}
shared, err := x25519SharedSecret(s.ephemeral, sh.ServerPub[:])
if err != nil {
return fmt.Errorf("ecdh failed: %w", err)
}
s.sessionC2S, s.sessionS2C, err = deriveSessionDirectionalBases(s.cfg.PSK, shared, s.nonce)
if err != nil {
return fmt.Errorf("derive session keys failed: %w", err)
}
s.responseSet = true
return nil
}
func (s *EarlyClientState) WrapConn(raw net.Conn) (net.Conn, error) {
if s == nil {
return nil, fmt.Errorf("nil client state")
}
if !s.responseSet {
return nil, fmt.Errorf("early handshake not completed")
}
obfsConn := buildEarlyClientObfsConn(raw, s.cfg, s.table)
rc, err := crypto.NewRecordConn(obfsConn, s.cfg.AEAD, s.sessionC2S, s.sessionS2C)
if err != nil {
return nil, fmt.Errorf("setup client session crypto failed: %w", err)
}
return rc, nil
}
func (s *EarlyClientState) Ready() bool {
return s != nil && s.responseSet
}
func NewHTTPMaskClientEarlyHandshake(cfg EarlyCodecConfig, table *sudokuobfs.Table, userHash [kipHelloUserHashSize]byte, feats uint32) (*httpmaskobfs.ClientEarlyHandshake, error) {
state, err := NewEarlyClientState(cfg, table, userHash, feats)
if err != nil {
return nil, err
}
return &httpmaskobfs.ClientEarlyHandshake{
RequestPayload: state.RequestPayload,
HandleResponse: state.ProcessResponse,
Ready: state.Ready,
WrapConn: state.WrapConn,
}, nil
}
func ProcessEarlyClientPayload(cfg EarlyCodecConfig, tables []*sudokuobfs.Table, payload []byte, allowReplay ReplayAllowFunc) (*EarlyServerState, error) {
if len(payload) == 0 {
return nil, fmt.Errorf("empty early payload")
}
if len(tables) == 0 {
return nil, fmt.Errorf("no tables configured")
}
var firstErr error
for _, table := range tables {
state, err := processEarlyClientPayloadForTable(cfg, table, payload, allowReplay)
if err == nil {
return state, nil
}
if firstErr == nil {
firstErr = err
}
}
if firstErr == nil {
firstErr = fmt.Errorf("early handshake probe failed")
}
return nil, firstErr
}
func processEarlyClientPayloadForTable(cfg EarlyCodecConfig, table *sudokuobfs.Table, payload []byte, allowReplay ReplayAllowFunc) (*EarlyServerState, error) {
mem := newEarlyMemoryConn(payload)
obfsConn := buildEarlyServerObfsConn(mem, cfg, table)
pskC2S, pskS2C := derivePSKDirectionalBases(cfg.PSK)
rc, err := crypto.NewRecordConn(obfsConn, cfg.AEAD, pskS2C, pskC2S)
if err != nil {
return nil, err
}
msg, err := ReadKIPMessage(rc)
if err != nil {
return nil, err
}
if msg.Type != KIPTypeClientHello {
return nil, fmt.Errorf("unexpected handshake message: %d", msg.Type)
}
ch, err := DecodeKIPClientHelloPayload(msg.Payload)
if err != nil {
return nil, err
}
if absInt64(time.Now().Unix()-ch.Timestamp.Unix()) > int64(earlyKIPHandshakeTTL.Seconds()) {
return nil, fmt.Errorf("time skew/replay")
}
userHash := hex.EncodeToString(ch.UserHash[:])
if allowReplay != nil && !allowReplay(userHash, ch.Nonce, time.Now()) {
return nil, fmt.Errorf("replay detected")
}
curve := ecdh.X25519()
serverEphemeral, err := curve.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("ecdh generate failed: %w", err)
}
shared, err := x25519SharedSecret(serverEphemeral, ch.ClientPub[:])
if err != nil {
return nil, fmt.Errorf("ecdh failed: %w", err)
}
sessionC2S, sessionS2C, err := deriveSessionDirectionalBases(cfg.PSK, shared, ch.Nonce)
if err != nil {
return nil, fmt.Errorf("derive session keys failed: %w", err)
}
var serverPub [kipHelloPubSize]byte
copy(serverPub[:], serverEphemeral.PublicKey().Bytes())
serverHello := &KIPServerHello{
Nonce: ch.Nonce,
ServerPub: serverPub,
SelectedFeats: ch.Features & KIPFeatAll,
}
respMem := newEarlyMemoryConn(nil)
respObfs := buildEarlyServerObfsConn(respMem, cfg, table)
respConn, err := crypto.NewRecordConn(respObfs, cfg.AEAD, pskS2C, pskC2S)
if err != nil {
return nil, fmt.Errorf("server early crypto setup failed: %w", err)
}
if err := WriteKIPMessage(respConn, KIPTypeServerHello, serverHello.EncodePayload()); err != nil {
return nil, fmt.Errorf("write early server hello failed: %w", err)
}
return &EarlyServerState{
ResponsePayload: respMem.Written(),
UserHash: userHash,
cfg: cfg,
table: table,
sessionC2S: sessionC2S,
sessionS2C: sessionS2C,
}, nil
}
func (s *EarlyServerState) WrapConn(raw net.Conn) (net.Conn, error) {
if s == nil {
return nil, fmt.Errorf("nil server state")
}
obfsConn := buildEarlyServerObfsConn(raw, s.cfg, s.table)
rc, err := crypto.NewRecordConn(obfsConn, s.cfg.AEAD, s.sessionS2C, s.sessionC2S)
if err != nil {
return nil, fmt.Errorf("setup server session crypto failed: %w", err)
}
return rc, nil
}
func NewHTTPMaskServerEarlyHandshake(cfg EarlyCodecConfig, tables []*sudokuobfs.Table, allowReplay ReplayAllowFunc) *httpmaskobfs.TunnelServerEarlyHandshake {
return &httpmaskobfs.TunnelServerEarlyHandshake{
Prepare: func(payload []byte) (*httpmaskobfs.PreparedServerEarlyHandshake, error) {
state, err := ProcessEarlyClientPayload(cfg, tables, payload, allowReplay)
if err != nil {
return nil, err
}
return &httpmaskobfs.PreparedServerEarlyHandshake{
ResponsePayload: state.ResponsePayload,
WrapConn: state.WrapConn,
UserHash: state.UserHash,
}, nil
},
}
}
+3
View File
@@ -337,6 +337,9 @@ func ServerHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, *Handshak
if err := cfg.Validate(); err != nil {
return nil, nil, fmt.Errorf("invalid config: %w", err)
}
if userHash, ok := httpmask.EarlyHandshakeUserHash(rawConn); ok {
return rawConn, &HandshakeMeta{UserHash: userHash}, nil
}
handshakeTimeout := time.Duration(cfg.HandshakeTimeoutSeconds) * time.Second
if handshakeTimeout <= 0 {
+48 -8
View File
@@ -14,6 +14,30 @@ type HTTPMaskTunnelServer struct {
ts *httpmask.TunnelServer
}
func newHTTPMaskEarlyCodecConfig(cfg *ProtocolConfig, psk string) EarlyCodecConfig {
return EarlyCodecConfig{
PSK: psk,
AEAD: cfg.AEADMethod,
EnablePureDownlink: cfg.EnablePureDownlink,
PaddingMin: cfg.PaddingMin,
PaddingMax: cfg.PaddingMax,
}
}
func newClientHTTPMaskEarlyHandshake(cfg *ProtocolConfig) (*httpmask.ClientEarlyHandshake, error) {
table, err := pickClientTable(cfg)
if err != nil {
return nil, err
}
return NewHTTPMaskClientEarlyHandshake(
newHTTPMaskEarlyCodecConfig(cfg, ClientAEADSeed(cfg.Key)),
table,
kipUserHashFromKey(cfg.Key),
KIPFeatAll,
)
}
func NewHTTPMaskTunnelServer(cfg *ProtocolConfig) *HTTPMaskTunnelServer {
return newHTTPMaskTunnelServer(cfg, false)
}
@@ -35,6 +59,11 @@ func newHTTPMaskTunnelServer(cfg *ProtocolConfig, passThroughOnReject bool) *HTT
Mode: cfg.HTTPMaskMode,
PathRoot: cfg.HTTPMaskPathRoot,
AuthKey: ServerAEADSeed(cfg.Key),
EarlyHandshake: NewHTTPMaskServerEarlyHandshake(
newHTTPMaskEarlyCodecConfig(cfg, ServerAEADSeed(cfg.Key)),
cfg.tableCandidates(),
globalHandshakeReplay.allow,
),
// When upstream fallback is enabled, preserve rejected HTTP requests for the caller.
PassThroughOnReject: passThroughOnReject,
})
@@ -101,14 +130,25 @@ func DialHTTPMaskTunnel(ctx context.Context, serverAddress string, cfg *Protocol
default:
return nil, fmt.Errorf("http-mask-mode=%q does not use http tunnel", cfg.HTTPMaskMode)
}
var (
earlyHandshake *httpmask.ClientEarlyHandshake
err error
)
if upgrade != nil {
earlyHandshake, err = newClientHTTPMaskEarlyHandshake(cfg)
if err != nil {
return nil, err
}
}
return httpmask.DialTunnel(ctx, serverAddress, httpmask.TunnelDialOptions{
Mode: cfg.HTTPMaskMode,
TLSEnabled: cfg.HTTPMaskTLSEnabled,
HostOverride: cfg.HTTPMaskHost,
PathRoot: cfg.HTTPMaskPathRoot,
AuthKey: ClientAEADSeed(cfg.Key),
Upgrade: upgrade,
Multiplex: cfg.HTTPMaskMultiplex,
DialContext: dial,
Mode: cfg.HTTPMaskMode,
TLSEnabled: cfg.HTTPMaskTLSEnabled,
HostOverride: cfg.HTTPMaskHost,
PathRoot: cfg.HTTPMaskPathRoot,
AuthKey: ClientAEADSeed(cfg.Key),
EarlyHandshake: earlyHandshake,
Upgrade: upgrade,
Multiplex: cfg.HTTPMaskMultiplex,
DialContext: dial,
})
}
@@ -389,6 +389,68 @@ func TestHTTPMaskTunnel_WS_TCPRoundTrip(t *testing.T) {
}
}
func TestHTTPMaskTunnel_EarlyHandshake_TCPRoundTrip(t *testing.T) {
modes := []string{"stream", "poll", "ws"}
for _, mode := range modes {
t.Run(mode, func(t *testing.T) {
key := "tunnel-early-" + mode
target := "1.1.1.1:80"
serverCfg := newTunnelTestTable(t, key)
serverCfg.HTTPMaskMode = mode
addr, stop, errCh := startTunnelServer(t, serverCfg, func(s *ServerSession) error {
if s.Type != SessionTypeTCP {
return fmt.Errorf("unexpected session type: %v", s.Type)
}
if s.Target != target {
return fmt.Errorf("target mismatch: %s", s.Target)
}
_, _ = s.Conn.Write([]byte("ok"))
return nil
})
defer stop()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
clientCfg := *serverCfg
clientCfg.ServerAddress = addr
handshakeCfg := clientCfg
handshakeCfg.DisableHTTPMask = true
tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext, func(raw net.Conn) (net.Conn, error) {
return ClientHandshake(raw, &handshakeCfg)
})
if err != nil {
t.Fatalf("dial tunnel: %v", err)
}
defer tunnelConn.Close()
addrBuf, err := EncodeAddress(target)
if err != nil {
t.Fatalf("encode addr: %v", err)
}
if err := WriteKIPMessage(tunnelConn, KIPTypeOpenTCP, addrBuf); err != nil {
t.Fatalf("write addr: %v", err)
}
buf := make([]byte, 2)
if _, err := io.ReadFull(tunnelConn, buf); err != nil {
t.Fatalf("read: %v", err)
}
if string(buf) != "ok" {
t.Fatalf("unexpected payload: %q", buf)
}
stop()
for err := range errCh {
t.Fatalf("server error: %v", err)
}
})
}
}
func TestHTTPMaskTunnel_Validation(t *testing.T) {
cfg := DefaultConfig()
cfg.Key = "k"
@@ -0,0 +1,174 @@
package httpmask
import (
"encoding/base64"
"errors"
"fmt"
"net"
"net/url"
"strings"
)
const (
tunnelEarlyDataQueryKey = "ed"
tunnelEarlyDataHeader = "X-Sudoku-Early"
)
type ClientEarlyHandshake struct {
RequestPayload []byte
HandleResponse func(payload []byte) error
Ready func() bool
WrapConn func(raw net.Conn) (net.Conn, error)
}
type TunnelServerEarlyHandshake struct {
Prepare func(payload []byte) (*PreparedServerEarlyHandshake, error)
}
type PreparedServerEarlyHandshake struct {
ResponsePayload []byte
WrapConn func(raw net.Conn) (net.Conn, error)
UserHash string
}
type earlyHandshakeMeta interface {
HTTPMaskEarlyHandshakeUserHash() string
}
type earlyHandshakeConn struct {
net.Conn
userHash string
}
func (c *earlyHandshakeConn) HTTPMaskEarlyHandshakeUserHash() string {
if c == nil {
return ""
}
return c.userHash
}
func wrapEarlyHandshakeConn(conn net.Conn, userHash string) net.Conn {
if conn == nil {
return nil
}
return &earlyHandshakeConn{Conn: conn, userHash: userHash}
}
func EarlyHandshakeUserHash(conn net.Conn) (string, bool) {
if conn == nil {
return "", false
}
v, ok := conn.(earlyHandshakeMeta)
if !ok {
return "", false
}
return v.HTTPMaskEarlyHandshakeUserHash(), true
}
type authorizeResponse struct {
token string
earlyPayload []byte
}
func isTunnelTokenByte(c byte) bool {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' ||
c == '_'
}
func parseAuthorizeResponse(body []byte) (*authorizeResponse, error) {
s := strings.TrimSpace(string(body))
idx := strings.Index(s, "token=")
if idx < 0 {
return nil, errors.New("missing token")
}
s = s[idx+len("token="):]
if s == "" {
return nil, errors.New("empty token")
}
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if isTunnelTokenByte(c) {
b.WriteByte(c)
continue
}
break
}
token := b.String()
if token == "" {
return nil, errors.New("empty token")
}
out := &authorizeResponse{token: token}
if earlyLine := findAuthorizeField(body, "ed="); earlyLine != "" {
decoded, err := base64.RawURLEncoding.DecodeString(earlyLine)
if err != nil {
return nil, fmt.Errorf("decode early authorize payload failed: %w", err)
}
out.earlyPayload = decoded
}
return out, nil
}
func findAuthorizeField(body []byte, prefix string) string {
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, prefix) {
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
}
}
return ""
}
func setEarlyDataQuery(rawURL string, payload []byte) (string, error) {
if len(payload) == 0 {
return rawURL, nil
}
u, err := url.Parse(rawURL)
if err != nil {
return "", err
}
q := u.Query()
q.Set(tunnelEarlyDataQueryKey, base64.RawURLEncoding.EncodeToString(payload))
u.RawQuery = q.Encode()
return u.String(), nil
}
func parseEarlyDataQuery(u *url.URL) ([]byte, error) {
if u == nil {
return nil, nil
}
val := strings.TrimSpace(u.Query().Get(tunnelEarlyDataQueryKey))
if val == "" {
return nil, nil
}
return base64.RawURLEncoding.DecodeString(val)
}
func applyEarlyHandshakeOrUpgrade(raw net.Conn, opts TunnelDialOptions) (net.Conn, error) {
out := raw
if opts.EarlyHandshake != nil && opts.EarlyHandshake.WrapConn != nil && (opts.EarlyHandshake.Ready == nil || opts.EarlyHandshake.Ready()) {
wrapped, err := opts.EarlyHandshake.WrapConn(raw)
if err != nil {
return nil, err
}
if wrapped != nil {
out = wrapped
}
return out, nil
}
if opts.Upgrade != nil {
wrapped, err := opts.Upgrade(raw)
if err != nil {
return nil, err
}
if wrapped != nil {
out = wrapped
}
}
return out, nil
}
+97 -73
View File
@@ -72,6 +72,10 @@ type TunnelDialOptions struct {
// AuthKey enables short-term HMAC auth for HTTP tunnel requests (anti-probing).
// When set (non-empty), each HTTP request carries an Authorization bearer token derived from AuthKey.
AuthKey string
// EarlyHandshake folds the protocol handshake into the HTTP/WS setup round trip.
// When the server accepts the early payload, DialTunnel returns a conn that is already post-handshake.
// When the server does not echo early data, DialTunnel falls back to Upgrade.
EarlyHandshake *ClientEarlyHandshake
// Upgrade optionally wraps the raw tunnel conn and/or writes a small prelude before DialTunnel returns.
// It is called with the raw tunnel conn; if it returns a non-nil conn, that conn is returned by DialTunnel.
Upgrade func(raw net.Conn) (net.Conn, error)
@@ -225,30 +229,11 @@ func canonicalHeaderHost(urlHost, scheme string) string {
}
func parseTunnelToken(body []byte) (string, error) {
s := strings.TrimSpace(string(body))
idx := strings.Index(s, "token=")
if idx < 0 {
return "", errors.New("missing token")
resp, err := parseAuthorizeResponse(body)
if err != nil {
return "", err
}
s = s[idx+len("token="):]
if s == "" {
return "", errors.New("empty token")
}
// Token is base64.RawURLEncoding (A-Z a-z 0-9 - _). Strip any trailing bytes (e.g. from CDN compression).
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
b.WriteByte(c)
continue
}
break
}
token := b.String()
if token == "" {
return "", errors.New("empty token")
}
return token, nil
return resp.token, nil
}
type httpClientTarget struct {
@@ -353,6 +338,13 @@ func dialSessionWithClient(ctx context.Context, client *http.Client, target http
auth := newTunnelAuth(opts.AuthKey, 0)
authorizeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/session")}).String()
if opts.EarlyHandshake != nil && len(opts.EarlyHandshake.RequestPayload) > 0 {
var err error
authorizeURL, err = setEarlyDataQuery(authorizeURL, opts.EarlyHandshake.RequestPayload)
if err != nil {
return nil, err
}
}
var bodyBytes []byte
for attempt := 0; ; attempt++ {
@@ -410,13 +402,19 @@ func dialSessionWithClient(ctx context.Context, client *http.Client, target http
break
}
token, err := parseTunnelToken(bodyBytes)
authResp, err := parseAuthorizeResponse(bodyBytes)
if err != nil {
return nil, fmt.Errorf("%s authorize failed: %q", mode, strings.TrimSpace(string(bodyBytes)))
}
token := authResp.token
if token == "" {
return nil, fmt.Errorf("%s authorize empty token", mode)
}
if opts.EarlyHandshake != nil && len(authResp.earlyPayload) > 0 && opts.EarlyHandshake.HandleResponse != nil {
if err := opts.EarlyHandshake.HandleResponse(authResp.earlyPayload); err != nil {
return nil, err
}
}
pushURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/api/v1/upload"), RawQuery: "token=" + url.QueryEscape(token)}).String()
pullURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: joinPathRoot(opts.PathRoot, "/stream"), RawQuery: "token=" + url.QueryEscape(token)}).String()
@@ -671,16 +669,10 @@ func dialStreamSplitWithClient(ctx context.Context, client *http.Client, target
if c == nil {
return nil, fmt.Errorf("failed to build stream split conn")
}
outConn := net.Conn(c)
if opts.Upgrade != nil {
upgraded, err := opts.Upgrade(c)
if err != nil {
_ = c.Close()
return nil, err
}
if upgraded != nil {
outConn = upgraded
}
outConn, err := applyEarlyHandshakeOrUpgrade(c, opts)
if err != nil {
_ = c.Close()
return nil, err
}
return outConn, nil
}
@@ -694,16 +686,10 @@ func dialStreamSplit(ctx context.Context, serverAddress string, opts TunnelDialO
if c == nil {
return nil, fmt.Errorf("failed to build stream split conn")
}
outConn := net.Conn(c)
if opts.Upgrade != nil {
upgraded, err := opts.Upgrade(c)
if err != nil {
_ = c.Close()
return nil, err
}
if upgraded != nil {
outConn = upgraded
}
outConn, err := applyEarlyHandshakeOrUpgrade(c, opts)
if err != nil {
_ = c.Close()
return nil, err
}
return outConn, nil
}
@@ -1120,16 +1106,10 @@ func dialPollWithClient(ctx context.Context, client *http.Client, target httpCli
if c == nil {
return nil, fmt.Errorf("failed to build poll conn")
}
outConn := net.Conn(c)
if opts.Upgrade != nil {
upgraded, err := opts.Upgrade(c)
if err != nil {
_ = c.Close()
return nil, err
}
if upgraded != nil {
outConn = upgraded
}
outConn, err := applyEarlyHandshakeOrUpgrade(c, opts)
if err != nil {
_ = c.Close()
return nil, err
}
return outConn, nil
}
@@ -1143,16 +1123,10 @@ func dialPoll(ctx context.Context, serverAddress string, opts TunnelDialOptions)
if c == nil {
return nil, fmt.Errorf("failed to build poll conn")
}
outConn := net.Conn(c)
if opts.Upgrade != nil {
upgraded, err := opts.Upgrade(c)
if err != nil {
_ = c.Close()
return nil, err
}
if upgraded != nil {
outConn = upgraded
}
outConn, err := applyEarlyHandshakeOrUpgrade(c, opts)
if err != nil {
_ = c.Close()
return nil, err
}
return outConn, nil
}
@@ -1528,6 +1502,8 @@ type TunnelServerOptions struct {
PullReadTimeout time.Duration
// SessionTTL is a best-effort TTL to prevent leaked sessions. 0 uses a conservative default.
SessionTTL time.Duration
// EarlyHandshake optionally folds the protocol handshake into the initial HTTP/WS round trip.
EarlyHandshake *TunnelServerEarlyHandshake
}
type TunnelServer struct {
@@ -1538,6 +1514,7 @@ type TunnelServer struct {
pullReadTimeout time.Duration
sessionTTL time.Duration
earlyHandshake *TunnelServerEarlyHandshake
mu sync.Mutex
sessions map[string]*tunnelSession
@@ -1570,6 +1547,7 @@ func NewTunnelServer(opts TunnelServerOptions) *TunnelServer {
passThroughOnReject: opts.PassThroughOnReject,
pullReadTimeout: timeout,
sessionTTL: ttl,
earlyHandshake: opts.EarlyHandshake,
sessions: make(map[string]*tunnelSession),
}
}
@@ -1925,9 +1903,12 @@ func (s *TunnelServer) handleStream(rawConn net.Conn, req *httpRequestHeader, he
switch strings.ToUpper(req.method) {
case http.MethodGet:
// Stream split-session: GET /session (no token) => token + start tunnel on a server-side pipe.
if token == "" && path == "/session" {
return s.sessionAuthorize(rawConn)
earlyPayload, err := parseEarlyDataQuery(u)
if err != nil {
return rejectOrReply(http.StatusBadRequest, "bad request")
}
return s.sessionAuthorize(rawConn, earlyPayload)
}
// Stream split-session: GET /stream?token=... => downlink poll.
if token != "" && path == "/stream" {
@@ -2045,10 +2026,18 @@ func writeSimpleHTTPResponse(w io.Writer, code int, body string) error {
func writeTokenHTTPResponse(w io.Writer, token string) error {
token = strings.TrimRight(token, "\r\n")
// Use application/octet-stream to avoid CDN auto-compression (e.g. brotli) breaking clients that expect a plain token string.
return writeTokenHTTPResponseWithEarlyData(w, token, nil)
}
func writeTokenHTTPResponseWithEarlyData(w io.Writer, token string, earlyPayload []byte) error {
token = strings.TrimRight(token, "\r\n")
body := "token=" + token
if len(earlyPayload) > 0 {
body += "\ned=" + base64.RawURLEncoding.EncodeToString(earlyPayload)
}
_, err := io.WriteString(w,
fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nCache-Control: no-store\r\nPragma: no-cache\r\nContent-Length: %d\r\nConnection: close\r\n\r\ntoken=%s",
len("token=")+len(token), token))
fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nCache-Control: no-store\r\nPragma: no-cache\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
len(body), body))
return err
}
@@ -2088,7 +2077,11 @@ func (s *TunnelServer) handlePoll(rawConn net.Conn, req *httpRequestHeader, head
switch strings.ToUpper(req.method) {
case http.MethodGet:
if token == "" && path == "/session" {
return s.sessionAuthorize(rawConn)
earlyPayload, err := parseEarlyDataQuery(u)
if err != nil {
return rejectOrReply(http.StatusBadRequest, "bad request")
}
return s.sessionAuthorize(rawConn, earlyPayload)
}
if token != "" && path == "/stream" {
if s.passThroughOnReject && !s.sessionHas(token) {
@@ -2128,7 +2121,7 @@ func (s *TunnelServer) handlePoll(rawConn net.Conn, req *httpRequestHeader, head
}
}
func (s *TunnelServer) sessionAuthorize(rawConn net.Conn) (HandleResult, net.Conn, error) {
func (s *TunnelServer) sessionAuthorize(rawConn net.Conn, earlyPayload []byte) (HandleResult, net.Conn, error) {
token, err := newSessionToken()
if err != nil {
_ = writeSimpleHTTPResponse(rawConn, http.StatusInternalServerError, "internal error")
@@ -2137,6 +2130,37 @@ func (s *TunnelServer) sessionAuthorize(rawConn net.Conn) (HandleResult, net.Con
}
c1, c2 := newHalfPipe()
outConn := net.Conn(c1)
var responsePayload []byte
var userHash string
if len(earlyPayload) > 0 && s.earlyHandshake != nil && s.earlyHandshake.Prepare != nil {
prepared, err := s.earlyHandshake.Prepare(earlyPayload)
if err != nil {
_ = c1.Close()
_ = c2.Close()
if s.passThroughOnReject {
return HandlePassThrough, newRejectedPreBufferedConn(rawConn, nil), nil
}
_ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found")
_ = rawConn.Close()
return HandleDone, nil, nil
}
responsePayload = prepared.ResponsePayload
userHash = prepared.UserHash
if prepared.WrapConn != nil {
wrapped, err := prepared.WrapConn(c1)
if err != nil {
_ = c1.Close()
_ = c2.Close()
_ = writeSimpleHTTPResponse(rawConn, http.StatusInternalServerError, "internal error")
_ = rawConn.Close()
return HandleDone, nil, nil
}
if wrapped != nil {
outConn = wrapEarlyHandshakeConn(wrapped, userHash)
}
}
}
s.mu.Lock()
s.sessions[token] = &tunnelSession{conn: c2, lastActive: time.Now()}
@@ -2144,9 +2168,9 @@ func (s *TunnelServer) sessionAuthorize(rawConn net.Conn) (HandleResult, net.Con
go s.reapLater(token)
_ = writeTokenHTTPResponse(rawConn, token)
_ = writeTokenHTTPResponseWithEarlyData(rawConn, token, responsePayload)
_ = rawConn.Close()
return HandleStartTunnel, c1, nil
return HandleStartTunnel, outConn, nil
}
func newSessionToken() (string, error) {
@@ -2,6 +2,7 @@ package httpmask
import (
"context"
"encoding/base64"
"fmt"
"io"
mrand "math/rand"
@@ -115,6 +116,16 @@ func dialWS(ctx context.Context, serverAddress string, opts TunnelDialOptions) (
Host: urlHost,
Path: joinPathRoot(opts.PathRoot, "/ws"),
}
if opts.EarlyHandshake != nil && len(opts.EarlyHandshake.RequestPayload) > 0 {
rawURL, err := setEarlyDataQuery(u.String(), opts.EarlyHandshake.RequestPayload)
if err != nil {
return nil, err
}
u, err = url.Parse(rawURL)
if err != nil {
return nil, err
}
}
header := make(stdhttp.Header)
applyWSHeaders(header, headerHost)
@@ -132,6 +143,16 @@ func dialWS(ctx context.Context, serverAddress string, opts TunnelDialOptions) (
d := ws.Dialer{
Host: headerHost,
Header: ws.HandshakeHeaderHTTP(header),
OnHeader: func(key, value []byte) error {
if !strings.EqualFold(string(key), tunnelEarlyDataHeader) || opts.EarlyHandshake == nil || opts.EarlyHandshake.HandleResponse == nil {
return nil
}
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(value)))
if err != nil {
return err
}
return opts.EarlyHandshake.HandleResponse(decoded)
},
NetDial: func(dialCtx context.Context, network, addr string) (net.Conn, error) {
if addr == urlHost {
addr = dialAddr
@@ -161,16 +182,10 @@ func dialWS(ctx context.Context, serverAddress string, opts TunnelDialOptions) (
}
wsConn := newWSStreamConn(conn, ws.StateClientSide)
if opts.Upgrade == nil {
return wsConn, nil
}
upgraded, err := opts.Upgrade(wsConn)
upgraded, err := applyEarlyHandshakeOrUpgrade(wsConn, opts)
if err != nil {
_ = wsConn.Close()
return nil, err
}
if upgraded != nil {
return upgraded, nil
}
return wsConn, nil
return upgraded, nil
}
@@ -1,6 +1,7 @@
package httpmask
import (
"encoding/base64"
"net"
"net/http"
"net/url"
@@ -63,15 +64,46 @@ func (s *TunnelServer) handleWS(rawConn net.Conn, req *httpRequestHeader, header
return rejectOrReply(http.StatusNotFound, "not found")
}
earlyPayload, err := parseEarlyDataQuery(u)
if err != nil {
return rejectOrReply(http.StatusBadRequest, "bad request")
}
var prepared *PreparedServerEarlyHandshake
if len(earlyPayload) > 0 && s.earlyHandshake != nil && s.earlyHandshake.Prepare != nil {
prepared, err = s.earlyHandshake.Prepare(earlyPayload)
if err != nil {
return rejectOrReply(http.StatusNotFound, "not found")
}
}
prefix := make([]byte, 0, len(headerBytes)+len(buffered))
prefix = append(prefix, headerBytes...)
prefix = append(prefix, buffered...)
wsConnRaw := newPreBufferedConn(rawConn, prefix)
if _, err := ws.Upgrade(wsConnRaw); err != nil {
upgrader := ws.Upgrader{}
if prepared != nil && len(prepared.ResponsePayload) > 0 {
upgrader.OnBeforeUpgrade = func() (ws.HandshakeHeader, error) {
h := http.Header{}
h.Set(tunnelEarlyDataHeader, base64.RawURLEncoding.EncodeToString(prepared.ResponsePayload))
return ws.HandshakeHeaderHTTP(h), nil
}
}
if _, err := upgrader.Upgrade(wsConnRaw); err != nil {
_ = rawConn.Close()
return HandleDone, nil, nil
}
return HandleStartTunnel, newWSStreamConn(wsConnRaw, ws.StateServerSide), nil
outConn := net.Conn(newWSStreamConn(wsConnRaw, ws.StateServerSide))
if prepared != nil && prepared.WrapConn != nil {
wrapped, err := prepared.WrapConn(outConn)
if err != nil {
_ = outConn.Close()
return HandleDone, nil, nil
}
if wrapped != nil {
outConn = wrapEarlyHandshakeConn(wrapped, prepared.UserHash)
}
}
return HandleStartTunnel, outConn, nil
}
+110 -82
View File
@@ -11,35 +11,35 @@ import (
)
const (
// 每次从 RNG 获取批量随机数的缓存大小,减少 RNG 函数调用开销
RngBatchSize = 128
packedProtectedPrefixBytes = 14
)
// 1. 使用 12字节->16组 的块处理优化 Write (减少循环开销)
// 2. 使用整数阈值随机概率判断 Padding,与纯 Sudoku 保持流量特征一致
// 3. Read 使用 copy 移动避免底层数组泄漏
// PackedConn encodes traffic with the packed Sudoku layout while preserving
// the same padding model as the regular connection.
type PackedConn struct {
net.Conn
table *Table
reader *bufio.Reader
// 读缓冲
// Read-side buffers.
rawBuf []byte
pendingData []byte // 解码后尚未被 Read 取走的字节
pendingData []byte
// 写缓冲与状态
// Write-side state.
writeMu sync.Mutex
writeBuf []byte
bitBuf uint64 // 暂存的位数据
bitCount int // 暂存的位数
bitBuf uint64
bitCount int
// 读状态
// Read-side bit accumulator.
readBitBuf uint64
readBits int
// 随机数与填充控制 - 使用整数阈值随机,与 Conn 一致
// Padding selection matches Conn's threshold-based model.
rng *rand.Rand
paddingThreshold uint64 // 与 Conn 保持一致的随机概率模型
paddingThreshold uint64
padMarker byte
padPool []byte
}
@@ -95,7 +95,6 @@ func NewPackedConn(c net.Conn, table *Table, pMin, pMax int) *PackedConn {
return pc
}
// maybeAddPadding 内联辅助:根据概率阈值插入 padding
func (pc *PackedConn) maybeAddPadding(out []byte) []byte {
if shouldPad(pc.rng, pc.paddingThreshold) {
out = append(out, pc.getPaddingByte())
@@ -103,7 +102,73 @@ func (pc *PackedConn) maybeAddPadding(out []byte) []byte {
return out
}
// Write 极致优化版 - 批量处理 12 字节
func (pc *PackedConn) appendGroup(out []byte, group byte) []byte {
out = pc.maybeAddPadding(out)
return append(out, pc.encodeGroup(group))
}
func (pc *PackedConn) appendForcedPadding(out []byte) []byte {
return append(out, pc.getPaddingByte())
}
func (pc *PackedConn) nextProtectedPrefixGap() int {
return 1 + pc.rng.Intn(2)
}
func (pc *PackedConn) writeProtectedPrefix(out []byte, p []byte) ([]byte, int) {
if len(p) == 0 {
return out, 0
}
limit := len(p)
if limit > packedProtectedPrefixBytes {
limit = packedProtectedPrefixBytes
}
for padCount := 0; padCount < 1+pc.rng.Intn(2); padCount++ {
out = pc.appendForcedPadding(out)
}
gap := pc.nextProtectedPrefixGap()
effective := 0
for i := 0; i < limit; i++ {
pc.bitBuf = (pc.bitBuf << 8) | uint64(p[i])
pc.bitCount += 8
for pc.bitCount >= 6 {
pc.bitCount -= 6
group := byte(pc.bitBuf >> pc.bitCount)
if pc.bitCount == 0 {
pc.bitBuf = 0
} else {
pc.bitBuf &= (1 << pc.bitCount) - 1
}
out = pc.appendGroup(out, group&0x3F)
}
effective++
if effective >= gap {
out = pc.appendForcedPadding(out)
effective = 0
gap = pc.nextProtectedPrefixGap()
}
}
return out, limit
}
func (pc *PackedConn) drainPendingData(dst []byte) int {
n := copy(dst, pc.pendingData)
if n == len(pc.pendingData) {
pc.pendingData = pc.pendingData[:0]
return n
}
remaining := len(pc.pendingData) - n
copy(pc.pendingData, pc.pendingData[n:])
pc.pendingData = pc.pendingData[:remaining]
return n
}
func (pc *PackedConn) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
@@ -112,20 +177,19 @@ func (pc *PackedConn) Write(p []byte) (int, error) {
pc.writeMu.Lock()
defer pc.writeMu.Unlock()
// 1. 预分配内存,避免 append 导致的多次扩容
// 预估:原数据 * 1.5 (4/3 + padding 余量)
needed := len(p)*3/2 + 32
if cap(pc.writeBuf) < needed {
pc.writeBuf = make([]byte, 0, needed)
}
out := pc.writeBuf[:0]
i := 0
var prefixN int
out, prefixN = pc.writeProtectedPrefix(out, p)
i := prefixN
n := len(p)
// 2. 头部对齐处理 (Slow Path)
for pc.bitCount > 0 && i < n {
out = pc.maybeAddPadding(out)
b := p[i]
i++
pc.bitBuf = (pc.bitBuf << 8) | uint64(b)
@@ -138,14 +202,11 @@ func (pc *PackedConn) Write(p []byte) (int, error) {
} else {
pc.bitBuf &= (1 << pc.bitCount) - 1
}
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(group&0x3F))
out = pc.appendGroup(out, group&0x3F)
}
}
// 3. 极速批量处理 (Fast Path) - 每次处理 12 字节 → 生成 16 个编码组
for i+11 < n {
// 处理 4 组,每组 3 字节
for batch := 0; batch < 4; batch++ {
b1, b2, b3 := p[i], p[i+1], p[i+2]
i += 3
@@ -155,19 +216,13 @@ func (pc *PackedConn) Write(p []byte) (int, error) {
g3 := ((b2 & 0x0F) << 2) | ((b3 >> 6) & 0x03)
g4 := b3 & 0x3F
// 每个组之前都有概率插入 padding
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g1))
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g2))
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g3))
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g4))
out = pc.appendGroup(out, g1)
out = pc.appendGroup(out, g2)
out = pc.appendGroup(out, g3)
out = pc.appendGroup(out, g4)
}
}
// 4. 处理剩余的 3 字节块
for i+2 < n {
b1, b2, b3 := p[i], p[i+1], p[i+2]
i += 3
@@ -177,17 +232,12 @@ func (pc *PackedConn) Write(p []byte) (int, error) {
g3 := ((b2 & 0x0F) << 2) | ((b3 >> 6) & 0x03)
g4 := b3 & 0x3F
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g1))
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g2))
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g3))
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(g4))
out = pc.appendGroup(out, g1)
out = pc.appendGroup(out, g2)
out = pc.appendGroup(out, g3)
out = pc.appendGroup(out, g4)
}
// 5. 尾部处理 (Tail Path) - 处理剩余的 1 或 2 个字节
for ; i < n; i++ {
b := p[i]
pc.bitBuf = (pc.bitBuf << 8) | uint64(b)
@@ -200,35 +250,28 @@ func (pc *PackedConn) Write(p []byte) (int, error) {
} else {
pc.bitBuf &= (1 << pc.bitCount) - 1
}
out = pc.maybeAddPadding(out)
out = append(out, pc.encodeGroup(group&0x3F))
out = pc.appendGroup(out, group&0x3F)
}
}
// 6. 处理残留位
if pc.bitCount > 0 {
out = pc.maybeAddPadding(out)
group := byte(pc.bitBuf << (6 - pc.bitCount))
pc.bitBuf = 0
pc.bitCount = 0
out = append(out, pc.encodeGroup(group&0x3F))
out = pc.appendGroup(out, group&0x3F)
out = append(out, pc.padMarker)
}
// 尾部可能添加 padding
out = pc.maybeAddPadding(out)
// 发送数据
if len(out) > 0 {
_, err := pc.Conn.Write(out)
pc.writeBuf = out[:0]
return len(p), err
return len(p), writeFull(pc.Conn, out)
}
pc.writeBuf = out[:0]
return len(p), nil
}
// Flush 处理最后不足 6 bit 的情况
func (pc *PackedConn) Flush() error {
pc.writeMu.Lock()
defer pc.writeMu.Unlock()
@@ -243,38 +286,34 @@ func (pc *PackedConn) Flush() error {
out = append(out, pc.padMarker)
}
// 尾部随机添加 padding
out = pc.maybeAddPadding(out)
if len(out) > 0 {
_, err := pc.Conn.Write(out)
pc.writeBuf = out[:0]
return err
return writeFull(pc.Conn, out)
}
return nil
}
// Read 优化版:减少切片操作,避免内存泄漏
func (pc *PackedConn) Read(p []byte) (int, error) {
// 1. 优先返回待处理区的数据
if len(pc.pendingData) > 0 {
n := copy(p, pc.pendingData)
if n == len(pc.pendingData) {
pc.pendingData = pc.pendingData[:0]
} else {
// 优化:移动剩余数据到数组头部,避免切片指向中间导致内存泄漏
remaining := len(pc.pendingData) - n
copy(pc.pendingData, pc.pendingData[n:])
pc.pendingData = pc.pendingData[:remaining]
func writeFull(w io.Writer, b []byte) error {
for len(b) > 0 {
n, err := w.Write(b)
if err != nil {
return err
}
return n, nil
b = b[n:]
}
return nil
}
func (pc *PackedConn) Read(p []byte) (int, error) {
if len(pc.pendingData) > 0 {
return pc.drainPendingData(p), nil
}
// 2. 循环读取直到解出数据或出错
for {
nr, rErr := pc.reader.Read(pc.rawBuf)
if nr > 0 {
// 缓存频繁访问的变量
rBuf := pc.readBitBuf
rBits := pc.readBits
padMarker := pc.padMarker
@@ -324,24 +363,13 @@ func (pc *PackedConn) Read(p []byte) (int, error) {
}
}
// 3. 返回解码后的数据 - 优化:避免底层数组泄漏
n := copy(p, pc.pendingData)
if n == len(pc.pendingData) {
pc.pendingData = pc.pendingData[:0]
} else {
remaining := len(pc.pendingData) - n
copy(pc.pendingData, pc.pendingData[n:])
pc.pendingData = pc.pendingData[:remaining]
}
return n, nil
return pc.drainPendingData(p), nil
}
// getPaddingByte 从 Pool 中随机取 Padding 字节
func (pc *PackedConn) getPaddingByte() byte {
return pc.padPool[pc.rng.Intn(len(pc.padPool))]
}
// encodeGroup 编码 6-bit 组
func (pc *PackedConn) encodeGroup(group byte) byte {
return pc.table.layout.encodeGroup(group)
}
@@ -0,0 +1,91 @@
package sudoku
import (
"bytes"
"io"
"math/rand"
"net"
"testing"
"time"
)
type mockConn struct {
readBuf []byte
writeBuf []byte
}
func (c *mockConn) Read(p []byte) (int, error) {
if len(c.readBuf) == 0 {
return 0, io.EOF
}
n := copy(p, c.readBuf)
c.readBuf = c.readBuf[n:]
return n, nil
}
func (c *mockConn) Write(p []byte) (int, error) {
c.writeBuf = append(c.writeBuf, p...)
return len(p), nil
}
func (c *mockConn) Close() error { return nil }
func (c *mockConn) LocalAddr() net.Addr { return nil }
func (c *mockConn) RemoteAddr() net.Addr { return nil }
func (c *mockConn) SetDeadline(time.Time) error { return nil }
func (c *mockConn) SetReadDeadline(time.Time) error { return nil }
func (c *mockConn) SetWriteDeadline(time.Time) error { return nil }
func TestPackedConn_ProtectedPrefixPadding(t *testing.T) {
table := NewTable("packed-prefix-seed", "prefer_ascii")
mock := &mockConn{}
writer := NewPackedConn(mock, table, 0, 0)
writer.rng = rand.New(rand.NewSource(1))
payload := bytes.Repeat([]byte{0}, 32)
if _, err := writer.Write(payload); err != nil {
t.Fatalf("write: %v", err)
}
wire := append([]byte(nil), mock.writeBuf...)
if len(wire) < 20 {
t.Fatalf("wire too short: %d", len(wire))
}
firstHint := -1
nonHintCount := 0
maxHintRun := 0
currentHintRun := 0
for i, b := range wire[:20] {
if table.layout.isHint(b) {
if firstHint == -1 {
firstHint = i
}
currentHintRun++
if currentHintRun > maxHintRun {
maxHintRun = currentHintRun
}
continue
}
nonHintCount++
currentHintRun = 0
}
if firstHint < 1 || firstHint > 2 {
t.Fatalf("expected 1-2 leading padding bytes, first hint index=%d", firstHint)
}
if nonHintCount < 6 {
t.Fatalf("expected dense prefix padding, got only %d non-hint bytes in first 20", nonHintCount)
}
if maxHintRun > 3 {
t.Fatalf("prefix still exposes long hint run: %d", maxHintRun)
}
reader := NewPackedConn(&mockConn{readBuf: wire}, table, 0, 0)
decoded := make([]byte, len(payload))
if _, err := io.ReadFull(reader, decoded); err != nil {
t.Fatalf("read back: %v", err)
}
if !bytes.Equal(decoded, payload) {
t.Fatalf("roundtrip mismatch")
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ func forceCloseAllConnections(roundTripper RoundTripper) {
roundTripper.CloseIdleConnections()
switch tr := roundTripper.(type) {
case *http.Http2Transport:
gun.CloseTransport(tr)
gun.CloseHttp2Transport(tr)
case *http3.Transport:
_ = tr.Close()
}
@@ -11,7 +11,7 @@ LUCI_TITLE:=LuCI Support for Automatic Partition Mount
LUCI_PKGARCH:=all
LUCI_DEPENDS:=+fdisk +block-mount +bc +blkid +parted +btrfs-progs +losetup +resize2fs +e2fsprogs +f2fs-tools +kmod-loop
PKG_VERSION:=2.0.3
PKG_RELEASE:=20260228
PKG_RELEASE:=20260310
PKG_LICENSE:=Apache-2.0
PKG_MAINTAINER:=Sirpdboy <herboy2008@gmail.com>
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright (C) 2021-2025 sirpdboy <herboy2008@gmail.com> https://github.com/sirpdboy/luci-app-partexp
# Copyright (C) 2021-2026 sirpdboy <herboy2008@gmail.com> https://github.com/sirpdboy/luci-app-partexp
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
@@ -279,8 +279,8 @@ check_part_space() {
check_free_space() {
DISK=$1
PARTED_OUTPUT=$(parted -s /dev/$DISK unit GB print free 2>/dev/null)
FREE_SPACE=$(echo "$PARTED_OUTPUT" | grep "Free Space" | awk '{print $3}' )
echo $FREE_SPACE |awk -F '.' '{print $1}' | sed 's/[A-Za-z]//g'
FREE_SPACE=$(echo "$PARTED_OUTPUT" | awk '/Free Space/ {last=$3} END {print last}' | sed 's/GB$//')
echo $FREE_SPACE | cut -d'.' -f1
}
show_partition_info() {
@@ -418,12 +418,6 @@ rootfs_resize() {
fi
}
get_config() {
config_get target_function $1 target_function 1
config_get target_disk $1 target_disk 1
config_get_bool keep_config $1 keep_config 1
config_get format_type $1 format_type
}
# 修改 fdiskB 函数,使用环境变量参数
fdiskB() {
@@ -1521,3 +1521,13 @@ function get_core(field, candidates)
end
return nil
end
function cleanEmptyTables(t)
if type(t) ~= "table" then return nil end
for k, v in pairs(t) do
if type(v) == "table" then
t[k] = cleanEmptyTables(v)
end
end
return next(t) and t or nil
end
@@ -241,31 +241,21 @@ function gen_outbound(flag, node, tag, proxy_table)
path = node.xhttp_path or "/",
host = node.xhttp_host,
extra = (function()
local extra_tbl = {}
-- 解析 xhttp_extra 并做简单容错处理
local extra = {}
if node.xhttp_extra then
local success, parsed = pcall(jsonc.parse, api.base64Decode(node.xhttp_extra))
if success and parsed then
extra_tbl = parsed.extra or parsed
for k, v in pairs(extra_tbl) do
if (type(v) == "table" and next(v) == nil) or v == nil then
extra_tbl[k] = nil
end
end
local ok, parsed = pcall(jsonc.parse, api.base64Decode(node.xhttp_extra))
if ok and type(parsed) == "table" then
extra = parsed.extra or parsed
end
end
-- 处理 User-Agent
if node.user_agent and node.user_agent ~= "" then
extra_tbl.headers = extra_tbl.headers or {}
if not extra_tbl.headers["User-Agent"] and not extra_tbl.headers["user-agent"] then
extra_tbl.headers["User-Agent"] = node.user_agent
extra.headers = extra.headers or {}
if not extra.headers["User-Agent"] and not extra.headers["user-agent"] then
extra.headers["User-Agent"] = node.user_agent
end
end
-- 清理空的 headers
if extra_tbl.headers and next(extra_tbl.headers) == nil then
extra_tbl.headers = nil
end
return next(extra_tbl) ~= nil and extra_tbl or nil
return api.cleanEmptyTables(extra)
end)()
} or nil,
hysteriaSettings = (node.transport == "hysteria") and {
@@ -322,25 +312,10 @@ function gen_outbound(flag, node, tag, proxy_table)
if node.finalmask and node.finalmask ~= "" then
local ok, fm = pcall(jsonc.parse, api.base64Decode(node.finalmask))
if ok and type(fm) == "table" then
if not finalmask or not next(finalmask) then
finalmask = fm
else
if type(fm.udp) == "table" then
finalmask.udp = finalmask.udp or {}
for i = 1, #fm.udp do
finalmask.udp[#finalmask.udp+1] = fm.udp[i]
end
end
if type(fm.tcp) == "table" then
finalmask.tcp = fm.tcp
end
if type(fm.quicParams) == "table" then
finalmask.quicParams = fm.quicParams
end
end
finalmask = fm
end
end
return (finalmask and next(finalmask)) and finalmask or nil
return api.cleanEmptyTables(finalmask)
end)()
} or nil,
settings = {
@@ -649,25 +624,10 @@ function gen_config_server(node)
if node.finalmask and node.finalmask ~= "" then
local ok, fm = pcall(jsonc.parse, api.base64Decode(node.finalmask))
if ok and type(fm) == "table" then
if not finalmask or not next(finalmask) then
finalmask = fm
else
if type(fm.udp) == "table" then
finalmask.udp = finalmask.udp or {}
for i = 1, #fm.udp do
finalmask.udp[#finalmask.udp+1] = fm.udp[i]
end
end
if type(fm.tcp) == "table" then
finalmask.tcp = fm.tcp
end
if type(fm.quicParams) == "table" then
finalmask.quicParams = fm.quicParams
end
end
finalmask = fm
end
end
return (finalmask and next(finalmask)) and finalmask or nil
return api.cleanEmptyTables(finalmask)
end)(),
sockopt = {
tcpFastOpen = (node.tcp_fast_open == "1") and true or nil,
@@ -124,6 +124,10 @@ const glossary = {
prefmt: 'rule_%s',
field: 'rule-providers',
},
inbound: {
prefmt: 'inbound_%s',
field: 'listeners',
},
server: {
prefmt: 'server_%s',
field: 'listeners',
@@ -148,7 +152,8 @@ const inbound_type = [
['anytls', _('AnyTLS') + ' - ' + _('TCP')],
['tuic', _('TUIC') + ' - ' + _('UDP')],
['hysteria2', _('Hysteria2') + ' - ' + _('UDP')],
//['tunnel', _('Tunnel') + ' - ' + _('TCP/UDP')]
['trusttunnel', _('TrustTunnel') + ' - ' + _('TCP/UDP')],
['tunnel', _('Tunnel') + ' - ' + _('TCP/UDP')]
];
const ip_version = [
@@ -183,6 +188,7 @@ const outbound_type = [
['hysteria2', _('Hysteria2') + ' - ' + _('UDP')],
['tuic', _('TUIC') + ' - ' + _('UDP')],
['masque', _('Masque') + ' - ' + _('UDP')], // https://blog.cloudflare.com/post-quantum-warp/
['trusttunnel', _('TrustTunnel') + ' - ' + _('TCP/UDP')],
['wireguard', _('WireGuard') + ' - ' + _('UDP')],
['ssh', _('SSH') + ' - ' + _('TCP')]
];
File diff suppressed because it is too large Load Diff
@@ -795,7 +795,7 @@ function renderRules(s, uciconfig) {
UIEl.setValue(rule.toString('json'));
}
o.write = function() {};
//o.depends('SUB-RULE', '');
//o.depends('SUB-RULE', ''); // work on subrules not rules
o.editable = true;
o = s.option(form.Flag, 'src', _('src'),
@@ -0,0 +1,38 @@
'use strict';
'require form';
'require uci';
'require ui';
'require view';
'require fchomo as hm';
'require fchomo.listeners as lsnr'
return view.extend({
load() {
return Promise.all([
uci.load('fchomo')
]);
},
render(data) {
let m, s, o;
m = new form.Map('fchomo', _('Edit inbound'));
/* Inbound settings START */
s = m.section(hm.GridSection, 'inbound', null);
s.addremove = true;
s.rowcolors = true;
s.sortable = true;
s.nodescriptions = true;
s.hm_modaltitle = [ _('Inbound'), _('Add a inbound') ];
s.hm_prefmt = hm.glossary[s.sectiontype].prefmt;
s.hm_field = hm.glossary[s.sectiontype].field;
s.hm_lowcase_only = false;
lsnr.renderListeners(s, data[0], true);
/* Inbound settings END */
return m.render();
}
});
@@ -255,13 +255,13 @@ return view.extend({
/* hm.validateAuth */
so = ss.taboption('field_general', form.Value, 'username', _('Username'));
so.validate = hm.validateAuthUsername;
so.depends({type: /^(http|socks5|mieru|ssh)$/});
so.depends({type: /^(http|socks5|mieru|trusttunnel|ssh)$/});
so.modalonly = true;
so = ss.taboption('field_general', form.Value, 'password', _('Password'));
so.password = true;
so.validate = hm.validateAuthPassword;
so.depends({type: /^(http|socks5|mieru|trojan|anytls|hysteria2|tuic|ssh)$/});
so.depends({type: /^(http|socks5|mieru|trojan|anytls|hysteria2|tuic|trusttunnel|ssh)$/});
so.modalonly = true;
so = ss.taboption('field_general', hm.TextValue, 'headers', _('HTTP header'));
@@ -375,6 +375,12 @@ return view.extend({
so.depends('type', 'mieru');
so.modalonly = true;
so = ss.taboption('field_general', form.Value, 'mieru_traffic_pattern', _('Traffic pattern'),
_('A base64 string is used to fine-tune network behavior.<br/>Please refer to the <a target="_blank" href="%s" rel="noreferrer noopener">document</a>.')
.format('https://github.com/enfein/mieru/blob/main/docs/traffic-pattern.md'));
so.depends('type', 'mieru');
so.modalonly = true;
/* Sudoku fields */
so = ss.taboption('field_general', form.Value, 'sudoku_key', _('Key'),
_('The ED25519 available private key or UUID provided by Sudoku server.'));
@@ -448,18 +454,19 @@ return view.extend({
so.value('stream', _('split-stream') + ' - ' + _('CDN support'));
so.value('poll', _('poll') + ' - ' + _('CDN support'));
so.value('auto', _('Auto') + ' - ' + _('CDN support'));
so.value('ws', _('WebSocket') + ' - ' + _('CDN support'));
so.depends('sudoku_http_mask', '1');
so.modalonly = true;
so = ss.taboption('field_general', form.Flag, 'sudoku_http_mask_tls', _('HTTP mask: %s').format(_('TLS')));
so.default = so.disabled;
so.depends({sudoku_http_mask_mode: /^(stream|poll|auto)$/});
so.depends({sudoku_http_mask_mode: /^(stream|poll|auto|ws)$/});
so.modalonly = true;
so = ss.taboption('field_general', form.Value, 'sudoku_http_mask_host', _('HTTP mask: %s').format(_('Host/SNI override')));
so.datatype = 'or(hostname, hostport)';
so.placeholder = 'example.com[:443]';
so.depends({sudoku_http_mask_mode: /^(stream|poll|auto)$/});
so.depends({sudoku_http_mask_mode: /^(stream|poll|auto|ws)$/});
so.modalonly = true;
so = ss.taboption('field_general', form.Value, 'sudoku_path_root', _('HTTP root path'));
@@ -475,8 +482,10 @@ return view.extend({
so.validate = function(section_id, value) {
const http_mask_mode = this.section.getOption('sudoku_http_mask_mode').formvalue(section_id);
if (http_mask_mode === 'ws' && value !== 'off')
return _('Expecting: %s').format(_('only applies when %s is not %s.').format(_('HTTP mask mode'), _('WebSocket')));
if (value === 'on' && !['stream', 'poll', 'auto'].includes(http_mask_mode))
return _('Expecting: %s').format(_('only applies when %s is stream/poll/auto.').format(_('HTTP mask mode')));
return _('Expecting: %s').format(_('only applies when %s is %s.').format(_('HTTP mask mode'), _('stream/poll/auto')));
return true;
}
@@ -512,15 +521,6 @@ return view.extend({
so.depends('type', 'tuic');
so.modalonly = true;
so = ss.taboption('field_general', form.ListValue, 'tuic_congestion_controller', _('Congestion controller'),
_('QUIC congestion controller.'));
so.default = hm.congestion_controller[0][0];
hm.congestion_controller.forEach((res) => {
so.value.apply(so, res);
})
so.depends('type', 'tuic');
so.modalonly = true;
so = ss.taboption('field_general', form.ListValue, 'tuic_udp_relay_mode', _('UDP relay mode'),
_('UDP packet relay mode.'));
so.default = 'native';
@@ -714,12 +714,15 @@ return view.extend({
so.depends('masque_remote_dns_resolve', '1');
so.modalonly = true;
so = ss.taboption('field_general', form.ListValue, 'masque_congestion_controller', _('Congestion controller'));
so.default = hm.congestion_controller[0][0];
hm.congestion_controller.forEach((res) => {
so.value.apply(so, res);
})
so.depends('type', 'masque');
/* TrustTunnel fields */
so = ss.taboption('field_general', form.Flag, 'trusttunnel_health_check', _('Health check'));
so.default = so.enabled;
so.depends('type', 'trusttunnel');
so.modalonly = true;
so = ss.taboption('field_general', form.Flag, 'trusttunnel_quic', _('QUIC'));
so.default = so.disabled;
so.depends('type', 'trusttunnel');
so.modalonly = true;
/* WireGuard fields */
@@ -848,9 +851,17 @@ return view.extend({
so.modalonly = true;
/* Extra fields */
so = ss.taboption('field_general', form.ListValue, 'congestion_controller', _('Congestion controller'));
so.default = hm.congestion_controller[0][0];
hm.congestion_controller.forEach((res) => {
so.value.apply(so, res);
})
so.depends({type: /^(tuic|masque|trusttunnel)$/});
so.modalonly = true;
so = ss.taboption('field_general', form.Flag, 'udp', _('UDP'));
so.default = so.disabled;
so.depends({type: /^(direct|socks5|ss|mieru|vmess|vless|trojan|anytls|masque|wireguard)$/});
so.depends({type: /^(direct|socks5|ss|mieru|vmess|vless|trojan|anytls|trusttunnel|masque|wireguard)$/});
so.modalonly = true;
so = ss.taboption('field_general', form.Flag, 'uot', _('UoT'),
@@ -941,7 +952,7 @@ return view.extend({
let tls = this.section.getUIElement(section_id, 'tls').node.querySelector('input');
// Force enabled
if (['trojan', 'anytls', 'hysteria', 'hysteria2', 'tuic'].includes(type)) {
if (['trojan', 'anytls', 'hysteria', 'hysteria2', 'tuic', 'trusttunnel'].includes(type)) {
tls.checked = true;
tls.disabled = true;
} else {
@@ -950,7 +961,7 @@ return view.extend({
return true;
}
so.depends({type: /^(http|socks5|vmess|vless|trojan|anytls|hysteria|hysteria2|tuic)$/});
so.depends({type: /^(http|socks5|vmess|vless|trojan|anytls|hysteria|hysteria2|tuic|trusttunnel)$/});
so.modalonly = true;
so = ss.taboption('field_tls', form.Flag, 'tls_disable_sni', _('Disable SNI'),
@@ -961,7 +972,7 @@ return view.extend({
so = ss.taboption('field_tls', form.Value, 'tls_sni', _('TLS SNI'),
_('Used to verify the hostname on the returned certificates.'));
so.depends({tls: '1', type: /^(http|vmess|vless|trojan|anytls|hysteria|hysteria2)$/});
so.depends({tls: '1', type: /^(http|vmess|vless|trojan|anytls|hysteria|hysteria2|trusttunnel)$/});
so.depends({tls: '1', tls_disable_sni: '0', type: /^(tuic)$/});
so.modalonly = true;
@@ -991,6 +1002,9 @@ return view.extend({
case 'anytls':
def_alpn = ['h2', 'http/1.1'];
break;
case 'trusttunnel':
def_alpn = ['h3', 'h2'];
break;
default:
def_alpn = [];
}
@@ -1000,7 +1014,7 @@ return view.extend({
return true;
}
so.depends({tls: '1', type: /^(vmess|vless|trojan|anytls|hysteria|hysteria2|tuic)$/});
so.depends({tls: '1', type: /^(vmess|vless|trojan|anytls|hysteria|hysteria2|tuic|trusttunnel)$/});
so.depends({type: 'ss', plugin: 'shadow-tls'});
so.modalonly = true;
@@ -1022,7 +1036,7 @@ return view.extend({
'<br/>' +
_('This is <strong>DANGEROUS</strong>, your traffic is almost like <strong>PLAIN TEXT</strong>! Use at your own risk!'));
so.default = so.disabled;
so.depends({tls: '1', type: /^(http|socks5|vmess|vless|trojan|anytls|hysteria|hysteria2|tuic)$/});
so.depends({tls: '1', type: /^(http|socks5|vmess|vless|trojan|anytls|hysteria|hysteria2|tuic|trusttunnel)$/});
so.modalonly = true;
so = ss.taboption('field_tls', form.Value, 'tls_cert_path', _('Certificate path') + _(' (mTLS)'),
@@ -1076,7 +1090,7 @@ return view.extend({
hm.tls_client_fingerprints.forEach((res) => {
so.value.apply(so, res);
})
so.depends({tls: '1', type: /^(vmess|vless|trojan|anytls)$/});
so.depends({tls: '1', type: /^(vmess|vless|trojan|anytls|trusttunnel)$/});
so.depends({type: 'ss', plugin: /^(shadow-tls|restls)$/});
so.modalonly = true;
@@ -6,125 +6,17 @@
'require view';
'require fchomo as hm';
const CBIDummyCopyValue = hm.CopyValue.extend({
__name__: 'CBI.DummyCopyValue',
renderWidget(/* ... */) {
let node = hm.CopyValue.prototype.renderWidget.apply(this, arguments);
node.firstChild.style.width = '30em';
return node;
},
write: function() {}
});
class VlessEncryption {
// origin:
// https://github.com/XTLS/Xray-core/pull/5067
// server:
// https://github.com/muink/mihomo/blob/7917f24f428e40ac20b8b8f953b02cf59d1be334/transport/vless/encryption/factory.go#L64
// https://github.com/muink/mihomo/blob/7917f24f428e40ac20b8b8f953b02cf59d1be334/transport/vless/encryption/server.go#L42
// client:
// https://github.com/muink/mihomo/blob/7917f24f428e40ac20b8b8f953b02cf59d1be334/transport/vless/encryption/factory.go#L12
// https://github.com/muink/mihomo/blob/7917f24f428e40ac20b8b8f953b02cf59d1be334/transport/vless/encryption/client.go#L45
/*
{
"method": "mlkem768x25519plus",
"xormode": "native",
"ticket": "600s",
"rtt": "0rtt",
"paddings": [ // Optional
"100-111-1111",
"75-0-111",
"50-0-3333",
...
],
"keypairs": [
{
"type": "vless-x25519",
"server": "cP5Oy9MOpTaBKKE17Pfd56mbb1CIfp5EMpyBYqr2EG8",
"client": "khEcQMT8j41xWmGYKpZtQ4vd8_9VWyFVmmCDIhRJ-Uk"
},
{
"type": "vless-mlkem768",
"server": "UHPx3nf-FVxF95byAw0YG025aQNw9HxKej-MiG5AhTcdW_WFpHlTVYQU5NHmXP6tmljSnB2iPmSQ29fisGxEog",
"client": "h4sdZgCc5-ZefvQ8mZmReOWQdxYb0mwngMdl7pKhYEZZpGWHUPKAmxug87Bgj3GqSHs195QeVpxfrMLNB5Jm0Ge71Fc-A3aLpaS3C3pARbGQoquUDUEVDNwEWjQTvFpGTUV3Nddw_LlRmWN6Wqhguti9cpS6GhEmkBvBayFeHgZosuaQ1FMoAqIeQzSSSoguCZtGLUmdQjEs3zc5rwG1rNanbhtyI3QnooYvr3A0vggIkbmddjtjwYaVQdMAj9Moavc12EAUajOV91QA73RWVuhelbe7pLumsHiW-emIdBgVhEgDDYdGaLq1E8QjB0WbIfufnJp-CJa3Ieu9gmDASTlQBeEREeA9gfoZcTpYD8elhJIJxaPJKXchvUVkFhZarcivlKoqVuaFPzsJM7KQCBC8zfS0t_oiBka-uzg3_Hl153nMTDaCAbZULPZGE-p2EazI2eFBCDktdHtDffJNo7i7ZYSkWkqN9ysr2QZRvYG_PYCzcYSo34Gf5WNvHKuz0Ye3kFkckfuirCmzr3knw2azrSOmpTOX_RSlMlse7HgFYwxHPMJnzPS19ymiwKZPgrAMvCmAUZmsxZGDoKeusNEDGSSFhLcTQys20qGBGYasIgKYGjAKGjK7SCxSOCGBQSU496XBkXQEeOB7k9Sh8jdB0pQGAZw9Ntwvrts2DjIUcsQBv-XEGfnHQXoBmDgzwzYEWxeHd0oNbPIlz7CqvNseoKu6uPZl85xynum6aWd6BDDAtwobbqYkuMUfOUhXf_cH13kWSnuJ6QrOxah94JzAnda3tWRDQ3RajOOjk-OXhbOqi8QMJRFdA_C-xMwQalM_rTSTKOqyCcaNSTkVmMlmyOt90tptk7jKUizDmGhGbsSU8WMY5mhdZ3eUd5O6gQitiMHI1EqnlaRNsXnKFoJ5yHV82Wp1dhFONCG_dlpqunVJD5bFgpxtdFDD-KmXQTymAalFjxeVl_xdc5xd4XYCYmk5dhEiQBE0J_S3Z6x0tmFORpWG9lESK_OBRSul9oKZh9Vet-UZ8FSOVtNFwbeokRwWpFuFL1dL3UpJeININ2cgUfDNWQlwItkokiFf_Kdy12y2O_hqJtoTpNttNxTOiclDzKM1KHNOjYJgTgydcid3mmJl3eA6ezyrDAw1RLCHBucIvYRfwbkmpYMvnfAaA2DIiaTNaSxX8BUl92V49UVKWlQSp8ijfmmTRHrBMmxKjvBIgHqC6dSMhVUEOMzCKXAO3giCS3eZzdrNQGhhqTxpYYnFf6uLoKOIiaGY-ByI1YoIVXxX8aCTOOpesFvHjwOKBEoj4Hoxd3iFMUJQazR7P2drnfmS11kgipM7pSUgB7POKwxEF0NQCedM41wVIuoathAqD6N6qalwQ6iOKlZOBUwwMVAMRDJ3aomG37ZeLYhv6fB0-pUUJSN1q4knjtkLFIJSUrih9FZ0XnOll_aeEgOICqQkb4aOMrovjcJEWvgdjUqGPdyIGgkurfqBRHih3dukUcYxt6Y__4KLQ7acqMx0FOFv0ZxFRTCIRGj_GAlFWUi6fpuPKebXUnEn1PRE0iNXwUV_4jESWb0"
},
...
]
}
*/
constructor(payload) {
this.input = payload || '';
try {
let content = JSON.parse(this.input.trim());
Object.keys(content).forEach(key => this[key] = content[key]);
} catch {}
this.method ||= hm.vless_encryption.methods[0][0];
this.xormode ||= hm.vless_encryption.xormodes[0][0];
this.ticket ||= hm.vless_encryption.tickets[0][0];
this.rtt ||= hm.vless_encryption.rtts[0][0];
this.paddings ||= [];
this.keypairs ||= [];
}
setKey(key, value) {
this[key] = value;
return this
}
_toMihomo(payload, side) {
if (!['server', 'client'].includes(side))
throw new Error('Unknown side: ' + side); // `Unknown side: '${side}'`
let required = [
payload.method,
payload.xormode,
side === 'server' ? payload.ticket : side === 'client' ? payload.rtt : null
].join('.');
return required +
(hm.isEmpty(payload.paddings) ? '' : '.' + payload.paddings.join('.')) + // Optional
(hm.isEmpty(payload.keypairs) ? '' : '.' + payload.keypairs.map(e => e[side]).join('.')); // Required
}
toString(format, side) {
format ||= 'json';
let payload = hm.removeBlankAttrs({
method: this.method,
xormode: this.xormode,
ticket: this.ticket,
rtt: this.rtt,
paddings: this.paddings || [],
keypairs: this.keypairs || []
});
if (format === 'json')
return JSON.stringify(payload);
else if (format === 'mihomo')
return this._toMihomo(payload, side);
else
throw new Error(`Unknown format: '${format}'`);
}
}
'require fchomo.listeners as lsnr'
return view.extend({
load() {
return Promise.all([
uci.load('fchomo'),
hm.getFeatures()
uci.load('fchomo')
]);
},
render(data) {
const dashboard_repo = uci.get(data[0], 'api', 'dashboard_repo');
const features = data[1];
let m, s, o;
@@ -168,845 +60,7 @@ return view.extend({
s.hm_field = hm.glossary[s.sectiontype].field;
s.hm_lowcase_only = false;
s.tab('field_general', _('General fields'));
s.tab('field_vless_encryption', _('Vless Encryption fields'));
s.tab('field_tls', _('TLS fields'));
s.tab('field_transport', _('Transport fields'));
s.tab('field_multiplex', _('Multiplex fields'));
s.tab('field_listen', _('Listen fields'));
/* General fields */
o = s.taboption('field_general', form.Value, 'label', _('Label'));
o.load = hm.loadDefaultLabel;
o.validate = hm.validateUniqueValue;
o.modalonly = true;
o = s.taboption('field_general', form.Flag, 'enabled', _('Enable'));
o.default = o.enabled;
o.editable = true;
o = s.taboption('field_general', form.Flag, 'auto_firewall', _('Firewall'),
_('Auto configure firewall'));
o.default = o.enabled;
o.editable = true;
o = s.taboption('field_general', form.ListValue, 'type', _('Type'));
o.default = hm.inbound_type[0][0];
hm.inbound_type.forEach((res) => {
o.value.apply(o, res);
})
o = s.taboption('field_general', form.Value, 'listen', _('Listen address'));
o.datatype = 'ipaddr';
o.placeholder = '::';
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'port', _('Listen port') + ' / ' + _('Ports pool'));
o.datatype = 'or(port, portrange)';
//o.placeholder = '1080,2079-2080,3080'; // @fw4 does not support port lists with commas
o.rmempty = false;
//o.validate = hm.validateCommonPort; // @fw4 does not support port lists with commas
// @dev: Features under development
// @rule
// @proxy
/* HTTP / SOCKS fields */
/* hm.validateAuth */
o = s.taboption('field_general', form.Value, 'username', _('Username'));
o.validate = hm.validateAuthUsername;
o.depends({type: /^(http|socks|mixed|mieru|trojan|anytls|hysteria2)$/});
o.modalonly = true;
o = s.taboption('field_general', hm.GenValue, 'password', _('Password'));
o.password = true;
o.validate = hm.validateAuthPassword;
o.rmempty = false;
o.depends({type: /^(http|socks|mixed|mieru|trojan|anytls|hysteria2)$/, username: /.+/});
o.depends({type: /^(tuic)$/, uuid: /.+/});
o.modalonly = true;
/* Hysteria2 fields */
o = s.taboption('field_general', form.Value, 'hysteria_up_mbps', _('Max upload speed'),
_('In Mbps.'));
o.datatype = 'uinteger';
o.depends('type', 'hysteria2');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'hysteria_down_mbps', _('Max download speed'),
_('In Mbps.'));
o.datatype = 'uinteger';
o.depends('type', 'hysteria2');
o.modalonly = true;
o = s.taboption('field_general', form.Flag, 'hysteria_ignore_client_bandwidth', _('Ignore client bandwidth'),
_('Tell the client to use the BBR flow control algorithm instead of Hysteria CC.'));
o.default = o.disabled;
o.depends({type: 'hysteria2', hysteria_up_mbps: '', hysteria_down_mbps: ''});
o.modalonly = true;
o = s.taboption('field_general', form.ListValue, 'hysteria_obfs_type', _('Obfuscate type'));
o.value('', _('Disable'));
o.value('salamander', _('Salamander'));
o.depends('type', 'hysteria2');
o.modalonly = true;
o = s.taboption('field_general', hm.GenValue, 'hysteria_obfs_password', _('Obfuscate password'),
_('Enabling obfuscation will make the server incompatible with standard QUIC connections, losing the ability to masquerade with HTTP/3.'));
o.password = true;
o.rmempty = false;
o.depends('type', 'hysteria');
o.depends({type: 'hysteria2', hysteria_obfs_type: /.+/});
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'hysteria_masquerade', _('Masquerade'),
_('HTTP3 server behavior when authentication fails.<br/>A 404 page will be returned if empty.'));
o.placeholder = 'file:///var/www or http://127.0.0.1:8080'
o.depends('type', 'hysteria2');
o.modalonly = true;
/* Shadowsocks fields */
o = s.taboption('field_general', form.ListValue, 'shadowsocks_chipher', _('Chipher'));
o.default = hm.shadowsocks_cipher_methods[1][0];
hm.shadowsocks_cipher_methods.forEach((res) => {
o.value.apply(o, res);
})
o.depends('type', 'shadowsocks');
o.modalonly = true;
o = s.taboption('field_general', hm.GenValue, 'shadowsocks_password', _('Password'));
o.password = true;
o.validate = function(section_id, value) {
const encmode = this.section.getOption('shadowsocks_chipher').formvalue(section_id);
return hm.validateShadowsocksPassword.call(this, encmode, section_id, value);
}
o.depends({type: 'shadowsocks', shadowsocks_chipher: /.+/});
o.modalonly = true;
/* Mieru fields */
o = s.taboption('field_general', form.ListValue, 'mieru_transport', _('Transport'));
o.default = 'TCP';
o.value('TCP');
o.value('UDP');
o.depends('type', 'mieru');
o.modalonly = true;
/* Sudoku fields */
const sudoku_keytypes = [
['sudoku-keypair', _('sudoku-keypair')],
['uuid', _('UUID')]
]
o = s.taboption('field_general', hm.GenValue, 'sudoku_key', _('Key'),
_('The ED25519 master public key or UUID generated by Sudoku.'));
o.hm_options = {
type: sudoku_keytypes[0][0],
callback: function(result) {
if (result.uuid)
return [
[this.option, result.uuid],
['sudoku_client_key', result.uuid]
]
else
return [
[this.option, result.public_key],
['sudoku_client_key', result.private_key]
]
}
}
o.renderWidget = function(section_id, option_index, cfgvalue) {
let node = form.Value.prototype.renderWidget.call(this, section_id, option_index, cfgvalue);
const cbid = this.cbid(section_id) + '._keytype_select';
const selected = this.hm_options.type;
let selectEl = E('select', {
id: cbid,
class: 'cbi-input-select',
style: 'width: 10em',
});
sudoku_keytypes.forEach(([k, v]) => {
selectEl.appendChild(E('option', {
'value': k,
'selected': (k === selected) ? '' : null
}, [ v ]));
});
node.appendChild(E('div', { 'class': 'control-group' }, [
selectEl,
E('button', {
class: 'cbi-button cbi-button-add',
click: ui.createHandlerFn(this, () => {
this.hm_options.type = document.getElementById(cbid).value;
return hm.handleGenKey.call(this, this.hm_options);
})
}, [ _('Generate') ])
]));
return node;
}
o.rmempty = false;
o.depends('type', 'sudoku');
o.modalonly = true;
o = s.taboption('field_general', hm.CopyValue, 'sudoku_client_key', _('Client key'));
o.depends('type', 'sudoku');
o.modalonly = true;
o = s.taboption('field_general', form.ListValue, 'sudoku_aead_method', _('Chipher'));
o.default = hm.sudoku_cipher_methods[0][0];
hm.sudoku_cipher_methods.forEach((res) => {
o.value.apply(o, res);
})
o.validate = function(section_id, value) {
const pure_downlink = this.section.getUIElement(section_id, 'sudoku_enable_pure_downlink')?.node.querySelector('input').checked;
if (value === 'none' && pure_downlink === false)
return _('Expecting: %s').format(_('Chipher must be enabled if obfuscate downlink is disabled.'));
return true;
}
o.depends('type', 'sudoku');
o.modalonly = true;
o = s.taboption('field_general', form.ListValue, 'sudoku_table_type', _('Obfuscate type'));
o.value('prefer_ascii', _('Obfuscated as ASCII data stream'));
o.value('prefer_entropy', _('Obfuscated as low-entropy data stream'));
o.depends('type', 'sudoku');
o.modalonly = true;
o = s.taboption('field_general', form.DynamicList, 'sudoku_custom_tables', _('Custom byte layout'));
o.renderWidget = function(/* ... */) {
let node = form.DynamicList.prototype.renderWidget.apply(this, arguments);
(node.querySelector('.control-group') || node).appendChild(E('button', {
class: 'cbi-button cbi-button-positive',
title: _('Generate'),
click: ui.createHandlerFn(this, hm.handleGenKey, this.hm_options || this.option)
}, [ _('Generate') ]));
return node;
}
o.validate = hm.validateSudokuCustomTable;
o.depends('sudoku_table_type', 'prefer_entropy');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'sudoku_padding_min', _('Minimum padding rate'));
o.datatype = 'and(uinteger, range(0, 100))';
o.default = 1;
o.rmempty = false;
o.depends('type', 'sudoku');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'sudoku_padding_max', _('Maximum padding rate'));
o.datatype = 'and(uinteger, range(0, 100))';
o.default = 15;
o.rmempty = false;
o.validate = function(section_id, value) {
const padding_min = this.section.getOption('sudoku_padding_min').formvalue(section_id);
if (value < padding_min)
return _('Expecting: %s').format(_('Maximum padding rate must be greater than or equal to the minimum padding rate.'));
return true;
}
o.depends('type', 'sudoku');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'sudoku_handshake_timeout', _('Handshake timeout'),
_('In seconds.'));
o.datatype = 'uinteger';
o.placeholder = 5;
o.depends('type', 'sudoku');
o.modalonly = true;
o = s.taboption('field_general', form.Flag, 'sudoku_enable_pure_downlink', _('Enable obfuscate for downlink'),
_('false = bandwidth optimized downlink; true = pure Sudoku downlink.'));
o.default = o.enabled;
o.depends('type', 'sudoku');
o.modalonly = true;
o = s.taboption('field_general', form.Flag, 'sudoku_http_mask', _('HTTP mask'));
o.default = o.enabled;
o.depends('type', 'sudoku');
o.modalonly = true;
o = s.taboption('field_general', form.ListValue, 'sudoku_http_mask_mode', _('HTTP mask mode'));
o.default = 'legacy';
o.value('legacy', _('Legacy'));
o.value('stream', _('split-stream') + ' - ' + _('CDN support'));
o.value('poll', _('poll') + ' - ' + _('CDN support'));
o.value('auto', _('Auto') + ' - ' + _('CDN support'));
o.depends('sudoku_http_mask', '1');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'sudoku_path_root', _('HTTP root path'));
o.depends('sudoku_http_mask', '1');
o.modalonly = true;
/* Tuic fields */
o = s.taboption('field_general', hm.GenValue, 'uuid', _('UUID'));
o.rmempty = false;
o.validate = hm.validateUUID;
o.depends('type', 'tuic');
o.modalonly = true;
o = s.taboption('field_general', form.ListValue, 'tuic_congestion_controller', _('Congestion controller'),
_('QUIC congestion controller.'));
o.default = hm.congestion_controller[0][0];
hm.congestion_controller.forEach((res) => {
o.value.apply(o, res);
})
o.depends('type', 'tuic');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'tuic_max_udp_relay_packet_size', _('Max UDP relay packet size'));
o.datatype = 'uinteger';
o.default = '1500';
o.depends('type', 'tuic');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'tuic_max_idle_time', _('Idle timeout'),
_('In seconds.'));
o.default = '15000';
o.validate = hm.validateTimeDuration;
o.depends('type', 'tuic');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'tuic_authentication_timeout', _('Auth timeout'),
_('In seconds.'));
o.default = '1000';
o.validate = hm.validateTimeDuration;
o.depends('type', 'tuic');
o.modalonly = true;
/* Trojan fields */
o = s.taboption('field_general', form.Flag, 'trojan_ss_enabled', _('Shadowsocks encrypt'));
o.default = o.disabled;
o.depends('type', 'trojan');
o.modalonly = true;
o = s.taboption('field_general', form.ListValue, 'trojan_ss_chipher', _('Shadowsocks chipher'));
o.default = hm.trojan_cipher_methods[0][0];
hm.trojan_cipher_methods.forEach((res) => {
o.value.apply(o, res);
})
o.depends({type: 'trojan', trojan_ss_enabled: '1'});
o.modalonly = true;
o = s.taboption('field_general', hm.GenValue, 'trojan_ss_password', _('Shadowsocks password'));
o.password = true;
o.validate = function(section_id, value) {
const encmode = this.section.getOption('trojan_ss_chipher').formvalue(section_id);
return hm.validateShadowsocksPassword.call(this, encmode, section_id, value);
}
o.depends({type: 'trojan', trojan_ss_enabled: '1'});
o.modalonly = true;
/* AnyTLS fields */
o = s.taboption('field_general', form.TextValue, 'anytls_padding_scheme', _('Padding scheme'));
o.depends('type', 'anytls');
o.modalonly = true;
/* VMess / VLESS fields */
o = s.taboption('field_general', hm.GenValue, 'vmess_uuid', _('UUID'));
o.rmempty = false;
o.validate = hm.validateUUID;
o.depends({type: /^(vmess|vless)$/});
o.modalonly = true;
o = s.taboption('field_general', form.ListValue, 'vless_flow', _('Flow'));
o.default = hm.vless_flow[0][0];
hm.vless_flow.forEach((res) => {
o.value.apply(o, res);
})
o.depends('type', 'vless');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'vmess_alterid', _('Alter ID'),
_('Legacy protocol support (VMess MD5 Authentication) is provided for compatibility purposes only, use of alterId > 1 is not recommended.'));
o.datatype = 'uinteger';
o.placeholder = '0';
o.depends('type', 'vmess');
o.modalonly = true;
/* Plugin fields */
o = s.taboption('field_general', form.ListValue, 'plugin', _('Plugin'));
o.value('', _('none'));
o.value('shadow-tls', _('shadow-tls'));
//o.value('kcp-tun', _('kcp-tun'));
o.depends('type', 'shadowsocks');
o.modalonly = true;
o = s.taboption('field_general', form.Value, 'plugin_opts_handshake_dest', _('Plugin: ') + _('Handshake target that supports TLS 1.3'));
o.datatype = 'hostport';
o.placeholder = 'cloud.tencent.com:443';
o.rmempty = false;
o.depends({plugin: 'shadow-tls'});
o.modalonly = true;
o = s.taboption('field_general', hm.GenValue, 'plugin_opts_thetlspassword', _('Plugin: ') + _('Password'));
o.password = true;
o.rmempty = false;
o.depends({plugin: 'shadow-tls'});
o.modalonly = true;
o = s.taboption('field_general', form.ListValue, 'plugin_opts_shadowtls_version', _('Plugin: ') + _('Version'));
o.value('1', _('v1'));
o.value('2', _('v2'));
o.value('3', _('v3'));
o.default = '3';
o.depends({plugin: 'shadow-tls'});
o.modalonly = true;
/* Extra fields */
o = s.taboption('field_general', form.Flag, 'udp', _('UDP'));
o.default = o.disabled;
o.depends({type: /^(socks|mixed|shadowsocks)$/});
o.modalonly = true;
/* Vless Encryption fields */
o = s.taboption('field_general', form.Flag, 'vless_decryption', _('decryption'));
o.default = o.disabled;
o.depends('type', 'vless');
o.modalonly = true;
const initVlessEncryptionOption = function(o, key) {
o.load = function(section_id) {
return new VlessEncryption(uci.get(data[0], section_id, 'vless_encryption_hmpayload'))[key];
}
o.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'vless_encryption_hmpayload');
let newpayload = new VlessEncryption(UIEl.getValue()).setKey(key, value);
UIEl.setValue(newpayload.toString());
[
['server', '_vless_encryption_decryption'],
['client', '_vless_encryption_encryption']
].forEach(([side, option]) => {
UIEl = this.section.getUIElement(section_id, option);
UIEl.setValue(newpayload.toString('mihomo', side));
});
}
o.write = function() {};
}
o = s.taboption('field_vless_encryption', form.Value, 'vless_encryption_hmpayload', _('Payload'));
o.readonly = true;
o.depends('vless_decryption', '1');
o.modalonly = true;
o = s.taboption('field_vless_encryption', CBIDummyCopyValue, '_vless_encryption_decryption', _('decryption'));
o.depends('vless_decryption', '1');
o.modalonly = true;
o = s.taboption('field_vless_encryption', CBIDummyCopyValue, '_vless_encryption_encryption', _('encryption'));
o.depends('vless_decryption', '1');
o.modalonly = true;
o = s.taboption('field_vless_encryption', form.ListValue, 'vless_encryption_method', _('Encryption method'));
o.default = hm.vless_encryption.methods[0][0];
hm.vless_encryption.methods.forEach((res) => {
o.value.apply(o, res);
})
initVlessEncryptionOption(o, 'method');
o.depends('vless_decryption', '1');
o.modalonly = true;
o = s.taboption('field_vless_encryption', form.RichListValue, 'vless_encryption_xormode', _('XOR mode'));
o.default = hm.vless_encryption.xormodes[0][0];
hm.vless_encryption.xormodes.forEach((res) => {
o.value.apply(o, res);
})
initVlessEncryptionOption(o, 'xormode');
o.depends('vless_decryption', '1');
o.modalonly = true;
o = s.taboption('field_vless_encryption', hm.RichValue, 'vless_encryption_ticket', _('Server') +' '+ _('RTT'));
o.default = hm.vless_encryption.tickets[0][0];
hm.vless_encryption.tickets.forEach((res) => {
o.value.apply(o, res);
})
initVlessEncryptionOption(o, 'ticket');
o.validate = function(section_id, value) {
if (!value)
return true;
if (!value.match(/^(\d+-)?\d+s$/))
return _('Expecting: %s').format('^(\\d+-)?\\d+s$');
return true;
}
o.rmempty = false;
o.depends('vless_decryption', '1');
o.modalonly = true;
o = s.taboption('field_vless_encryption', form.ListValue, 'vless_encryption_rtt', _('Client') +' '+ _('RTT'));
o.default = hm.vless_encryption.rtts[0][0];
hm.vless_encryption.rtts.forEach((res) => {
o.value.apply(o, res);
})
initVlessEncryptionOption(o, 'rtt');
o.rmempty = false;
o.depends('vless_decryption', '1');
o.modalonly = true;
o = s.taboption('field_vless_encryption', hm.less_25_12 ? hm.DynamicList : form.DynamicList, 'vless_encryption_paddings', _('Paddings'), // @less_25_12
_('The server and client can set different padding parameters.') + '</br>' +
_('In the order of one <code>Padding-Length</code> and one <code>Padding-Interval</code>, infinite concatenation.') + '</br>' +
_('The first padding must have a probability of 100% and at least 35 bytes.'));
hm.vless_encryption.paddings.forEach((res) => {
o.value.apply(o, res);
})
initVlessEncryptionOption(o, 'paddings');
o.validate = function(section_id, value) {
if (!value)
return true;
if (!value.match(/^\d+(-\d+){2}$/))
return _('Expecting: %s').format('^\\d+(-\\d+){2}$');
return true;
}
o.allowduplicates = true;
o.depends('vless_decryption', '1');
o.modalonly = true;
o = s.taboption('field_vless_encryption', hm.GenText, 'vless_encryption_keypairs', _('Keypairs'));
o.placeholder = '[\n {\n "type": "vless-x25519",\n "server": "cP5Oy9MOpTaBKKE17Pfd56mbb1CIfp5EMpyBYqr2EG8",\n "client": "khEcQMT8j41xWmGYKpZtQ4vd8_9VWyFVmmCDIhRJ-Uk"\n },\n {\n "type": "vless-mlkem768",\n "server": "UHPx3nf-FVxF95byAw0YG025aQNw9HxKej-MiG5AhTcdW_WFpHlTVYQU5NHmXP6tmljSnB2iPmSQ29fisGxEog",\n "client": "h4sdZgCc5-ZefvQ8mZmReOWQdxYb0mwngMdl7pKhYEZZpGWHUPKAmxug87Bgj3GqSHs195QeVpxfrMLNB5J..."\n },\n ...\n]';
o.rows = 10;
o.hm_options = {
type: hm.vless_encryption.keypairs.types[0][0],
params: '',
callback: function(result) {
const section_id = this.section.section;
const key_type = this.hm_options.type;
let keypair = {"type": key_type, "server": "", "client": ""};
switch (key_type) {
case 'vless-x25519':
keypair.server = result.private_key;
keypair.client = result.password;
break;
case 'vless-mlkem768':
keypair.server = result.seed;
keypair.client = result.client;
break;
default:
break;
}
let keypairs = [];
try {
keypairs = JSON.parse(this.formvalue(section_id).trim());
} catch {}
if (!Array.isArray(keypairs))
keypairs = [];
keypairs.push(keypair);
return [
[this.option, JSON.stringify(keypairs, null, 2)]
]
}
}
o.renderWidget = function(section_id, option_index, cfgvalue) {
let node = hm.TextValue.prototype.renderWidget.call(this, section_id, option_index, cfgvalue);
const cbid = this.cbid(section_id) + '._keytype_select';
const selected = this.hm_options.type;
let selectEl = E('select', {
id: cbid,
class: 'cbi-input-select',
style: 'width: 10em',
});
hm.vless_encryption.keypairs.types.forEach(([k, v]) => {
selectEl.appendChild(E('option', {
'value': k,
'selected': (k === selected) ? '' : null
}, [ v ]));
});
node.appendChild(E('div', { 'class': 'control-group' }, [
selectEl,
E('button', {
class: 'cbi-button cbi-button-add',
click: ui.createHandlerFn(this, () => {
this.hm_options.type = document.getElementById(cbid).value;
return hm.handleGenKey.call(this, this.hm_options);
})
}, [ _('Generate') ])
]));
return node;
}
o.load = function(section_id) {
return JSON.stringify(new VlessEncryption(uci.get(data[0], section_id, 'vless_encryption_hmpayload'))['keypairs'], null, 2);
}
o.validate = function(section_id, value) {
let result = hm.validateJson.call(this, section_id, value);
if (result === true) {
let keypairs = JSON.parse(value.trim());
if (Array.isArray(keypairs) && keypairs.length >= 1) {
let UIEl = this.section.getUIElement(section_id, 'vless_encryption_hmpayload');
let newpayload = new VlessEncryption(UIEl.getValue()).setKey('keypairs', keypairs);
UIEl.setValue(newpayload.toString());
[
['server', '_vless_encryption_decryption'],
['client', '_vless_encryption_encryption']
].forEach(([side, option]) => {
UIEl = this.section.getUIElement(section_id, option);
UIEl.setValue(newpayload.toString('mihomo', side));
});
} else
return _('Expecting: %s').format(_('least one keypair required'));
return true;
} else
return result;
}
o.rmempty = false;
o.depends('vless_decryption', '1');
o.modalonly = true;
/* TLS fields */
o = s.taboption('field_general', form.Flag, 'tls', _('TLS'));
o.default = o.disabled;
o.validate = function(section_id, value) {
const type = this.section.getOption('type').formvalue(section_id);
let tls = this.section.getUIElement(section_id, 'tls').node.querySelector('input');
let tls_alpn = this.section.getUIElement(section_id, 'tls_alpn');
let tls_reality = this.section.getUIElement(section_id, 'tls_reality').node.querySelector('input');
// Force enabled
if (['trojan', 'anytls', 'tuic', 'hysteria2'].includes(type)) {
tls.checked = true;
tls.disabled = true;
if (['tuic', 'hysteria2'].includes(type) && !`${tls_alpn.getValue()}`)
tls_alpn.setValue('h3');
} else {
tls.removeAttribute('disabled');
}
// Force disabled
if (!['vmess', 'vless', 'trojan'].includes(type)) {
tls_reality.checked = false;
tls_reality.disabled = true;
} else {
tls_reality.removeAttribute('disabled');
}
return true;
}
o.depends({type: /^(http|socks|mixed|vmess|vless|trojan|anytls|tuic|hysteria2)$/});
o.modalonly = true;
o = s.taboption('field_tls', form.DynamicList, 'tls_alpn', _('TLS ALPN'),
_('List of supported application level protocols, in order of preference.'));
o.depends('tls', '1');
o.modalonly = true;
o = s.taboption('field_tls', form.Value, 'tls_cert_path', _('Certificate path'),
_('The %s public key, in PEM format.').format(_('Server')));
o.value('/etc/fchomo/certs/server_publickey.pem');
o.depends({tls: '1', tls_reality: '0'});
o.rmempty = false;
o.modalonly = true;
o = s.taboption('field_tls', form.Button, '_upload_cert', _('Upload certificate'),
_('<strong>Save your configuration before uploading files!</strong>'));
o.inputstyle = 'action';
o.inputtitle = _('Upload...');
o.depends({tls: '1', tls_cert_path: '/etc/fchomo/certs/server_publickey.pem'});
o.onclick = L.bind(hm.uploadCertificate, o, _('certificate'), 'server_publickey');
o.modalonly = true;
o = s.taboption('field_tls', form.Value, 'tls_key_path', _('Key path'),
_('The %s private key, in PEM format.').format(_('Server')));
o.value('/etc/fchomo/certs/server_privatekey.pem');
o.rmempty = false;
o.depends({tls: '1', tls_cert_path: /.+/});
o.modalonly = true;
o = s.taboption('field_tls', form.Button, '_upload_key', _('Upload key'),
_('<strong>Save your configuration before uploading files!</strong>'));
o.inputstyle = 'action';
o.inputtitle = _('Upload...');
o.depends({tls: '1', tls_key_path: '/etc/fchomo/certs/server_privatekey.pem'});
o.onclick = L.bind(hm.uploadCertificate, o, _('private key'), 'server_privatekey');
o.modalonly = true;
o = s.taboption('field_tls', form.ListValue, 'tls_client_auth_type', _('Client Auth type') + _(' (mTLS)'));
o.default = hm.tls_client_auth_types[0][0];
hm.tls_client_auth_types.forEach((res) => {
o.value.apply(o, res);
})
o.depends({tls: '1', type: /^(http|socks|mixed|vmess|vless|trojan|anytls|hysteria2|tuic)$/});
o.modalonly = true;
o = s.taboption('field_tls', form.Value, 'tls_client_auth_cert_path', _('Client Auth Certificate path') + _(' (mTLS)'),
_('The %s public key, in PEM format.').format(_('Client')));
o.value('/etc/fchomo/certs/client_publickey.pem');
o.validate = function(/* ... */) {
return hm.validateMTLSClientAuth.call(this, 'tls_client_auth_type', ...arguments);
}
o.depends({tls: '1', type: /^(http|socks|mixed|vmess|vless|trojan|anytls|hysteria2|tuic)$/});
o.modalonly = true;
o = s.taboption('field_tls', form.Button, '_upload_client_auth_cert', _('Upload certificate') + _(' (mTLS)'),
_('<strong>Save your configuration before uploading files!</strong>'));
o.inputstyle = 'action';
o.inputtitle = _('Upload...');
o.depends({tls: '1', tls_client_auth_cert_path: '/etc/fchomo/certs/client_publickey.pem'});
o.onclick = L.bind(hm.uploadCertificate, o, _('certificate'), 'client_publickey');
o.modalonly = true;
o = s.taboption('field_tls', hm.GenText, 'tls_ech_key', _('ECH key'));
o.placeholder = '-----BEGIN ECH KEYS-----\nACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK\nmadSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz\ndC5jb20AAA==\n-----END ECH KEYS-----';
o.hm_placeholder = 'outer-sni.any.domain';
o.cols = 30;
o.rows = 2;
o.hm_options = {
type: 'ech-keypair',
params: '',
callback: function(result) {
return [
[this.option, result.ech_key],
['tls_ech_config', result.ech_cfg]
]
}
}
o.renderWidget = function(section_id, option_index, cfgvalue) {
let node = hm.TextValue.prototype.renderWidget.call(this, section_id, option_index, cfgvalue);
const cbid = this.cbid(section_id) + '._outer_sni';
node.appendChild(E('div', { 'class': 'control-group' }, [
E('input', {
id: cbid,
class: 'cbi-input-text',
style: 'width: 10em',
placeholder: this.hm_placeholder
}),
E('button', {
class: 'cbi-button cbi-button-add',
click: ui.createHandlerFn(this, () => {
this.hm_options.params = document.getElementById(cbid).value;
return hm.handleGenKey.call(this, this.hm_options);
})
}, [ _('Generate') ])
]));
return node;
}
o.depends({tls: '1', type: /^(http|socks|mixed|vmess|vless|trojan|anytls|hysteria2|tuic)$/});
o.modalonly = true;
o = s.taboption('field_tls', hm.CopyValue, 'tls_ech_config', _('ECH config'),
_('This ECH parameter needs to be added to the HTTPS record of the domain.'));
o.placeholder = 'AEn+DQBFKwAgACABWIHUGj4u+PIggYXcR5JF0gYk3dCRioBW8uJq9H4mKAAIAAEAAQABAANAEnB1YmxpYy50bHMtZWNoLmRldgAA';
o.depends({tls: '1', type: /^(http|socks|mixed|vmess|vless|trojan|anytls|hysteria2|tuic)$/});
o.modalonly = true;
// uTLS fields
o = s.taboption('field_tls', form.Flag, 'tls_reality', _('REALITY'));
o.default = o.disabled;
o.depends('tls', '1');
o.modalonly = true;
o = s.taboption('field_tls', form.Value, 'tls_reality_dest', _('REALITY handshake server'));
o.datatype = 'hostport';
o.placeholder = 'cloud.tencent.com:443';
o.rmempty = false;
o.depends('tls_reality', '1');
o.modalonly = true;
o = s.taboption('field_tls', hm.GenValue, 'tls_reality_private_key', _('REALITY private key'));
o.hm_options = {
type: 'reality-keypair',
callback: function(result) {
return [
[this.option, result.private_key],
['tls_reality_public_key', result.public_key]
]
}
}
o.password = true;
o.rmempty = false;
o.depends('tls_reality', '1');
o.modalonly = true;
o = s.taboption('field_tls', hm.CopyValue, 'tls_reality_public_key', _('REALITY public key'));
o.depends('tls_reality', '1');
o.modalonly = true;
o = s.taboption('field_tls', form.DynamicList, 'tls_reality_short_id', _('REALITY short ID'));
//o.value('', '""');
o.rmempty = false;
o.depends('tls_reality', '1');
o.modalonly = true;
o = s.taboption('field_tls', form.DynamicList, 'tls_reality_server_names', _('REALITY certificate issued to'));
o.datatype = 'list(hostname)';
o.placeholder = 'cloud.tencent.com';
o.rmempty = false;
o.depends('tls_reality', '1');
o.modalonly = true;
/* Transport fields */
o = s.taboption('field_general', form.Flag, 'transport_enabled', _('Transport'));
o.default = o.disabled;
o.depends({type: /^(vmess|vless|trojan)$/});
o.modalonly = true;
o = s.taboption('field_transport', form.ListValue, 'transport_type', _('Transport type'));
o.value('grpc', _('gRPC'));
o.value('ws', _('WebSocket'));
o.validate = function(section_id, value) {
const type = this.section.getOption('type').formvalue(section_id);
switch (type) {
case 'vmess':
case 'vless':
if (!['http', 'h2', 'grpc', 'ws'].includes(value))
return _('Expecting: only support %s.').format(_('HTTP') +
' / ' + _('HTTPUpgrade') +
' / ' + _('gRPC') +
' / ' + _('WebSocket'));
break;
case 'trojan':
if (!['grpc', 'ws'].includes(value))
return _('Expecting: only support %s.').format(_('gRPC') +
' / ' + _('WebSocket'));
break;
default:
break;
}
return true;
}
o.depends('transport_enabled', '1');
o.modalonly = true;
o = s.taboption('field_transport', form.Value, 'transport_path', _('Request path'));
o.placeholder = '/';
o.default = '/';
o.rmempty = false;
o.depends({transport_enabled: '1', transport_type: 'ws'});
o.modalonly = true;
o = s.taboption('field_transport', form.Value, 'transport_grpc_servicename', _('gRPC service name'));
o.placeholder = 'GunService';
o.rmempty = false;
o.depends({transport_enabled: '1', transport_type: 'grpc'});
o.modalonly = true;
lsnr.renderListeners(s, data[0], false);
/* Server settings END */
return m.render();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34 -24
View File
@@ -45,6 +45,31 @@ opmc() { # @less_25_12
$OPM $action "$@"
}
# add_firewall
add_firewall() {
local enabled auto_firewall listen port
config_get_bool enabled "$1" "enabled" "1"
config_get_bool auto_firewall "$1" "auto_firewall" "1"
config_get listen "$1" "listen" "::"
config_get port "$1" "port"
[ "$enabled" = "0" ] && return 0
[ "$auto_firewall" = "0" ] && return 0
json_add_object ''
json_add_string type rule
json_add_string target ACCEPT
json_add_string name "$1"
#json_add_string family '' # '' = IPv4 and IPv6
json_add_string proto 'tcp udp'
json_add_string direction in
json_add_string src "*"
#json_add_string dest '' # '' = input
json_add_string dest_ip "$(echo "$listen" | grep -vE '^(0\.\d+\.\d+\.\d+|::)$')"
json_add_string dest_port "$port"
json_close_object
}
config_load "$CONF"
# define global var: DEF_WAN DEF_WAN6 NIC_* NIC6_*
@@ -333,6 +358,15 @@ start_service() {
procd_set_param stderr 1
procd_set_param respawn
# add_firewall
procd_open_data
# configure firewall
json_add_array firewall
# meta l4proto %s th dport %s counter accept comment "!%s: accept server instance [%s]"
config_foreach add_firewall "inbound"
json_close_array
procd_close_data
procd_close_instance
fi
fi
@@ -380,30 +414,6 @@ start_service() {
procd_set_param respawn
# add_firewall
add_firewall() {
local enabled auto_firewall listen port
config_get_bool enabled "$1" "enabled" "1"
config_get_bool auto_firewall "$1" "auto_firewall" "1"
config_get listen "$1" "listen" "::"
config_get port "$1" "port"
[ "$enabled" = "0" ] && return 0
[ "$auto_firewall" = "0" ] && return 0
json_add_object ''
json_add_string type rule
json_add_string target ACCEPT
json_add_string name "$1"
#json_add_string family '' # '' = IPv4 and IPv6
json_add_string proto 'tcp udp'
json_add_string direction in
json_add_string src "*"
#json_add_string dest '' # '' = input
json_add_string dest_ip "$(echo "$listen" | grep -vE '^(0\.\d+\.\d+\.\d+|::)$')"
json_add_string dest_port "$port"
json_close_object
}
#
procd_open_data
# configure firewall
json_add_array firewall
@@ -34,6 +34,22 @@ migrate() {
uci_set "$CONF" "$1" vless_encryption_encryption "$vless_encryption"
fi
fi
# tuic_congestion_controller -> congestion_controller
if isDefined "$1" tuic_congestion_controller; then
local tuic_congestion_controller
config_get tuic_congestion_controller "$1" tuic_congestion_controller ""
uci_remove "$CONF" "$1" tuic_congestion_controller
uci_set "$CONF" "$1" congestion_controller "$tuic_congestion_controller"
fi
# masque_congestion_controller -> congestion_controller
if isDefined "$1" masque_congestion_controller; then
local masque_congestion_controller
config_get masque_congestion_controller "$1" masque_congestion_controller ""
uci_remove "$CONF" "$1" masque_congestion_controller
uci_set "$CONF" "$1" congestion_controller "$masque_congestion_controller"
fi
}
config_foreach migrate node
@@ -0,0 +1,33 @@
#!/bin/sh
# Migration script for fchomo server
# Used to migrate LuCI application server option.
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
CONF=fchomo
config_load "$CONF"
# isDefined <section> <option>
isDefined() {
local opt=CONFIG_${1}_${2};
eval "[ -n \"\${$opt+x}\" ] && return 0 || return 1"
}
migrate() {
# tuic_congestion_controller -> congestion_controller
if isDefined "$1" tuic_congestion_controller; then
local tuic_congestion_controller
config_get tuic_congestion_controller "$1" tuic_congestion_controller ""
uci_remove "$CONF" "$1" tuic_congestion_controller
uci_set "$CONF" "$1" congestion_controller "$tuic_congestion_controller"
fi
}
config_foreach migrate server
uci_commit "$CONF"
exit 0
@@ -11,6 +11,7 @@ import { urldecode, urlencode } from 'luci.http';
import {
isEmpty, strToBool, strToInt, bytesizeToByte, durationToSecond,
arrToObj, removeBlankAttrs,
parseListener,
HM_DIR, RUN_DIR, PRESET_OUTBOUND, RULES_LOGICAL_TYPE
} from 'fchomo';
@@ -39,6 +40,7 @@ const ucisniff = 'sniff',
ucidnspoli = 'dns_policy',
ucidnsnpoli = 'dns_node_policy',
ucipgrp = 'proxy_group',
uciinbd = 'inbound',
ucinode = 'node',
uciprov = 'provider',
ucichain = 'dialer_proxy',
@@ -326,29 +328,14 @@ uci.foreach(uciconf, ucisniff, (cfg) => {
/* Inbound START */
const proxy_mode = uci.get(uciconf, uciinbound, 'proxy_mode') || 'redir_tproxy';
/* Listen ports */
config.listeners = [];
push(config.listeners, {
name: 'mixed-in',
type: 'mixed',
port: strToInt(uci.get(uciconf, uciinbound, 'mixed_port')) || 7890,
listen: '::',
udp: true
});
config["allow-lan"] = true;
config["bind-address"] = "*";
config["mixed-port"] = strToInt(uci.get(uciconf, uciinbound, 'mixed_port')) || 7890;
if (match(proxy_mode, /redir/))
push(config.listeners, {
name: 'redir-in',
type: 'redir',
port: strToInt(uci.get(uciconf, uciinbound, 'redir_port')) || 7891,
listen: '::'
});
config["redir-port"] = strToInt(uci.get(uciconf, uciinbound, 'redir_port')) || 7891;
if (match(proxy_mode, /tproxy/))
push(config.listeners, {
name: 'tproxy-in',
type: 'tproxy',
port: strToInt(uci.get(uciconf, uciinbound, 'tproxy_port')) || 7892,
listen: '::',
udp: true
});
config["tproxy-port"] = strToInt(uci.get(uciconf, uciinbound, 'tproxy_port')) || 7892;
config.listeners = [];
push(config.listeners, {
name: 'dns-in',
type: 'tunnel',
@@ -357,6 +344,13 @@ push(config.listeners, {
network: ['tcp', 'udp'],
target: '1.1.1.1:53'
}); // @Not required for v1.19.2+
/* Custom Inbound settings */
uci.foreach(uciconf, uciinbd, (cfg) => {
if (cfg.enabled === '0')
return;
push(config.listeners, parseListener(cfg, true, get_proxygroup(cfg.proxy)));
});
/* Tun settings */
if (match(proxy_mode, /tun/))
config.tun = {
@@ -502,7 +496,6 @@ uci.foreach(uciconf, ucinode, (cfg) => {
cipher: cfg.vmess_chipher || cfg.shadowsocks_chipher,
password: cfg.shadowsocks_password || cfg.password,
headers: cfg.headers ? json(cfg.headers) : null,
"congestion-controller": cfg.tuic_congestion_controller || cfg.masque_congestion_controller,
"private-key": cfg.masque_private_key || cfg.wireguard_private_key || cfg.ssh_priv_key,
"public-key": cfg.masque_endpoint_public_key || cfg.wireguard_peer_public_key,
ip: cfg.masque_ip || cfg.wireguard_ip,
@@ -531,6 +524,7 @@ uci.foreach(uciconf, ucinode, (cfg) => {
transport: cfg.mieru_transport,
multiplexing: cfg.mieru_multiplexing,
"handshake-mode": cfg.mieru_handshake_mode,
"traffic-pattern": cfg.mieru_traffic_pattern,
/* Sudoku */
key: cfg.sudoku_key,
@@ -540,12 +534,16 @@ uci.foreach(uciconf, ucinode, (cfg) => {
"table-type": cfg.sudoku_table_type,
"custom-tables": cfg.sudoku_custom_tables,
"enable-pure-downlink": (cfg.sudoku_enable_pure_downlink === '0') ? false : null,
"http-mask": (cfg.sudoku_http_mask === '0') ? false : true,
"http-mask-mode": cfg.sudoku_http_mask_mode,
"http-mask-tls": strToBool(cfg.sudoku_http_mask_tls),
"http-mask-host": cfg.sudoku_http_mask_host,
"path-root": cfg.sudoku_path_root,
"http-mask-multiplex": cfg.sudoku_http_mask_multiplex,
...(cfg.type === 'sudoku' ? {
httpmask: (cfg.sudoku_http_mask === '0') ? { disable: true } : {
disable: false,
mode: cfg.sudoku_http_mask_mode,
tls: strToBool(cfg.sudoku_http_mask_tls) || false,
host: cfg.sudoku_http_mask_host,
path_root: cfg.sudoku_path_root,
multiplex: cfg.sudoku_http_mask_multiplex,
}
} : {}),
/* Snell */
psk: cfg.snell_psk,
@@ -587,6 +585,10 @@ uci.foreach(uciconf, ucinode, (cfg) => {
"packet-encoding": cfg.vmess_packet_encoding,
encryption: cfg.vless_encryption === '1' ? cfg.vless_encryption_encryption : null,
/* TrustTunnel */
"health-check": strToBool(cfg.trusttunnel_health_check === '0' ? false : true),
quic: strToBool(cfg.trusttunnel_quic),
/* WireGuard */
"pre-shared-key": cfg.wireguard_pre_shared_key,
"allowed-ips": cfg.wireguard_allowed_ips,
@@ -605,12 +607,13 @@ uci.foreach(uciconf, ucinode, (cfg) => {
} : null,
/* Extra fields */
"congestion-controller": cfg.congestion_controller,
udp: strToBool(cfg.udp),
"udp-over-tcp": strToBool(cfg.uot),
"udp-over-tcp-version": cfg.uot_version,
/* TLS fields */
tls: (cfg.type in ['trojan', 'anytls', 'hysteria', 'hysteria2', 'tuic']) ? null : strToBool(cfg.tls),
tls: (cfg.type in ['trojan', 'anytls', 'hysteria', 'hysteria2', 'tuic', 'trusttunnel']) ? null : strToBool(cfg.tls),
"disable-sni": strToBool(cfg.tls_disable_sni),
...arrToObj([[(cfg.type in ['vmess', 'vless']) ? 'servername' : 'sni', cfg.tls_sni]]),
fingerprint: cfg.tls_fingerprint,
@@ -7,6 +7,7 @@ import { cursor } from 'uci';
import {
isEmpty, strToBool, strToInt, durationToSecond,
arrToObj, removeBlankAttrs,
parseListener,
HM_DIR, RUN_DIR, PRESET_OUTBOUND
} from 'fchomo';
@@ -33,23 +34,6 @@ function parse_users(cfg) {
return users;
}
function parse_vless_encryption(payload, side) {
if (isEmpty(payload))
return null;
let content = json(trim(payload));
let required = join('.', [
content.method,
content.xormode,
side === 'server' ? content.ticket : side === 'client' ? content.rtt : null
]);
return required +
(isEmpty(content.paddings) ? '' : '.' + join('.', content.paddings)) + // Optional
(isEmpty(content.keypairs) ? '' : '.' + join('.', map(content.keypairs, e => e[side]))); // Required
}
/* Config helper END */
/* Main */
@@ -61,131 +45,7 @@ uci.foreach(uciconf, uciserver, (cfg) => {
if (cfg.enabled === '0')
return;
push(config.listeners, {
name: cfg['.name'],
type: cfg.type,
listen: cfg.listen || '::',
port: cfg.port,
proxy: 'DIRECT',
/* HTTP / SOCKS / VMess / VLESS / Trojan / AnyTLS / Tuic / Hysteria2 */
users: (cfg.type in ['http', 'socks', 'mixed', 'vmess', 'vless', 'trojan']) ? [
{
/* HTTP / SOCKS */
username: cfg.username,
password: cfg.password,
/* VMess / VLESS */
uuid: cfg.vmess_uuid,
flow: cfg.vless_flow,
alterId: strToInt(cfg.vmess_alterid)
}
/*{
}*/
] : ((cfg.type in ['mieru', 'anytls', 'tuic', 'hysteria2']) ? {
/* Mieru / AnyTLS / Hysteria2 */
...arrToObj([[cfg.username, cfg.password]]),
/* Tuic */
...arrToObj([[cfg.uuid, cfg.password]])
} : null),
/* Hysteria2 */
up: strToInt(cfg.hysteria_up_mbps),
down: strToInt(cfg.hysteria_down_mbps),
"ignore-client-bandwidth": strToBool(cfg.hysteria_ignore_client_bandwidth),
obfs: cfg.hysteria_obfs_type,
"obfs-password": cfg.hysteria_obfs_password,
masquerade: cfg.hysteria_masquerade,
/* Shadowsocks */
cipher: cfg.shadowsocks_chipher,
password: cfg.shadowsocks_password,
/* Mieru */
transport: cfg.mieru_transport,
/* Sudoku */
key: cfg.sudoku_key,
"aead-method": replace(cfg.sudoku_aead_method || '', 'chacha20-ietf-poly1305', 'chacha20-poly1305') || null,
"padding-min": strToInt(cfg.sudoku_padding_min),
"padding-max": strToInt(cfg.sudoku_padding_max),
"table-type": cfg.sudoku_table_type,
"custom-tables": cfg.sudoku_custom_tables,
"handshake-timeout": strToInt(cfg.sudoku_handshake_timeout) ?? null,
"enable-pure-downlink": (cfg.sudoku_enable_pure_downlink === '0') ? false : null,
"disable-http-mask": (cfg.sudoku_http_mask === '0') ? true : null,
"http-mask-mode": cfg.sudoku_http_mask_mode,
"path-root": cfg.sudoku_path_root,
/* Tuic */
"congestion-controller": cfg.tuic_congestion_controller,
"max-idle-time": durationToSecond(cfg.tuic_max_idle_time),
"authentication-timeout": durationToSecond(cfg.tuic_authentication_timeout),
"max-udp-relay-packet-size": strToInt(cfg.tuic_max_udp_relay_packet_size),
/* Trojan */
"ss-option": cfg.trojan_ss_enabled === '1' ? {
enabled: true,
method: cfg.trojan_ss_chipher,
password: cfg.trojan_ss_password
} : null,
/* AnyTLS */
"padding-scheme": cfg.anytls_padding_scheme,
/* VMess / VLESS */
decryption: cfg.vless_decryption === '1' ? parse_vless_encryption(cfg.vless_encryption_hmpayload, 'server') : null,
/* Plugin fields */
...(cfg.plugin ? {
// shadow-tls
"shadow-tls": cfg.plugin === 'shadow-tls' ? {
enable: true,
version: strToInt(cfg.plugin_opts_shadowtls_version),
...(strToInt(cfg.plugin_opts_shadowtls_version) >= 3 ? {
users: [
{
name: 1,
password: cfg.plugin_opts_thetlspassword
}
],
} : { password: cfg.plugin_opts_thetlspassword }),
handshake: {
dest: cfg.plugin_opts_handshake_dest
},
} : null
} : {}),
/* Extra fields */
udp: strToBool(cfg.udp),
/* TLS fields */
...(cfg.tls === '1' ? {
alpn: cfg.tls_alpn,
...(cfg.tls_reality === '1' ? {
"reality-config": {
dest: cfg.tls_reality_dest,
"private-key": cfg.tls_reality_private_key,
"short-id": cfg.tls_reality_short_id,
"server-names": cfg.tls_reality_server_names
}
} : {
certificate: cfg.tls_cert_path,
"private-key": cfg.tls_key_path
}),
"client-auth-type": cfg.tls_client_auth_type,
"client-auth-cert": cfg.tls_client_auth_cert_path,
"ech-key": cfg.tls_ech_key,
} : {}),
/* Transport fields */
...(cfg.transport_enabled === '1' ? {
"grpc-service-name": cfg.transport_grpc_servicename,
"ws-path": cfg.transport_path
} : {})
});
push(config.listeners, parseListener(cfg, false));
});
/* Inbound END */
@@ -34,6 +34,14 @@
"path": "fchomo/hosts"
}
},
"admin/services/fchomo/inbound": {
"title": "Inbound",
"order": 27,
"action": {
"type": "view",
"path": "fchomo/inbound"
}
},
"admin/services/fchomo/node": {
"title": "Node",
"order": 30,
@@ -191,3 +191,163 @@ export function removeBlankAttrs(res) {
return content;
};
/* String helper end */
/* String universal start */
export function parseVlessEncryption(payload, side) {
if (isEmpty(payload))
return null;
let content = json(trim(payload));
let required = join('.', [
content.method,
content.xormode,
side === 'server' ? content.ticket : side === 'client' ? content.rtt : null
]);
return required +
(isEmpty(content.paddings) ? '' : '.' + join('.', content.paddings)) + // Optional
(isEmpty(content.keypairs) ? '' : '.' + join('.', map(content.keypairs, e => e[side]))); // Required
};
export function parseListener(cfg, isClient, label) {
return {
name: cfg['.name'],
type: cfg.type,
listen: cfg.listen || '::',
port: cfg.port,
...(isClient ? {
rule: cfg.rule,
proxy: label,
} : {}),
/* HTTP / SOCKS / VMess / VLESS / Trojan / AnyTLS / Tuic / Hysteria2 */
users: (cfg.type in ['http', 'socks', 'mixed', 'vmess', 'vless', 'trojan', 'trusttunnel']) ? [
{
/* HTTP / SOCKS */
username: cfg.username,
password: cfg.password,
/* VMess / VLESS */
uuid: cfg.vmess_uuid,
flow: cfg.vless_flow,
alterId: strToInt(cfg.vmess_alterid)
}
/*{
}*/
] : ((cfg.type in ['mieru', 'anytls', 'tuic', 'hysteria2']) ? {
/* Mieru / AnyTLS / Hysteria2 */
...arrToObj([[cfg.username, cfg.password]]),
/* Tuic */
...arrToObj([[cfg.uuid, cfg.password]])
} : null),
/* Hysteria2 */
up: strToInt(cfg.hysteria_up_mbps),
down: strToInt(cfg.hysteria_down_mbps),
"ignore-client-bandwidth": strToBool(cfg.hysteria_ignore_client_bandwidth),
obfs: cfg.hysteria_obfs_type,
"obfs-password": cfg.hysteria_obfs_password,
masquerade: cfg.hysteria_masquerade,
/* Shadowsocks */
cipher: cfg.shadowsocks_chipher,
password: cfg.shadowsocks_password,
/* Mieru */
transport: cfg.mieru_transport,
"traffic-pattern": cfg.mieru_traffic_pattern,
/* Sudoku */
key: cfg.sudoku_key,
"aead-method": replace(cfg.sudoku_aead_method || '', 'chacha20-ietf-poly1305', 'chacha20-poly1305') || null,
"padding-min": strToInt(cfg.sudoku_padding_min),
"padding-max": strToInt(cfg.sudoku_padding_max),
"table-type": cfg.sudoku_table_type,
"custom-tables": cfg.sudoku_custom_tables,
"handshake-timeout": strToInt(cfg.sudoku_handshake_timeout) ?? null,
"enable-pure-downlink": (cfg.sudoku_enable_pure_downlink === '0') ? false : null,
...(cfg.type === 'sudoku' ? {
httpmask: (cfg.sudoku_http_mask === '0') ? { disable: true } : {
disable: false,
mode: cfg.sudoku_http_mask_mode,
path_root: cfg.sudoku_path_root,
}
} : {}),
fallback: (cfg.sudoku_http_mask === '0') ? null : cfg.sudoku_fallback,
/* Tuic */
"max-idle-time": durationToSecond(cfg.tuic_max_idle_time),
"authentication-timeout": durationToSecond(cfg.tuic_authentication_timeout),
"max-udp-relay-packet-size": strToInt(cfg.tuic_max_udp_relay_packet_size),
/* Trojan */
"ss-option": cfg.trojan_ss_enabled === '1' ? {
enabled: true,
method: cfg.trojan_ss_chipher,
password: cfg.trojan_ss_password
} : null,
/* AnyTLS */
"padding-scheme": cfg.anytls_padding_scheme,
/* VMess / VLESS */
decryption: cfg.vless_decryption === '1' ? parseVlessEncryption(cfg.vless_encryption_hmpayload, 'server') : null,
/* Tunnel */
target: cfg.tunnel_target,
/* Plugin fields */
...(cfg.plugin ? {
// shadow-tls
"shadow-tls": cfg.plugin === 'shadow-tls' ? {
enable: true,
version: strToInt(cfg.plugin_opts_shadowtls_version),
...(strToInt(cfg.plugin_opts_shadowtls_version) >= 3 ? {
users: [
{
name: 1,
password: cfg.plugin_opts_thetlspassword
}
],
} : { password: cfg.plugin_opts_thetlspassword }),
handshake: {
dest: cfg.plugin_opts_handshake_dest
},
} : null
} : {}),
/* Extra fields */
"congestion-controller": cfg.congestion_controller,
network: cfg.network,
udp: strToBool(cfg.udp),
/* TLS fields */
...(cfg.tls === '1' ? {
alpn: cfg.tls_alpn,
...(cfg.tls_reality === '1' ? {
"reality-config": {
dest: cfg.tls_reality_dest,
"private-key": cfg.tls_reality_private_key,
"short-id": cfg.tls_reality_short_id,
"server-names": cfg.tls_reality_server_names
}
} : {
certificate: cfg.tls_cert_path,
"private-key": cfg.tls_key_path
}),
"client-auth-type": cfg.tls_client_auth_type,
"client-auth-cert": cfg.tls_client_auth_cert_path,
"ech-key": cfg.tls_ech_key,
} : {}),
/* Transport fields */
...(cfg.transport_enabled === '1' ? {
"grpc-service-name": cfg.transport_grpc_servicename,
"ws-path": cfg.transport_path
} : {})
}
};
/* String universal end */
@@ -1521,3 +1521,13 @@ function get_core(field, candidates)
end
return nil
end
function cleanEmptyTables(t)
if type(t) ~= "table" then return nil end
for k, v in pairs(t) do
if type(v) == "table" then
t[k] = cleanEmptyTables(v)
end
end
return next(t) and t or nil
end
@@ -241,31 +241,21 @@ function gen_outbound(flag, node, tag, proxy_table)
path = node.xhttp_path or "/",
host = node.xhttp_host,
extra = (function()
local extra_tbl = {}
-- 解析 xhttp_extra 并做简单容错处理
local extra = {}
if node.xhttp_extra then
local success, parsed = pcall(jsonc.parse, api.base64Decode(node.xhttp_extra))
if success and parsed then
extra_tbl = parsed.extra or parsed
for k, v in pairs(extra_tbl) do
if (type(v) == "table" and next(v) == nil) or v == nil then
extra_tbl[k] = nil
end
end
local ok, parsed = pcall(jsonc.parse, api.base64Decode(node.xhttp_extra))
if ok and type(parsed) == "table" then
extra = parsed.extra or parsed
end
end
-- 处理 User-Agent
if node.user_agent and node.user_agent ~= "" then
extra_tbl.headers = extra_tbl.headers or {}
if not extra_tbl.headers["User-Agent"] and not extra_tbl.headers["user-agent"] then
extra_tbl.headers["User-Agent"] = node.user_agent
extra.headers = extra.headers or {}
if not extra.headers["User-Agent"] and not extra.headers["user-agent"] then
extra.headers["User-Agent"] = node.user_agent
end
end
-- 清理空的 headers
if extra_tbl.headers and next(extra_tbl.headers) == nil then
extra_tbl.headers = nil
end
return next(extra_tbl) ~= nil and extra_tbl or nil
return api.cleanEmptyTables(extra)
end)()
} or nil,
hysteriaSettings = (node.transport == "hysteria") and {
@@ -322,25 +312,10 @@ function gen_outbound(flag, node, tag, proxy_table)
if node.finalmask and node.finalmask ~= "" then
local ok, fm = pcall(jsonc.parse, api.base64Decode(node.finalmask))
if ok and type(fm) == "table" then
if not finalmask or not next(finalmask) then
finalmask = fm
else
if type(fm.udp) == "table" then
finalmask.udp = finalmask.udp or {}
for i = 1, #fm.udp do
finalmask.udp[#finalmask.udp+1] = fm.udp[i]
end
end
if type(fm.tcp) == "table" then
finalmask.tcp = fm.tcp
end
if type(fm.quicParams) == "table" then
finalmask.quicParams = fm.quicParams
end
end
finalmask = fm
end
end
return (finalmask and next(finalmask)) and finalmask or nil
return api.cleanEmptyTables(finalmask)
end)()
} or nil,
settings = {
@@ -649,25 +624,10 @@ function gen_config_server(node)
if node.finalmask and node.finalmask ~= "" then
local ok, fm = pcall(jsonc.parse, api.base64Decode(node.finalmask))
if ok and type(fm) == "table" then
if not finalmask or not next(finalmask) then
finalmask = fm
else
if type(fm.udp) == "table" then
finalmask.udp = finalmask.udp or {}
for i = 1, #fm.udp do
finalmask.udp[#finalmask.udp+1] = fm.udp[i]
end
end
if type(fm.tcp) == "table" then
finalmask.tcp = fm.tcp
end
if type(fm.quicParams) == "table" then
finalmask.quicParams = fm.quicParams
end
end
finalmask = fm
end
end
return (finalmask and next(finalmask)) and finalmask or nil
return api.cleanEmptyTables(finalmask)
end)(),
sockopt = {
tcpFastOpen = (node.tcp_fast_open == "1") and true or nil,
+2 -2
View File
@@ -5,12 +5,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=mihomo
PKG_VERSION:=1.19.20
PKG_VERSION:=1.19.21
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/metacubex/mihomo/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=e2c6b8a3a86d979a826c8e6dd36cf04148773931b75bac72069717d5297b640d
PKG_HASH:=5ec56180650d95f8d4898af6c9c21afae8f8becfa1e613a8f14a8f3da45646c8
PKG_MAINTAINER:=Anya Lin <hukk1996@gmail.com>
PKG_LICENSE:=GPL-2.0
+3 -3
View File
@@ -81,10 +81,10 @@ type LoggerServiceServer interface {
type UnimplementedLoggerServiceServer struct{}
func (UnimplementedLoggerServiceServer) RestartLogger(context.Context, *RestartLoggerRequest) (*RestartLoggerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RestartLogger not implemented")
return nil, status.Error(codes.Unimplemented, "method RestartLogger not implemented")
}
func (UnimplementedLoggerServiceServer) FollowLog(*FollowLogRequest, grpc.ServerStreamingServer[FollowLogResponse]) error {
return status.Errorf(codes.Unimplemented, "method FollowLog not implemented")
return status.Error(codes.Unimplemented, "method FollowLog not implemented")
}
func (UnimplementedLoggerServiceServer) mustEmbedUnimplementedLoggerServiceServer() {}
func (UnimplementedLoggerServiceServer) testEmbeddedByValue() {}
@@ -97,7 +97,7 @@ type UnsafeLoggerServiceServer interface {
}
func RegisterLoggerServiceServer(s grpc.ServiceRegistrar, srv LoggerServiceServer) {
// If the following call pancis, it indicates UnimplementedLoggerServiceServer was
// If the following call panics, it indicates UnimplementedLoggerServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
@@ -0,0 +1,130 @@
package fusedPacketConn
import (
"errors"
"time"
"github.com/v2fly/v2ray-core/v5/common/net"
)
var errClosed = errors.New("fused packet conn is closed")
// FusedPacketConn combines two PacketConn socket to create a dual stack PacketConn
// When sending packet, the correct PacketConn for that destination address will be chosen
// When receiving packet, will receive packet from either socket
// Other operations will be done on both conn
type FusedPacketConn struct {
ipv6 net.PacketConn
ipv4 net.PacketConn
readCh chan readResult
done chan struct{}
localAddrPreferIPv6 bool
}
type readResult struct {
data []byte
addr net.Addr
err error
}
func NewFusedPacketConn(ipv4, ipv6 net.PacketConn, readBufSize int, localAddrPreferIPv6 bool) *FusedPacketConn {
f := &FusedPacketConn{
ipv4: ipv4,
ipv6: ipv6,
readCh: make(chan readResult, 2),
done: make(chan struct{}),
localAddrPreferIPv6: localAddrPreferIPv6,
}
go f.readLoop(ipv4, readBufSize)
go f.readLoop(ipv6, readBufSize)
return f
}
func (f *FusedPacketConn) readLoop(conn net.PacketConn, bufSize int) {
for {
buf := make([]byte, bufSize)
n, addr, err := conn.ReadFrom(buf)
select {
case <-f.done:
return
case f.readCh <- readResult{data: buf[:n], addr: addr, err: err}:
}
if err != nil {
return
}
}
}
func (f *FusedPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
select {
case <-f.done:
return 0, nil, errClosed
case r := <-f.readCh:
if r.err != nil {
return 0, r.addr, r.err
}
n := copy(p, r.data)
return n, r.addr, nil
}
}
func isIPv4Addr(addr net.Addr) bool {
udpAddr, ok := addr.(*net.UDPAddr)
if !ok {
return false
}
return udpAddr.IP.To4() != nil
}
func (f *FusedPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) {
if isIPv4Addr(addr) {
return f.ipv4.WriteTo(p, addr)
}
return f.ipv6.WriteTo(p, addr)
}
func (f *FusedPacketConn) Close() error {
close(f.done)
err4 := f.ipv4.Close()
err6 := f.ipv6.Close()
if err4 != nil {
return err4
}
return err6
}
func (f *FusedPacketConn) LocalAddr() net.Addr {
if f.localAddrPreferIPv6 {
return f.ipv6.LocalAddr()
}
return f.ipv4.LocalAddr()
}
func (f *FusedPacketConn) SetDeadline(t time.Time) error {
err4 := f.ipv4.SetDeadline(t)
err6 := f.ipv6.SetDeadline(t)
if err4 != nil {
return err4
}
return err6
}
func (f *FusedPacketConn) SetReadDeadline(t time.Time) error {
err4 := f.ipv4.SetReadDeadline(t)
err6 := f.ipv6.SetReadDeadline(t)
if err4 != nil {
return err4
}
return err6
}
func (f *FusedPacketConn) SetWriteDeadline(t time.Time) error {
err4 := f.ipv4.SetWriteDeadline(t)
err6 := f.ipv6.SetWriteDeadline(t)
if err4 != nil {
return err4
}
return err6
}
@@ -0,0 +1,29 @@
package stun
import (
"github.com/pion/stun/v3"
"github.com/v2fly/v2ray-core/v5/common/net"
)
type STUNMessageCallback func(b []byte, addr net.Addr)
func NewFilteredConnection(inner net.PacketConn, callback STUNMessageCallback) (*FilteredConnection, error) {
return &FilteredConnection{
PacketConn: inner,
stunMsgCallback: callback,
}, nil
}
type FilteredConnection struct {
net.PacketConn
stunMsgCallback STUNMessageCallback
}
func (f *FilteredConnection) ReadFrom(b []byte) (int, net.Addr, error) {
n, addr, err := f.PacketConn.ReadFrom(b)
if stun.IsMessage(b[:n]) {
f.stunMsgCallback(b[:n], addr)
}
return n, addr, err
}
@@ -0,0 +1,656 @@
package stun
// Mostly Machine Generated Code
import (
"context"
"encoding/binary"
"errors"
"net"
"sync"
"time"
"github.com/pion/stun/v3"
"github.com/v2fly/v2ray-core/v5/common/task"
)
type NATDependantType int
const (
Unknown NATDependantType = iota
Independent
EndpointDependent
EndpointPortDependent
EndpointPortDependentPinned
)
type NATYesOrNoUnknownType int
const (
NATYesOrNoUnknownType_Unknown NATYesOrNoUnknownType = iota
NATYesOrNoUnknownType_Yes
NATYesOrNoUnknownType_No
)
type NATTypeTest struct {
newStunConn func() (net.PacketConn, error)
testsTranscript []TestConducted
transcriptMux sync.Mutex
Timeout time.Duration
Attempts int
FilterBehaviour NATDependantType
MappingBehaviour NATDependantType
HairpinBehaviour NATYesOrNoUnknownType
StableMappingOnSecondaryServer NATYesOrNoUnknownType
// Calculated values from testsTranscript
PreserveSourcePortWhenSourceNATMapping NATYesOrNoUnknownType
SingleSourceIPSourceNATMapping NATYesOrNoUnknownType
// PreserveSourceIPPortWhenDestNATMapping
// means when receiving packets,
// whether the real source address is preserved in the reply message
// some time a bad proxy would fill in a default value rather the real remote address
// this can be detected when asking remote server to reply from a different ip or port
PreserveSourceIPPortWhenDestNATMapping NATYesOrNoUnknownType
TestServer net.Addr
TestServerSecondary net.Addr
SourceIPs []net.IP
}
func NewNATTypeTest(newStunConn func() (net.PacketConn, error), testServer net.Addr, testServerSecondary net.Addr, timeout time.Duration, attempts int) *NATTypeTest {
return &NATTypeTest{
newStunConn: newStunConn,
Timeout: timeout,
Attempts: attempts,
TestServer: testServer,
TestServerSecondary: testServerSecondary,
}
}
type TestConducted struct {
Req stun.Message
ReqSentTo net.Addr
ReqSentFrom net.Addr
Resp *stun.Message
RespFrom net.Addr
}
func changeRequestSetter(changeIP, changePort bool) stun.RawAttribute {
val := make([]byte, 4)
var flags uint32
if changeIP {
flags |= 0x04
}
if changePort {
flags |= 0x02
}
binary.BigEndian.PutUint32(val, flags)
return stun.RawAttribute{
Type: stun.AttrChangeRequest,
Value: val,
}
}
func startBackgroundReader(conn *StunClientConn) {
go func() {
buf := make([]byte, 1500)
for {
_, _, err := conn.ReadFrom(buf)
if err != nil {
return
}
}
}()
}
func (t *NATTypeTest) recordTransaction(tc TestConducted) {
t.transcriptMux.Lock()
defer t.transcriptMux.Unlock()
t.testsTranscript = append(t.testsTranscript, tc)
}
// doTransactionWithRetry sends multiple STUN requests at once (each with a fresh
// transaction ID) and waits for the first response within a single timeout window.
// This avoids sequential retry delays caused by UDP packet loss.
// Non-timeout errors from sending are returned immediately.
func (t *NATTypeTest) doTransactionWithRetry(conn *StunClientConn, localAddr net.Addr, dest net.Addr, attempts int, setters ...stun.Setter) (*stun.Message, net.Addr, error) { //nolint:unparam
type result struct {
msg stun.Message
addr net.Addr
}
ch := make(chan result, attempts)
var txIDs []stunTransactionID
var firstMsg *stun.Message
for i := 0; i < attempts; i++ {
msg := stun.MustBuild(setters...)
if i == 0 {
firstMsg = msg
}
_, _, err := conn.ExecuteSTUNMessageAsync(*msg, dest, func(_ [stun.TransactionIDSize]byte, respMsg stun.Message, respAddr net.Addr) {
ch <- result{msg: respMsg, addr: respAddr}
})
if err != nil {
for _, id := range txIDs {
conn.processor.CancelTransaction(id)
}
return nil, nil, err
}
txIDs = append(txIDs, msg.TransactionID)
}
// Wait for first response or timeout
var resp *result
select {
case r := <-ch:
resp = &r
case <-time.After(t.Timeout):
}
// Cancel all remaining pending transactions
for _, id := range txIDs {
conn.processor.CancelTransaction(id)
}
// Record result
if resp != nil {
respMsg := resp.msg
t.recordTransaction(TestConducted{
Req: *firstMsg,
ReqSentTo: dest,
ReqSentFrom: localAddr,
Resp: &respMsg,
RespFrom: resp.addr,
})
return &respMsg, resp.addr, nil
}
t.recordTransaction(TestConducted{
Req: *firstMsg,
ReqSentTo: dest,
ReqSentFrom: localAddr,
})
return nil, nil, ErrTimeout
}
// TestFilterBehaviour determines NAT filtering behavior per RFC 5780 Section 4.4.
func (t *NATTypeTest) TestFilterBehaviour() error {
rawConn, err := t.newStunConn()
if err != nil {
return err
}
conn, err := NewStunClientConn(rawConn)
if err != nil {
rawConn.Close()
return err
}
defer conn.Close()
localAddr := rawConn.LocalAddr()
startBackgroundReader(conn)
// Test I: Regular binding to confirm connectivity and get OTHER-ADDRESS
resp1, _, err := t.doTransactionWithRetry(conn, localAddr, t.TestServer, t.Attempts,
stun.TransactionID, stun.BindingRequest)
if err != nil {
return err
}
// Check if server supports RFC 5780 (OTHER-ADDRESS).
// Without it, CHANGE-REQUEST results are unreliable.
var filterOtherAddr stun.OtherAddress
if err := filterOtherAddr.GetFrom(resp1); err != nil {
t.FilterBehaviour = Unknown
return nil
}
// Test II: Request server to respond from different IP and port
_, _, err = t.doTransactionWithRetry(conn, localAddr, t.TestServer, t.Attempts,
stun.TransactionID, stun.BindingRequest, changeRequestSetter(true, true))
if err == nil {
t.FilterBehaviour = Independent
return nil
}
if !errors.Is(err, ErrTimeout) {
return err
}
// Test III: Request server to respond from different port only
_, _, err = t.doTransactionWithRetry(conn, localAddr, t.TestServer, t.Attempts,
stun.TransactionID, stun.BindingRequest, changeRequestSetter(false, true))
if err == nil {
t.FilterBehaviour = EndpointDependent
return nil
}
if !errors.Is(err, ErrTimeout) {
return err
}
// Test IV: Check if sending outbound UDP can open the filter for a new endpoint.
// Send a binding to the alternative address to create a NAT filter entry,
// then ask the original server to reply from that alternative address.
// If the response arrives, the filter can be opened by outbound packets.
altAddr := &net.UDPAddr{IP: filterOtherAddr.IP, Port: filterOtherAddr.Port}
// Send binding to alt address to open the NAT filter for that endpoint
_, _, err = t.doTransactionWithRetry(conn, localAddr, altAddr, t.Attempts,
stun.TransactionID, stun.BindingRequest)
if err != nil && !errors.Is(err, ErrTimeout) {
return err
}
// Now ask original server to reply from the alternative address
_, _, err = t.doTransactionWithRetry(conn, localAddr, t.TestServer, t.Attempts,
stun.TransactionID, stun.BindingRequest, changeRequestSetter(true, true))
if err == nil {
t.FilterBehaviour = EndpointPortDependent
return nil
}
if !errors.Is(err, ErrTimeout) {
return err
}
t.FilterBehaviour = EndpointPortDependentPinned
return nil
}
// TestMappingBehaviour determines NAT mapping behavior per RFC 5780 Section 4.3.
func (t *NATTypeTest) TestMappingBehaviour() error {
rawConn, err := t.newStunConn()
if err != nil {
return err
}
conn, err := NewStunClientConn(rawConn)
if err != nil {
rawConn.Close()
return err
}
defer conn.Close()
localAddr := rawConn.LocalAddr()
startBackgroundReader(conn)
// Test I: Regular binding to primary server
resp1, _, err := t.doTransactionWithRetry(conn, localAddr, t.TestServer, t.Attempts,
stun.TransactionID, stun.BindingRequest)
if err != nil {
return err
}
var mappedAddr1 stun.XORMappedAddress
if err := mappedAddr1.GetFrom(resp1); err != nil {
return err
}
var otherAddr stun.OtherAddress
if err := otherAddr.GetFrom(resp1); err != nil {
// Server does not support RFC 5780 (no OTHER-ADDRESS), cannot test mapping
t.MappingBehaviour = Unknown
return nil
}
// Test II: From same socket, binding to OTHER-ADDRESS (different IP and port)
altAddr := &net.UDPAddr{IP: otherAddr.IP, Port: otherAddr.Port}
resp2, _, err := t.doTransactionWithRetry(conn, localAddr, altAddr, t.Attempts,
stun.TransactionID, stun.BindingRequest)
if err != nil {
return err
}
var mappedAddr2 stun.XORMappedAddress
if err := mappedAddr2.GetFrom(resp2); err != nil {
return err
}
if mappedAddr1.String() == mappedAddr2.String() {
t.MappingBehaviour = Independent
return nil
}
// Test III: From same socket, binding to (other IP, original port)
serverUDP, ok := t.TestServer.(*net.UDPAddr)
if !ok {
return errors.New("TestServer is not a UDP address")
}
altAddr2 := &net.UDPAddr{IP: otherAddr.IP, Port: serverUDP.Port}
resp3, _, err := t.doTransactionWithRetry(conn, localAddr, altAddr2, t.Attempts,
stun.TransactionID, stun.BindingRequest)
if err != nil {
return err
}
var mappedAddr3 stun.XORMappedAddress
if err := mappedAddr3.GetFrom(resp3); err != nil {
return err
}
if mappedAddr2.String() == mappedAddr3.String() {
t.MappingBehaviour = EndpointDependent
} else {
t.MappingBehaviour = EndpointPortDependent
}
return nil
}
func (t *NATTypeTest) TestMappingBehaviourWithSecondaryServer() error {
if t.TestServerSecondary == nil {
t.StableMappingOnSecondaryServer = NATYesOrNoUnknownType_Unknown
return nil
}
rawConn, err := t.newStunConn()
if err != nil {
return err
}
conn, err := NewStunClientConn(rawConn)
if err != nil {
rawConn.Close()
return err
}
defer conn.Close()
localAddr := rawConn.LocalAddr()
startBackgroundReader(conn)
// Binding to primary server
resp1, _, err := t.doTransactionWithRetry(conn, localAddr, t.TestServer, t.Attempts,
stun.TransactionID, stun.BindingRequest)
if err != nil {
return err
}
var mappedAddr1 stun.XORMappedAddress
if err := mappedAddr1.GetFrom(resp1); err != nil {
return err
}
// Binding to secondary server from the same socket
resp2, _, err := t.doTransactionWithRetry(conn, localAddr, t.TestServerSecondary, t.Attempts,
stun.TransactionID, stun.BindingRequest)
if err != nil {
return err
}
var mappedAddr2 stun.XORMappedAddress
if err := mappedAddr2.GetFrom(resp2); err != nil {
return err
}
if mappedAddr1.String() == mappedAddr2.String() {
t.StableMappingOnSecondaryServer = NATYesOrNoUnknownType_Yes
} else {
t.StableMappingOnSecondaryServer = NATYesOrNoUnknownType_No
}
return nil
}
// TestHairpinBehaviour determines if the NAT supports hairpinning per RFC 5780 Section 4.5.
// Both sockets must first get their mapped addresses via STUN, then send to each other's
// mapped address. This ensures the NAT filter is opened for the peer's mapped address
// before the hairpin test packet arrives, avoiding false negatives from filtering.
func (t *NATTypeTest) TestHairpinBehaviour() error {
// Socket 1: get mapped address
rawConn1, err := t.newStunConn()
if err != nil {
return err
}
conn1, err := NewStunClientConn(rawConn1)
if err != nil {
rawConn1.Close()
return err
}
defer conn1.Close()
localAddr1 := rawConn1.LocalAddr()
startBackgroundReader(conn1)
resp1, _, err := t.doTransactionWithRetry(conn1, localAddr1, t.TestServer, t.Attempts,
stun.TransactionID, stun.BindingRequest)
if err != nil {
return err
}
var mappedAddr1 stun.XORMappedAddress
if err := mappedAddr1.GetFrom(resp1); err != nil {
return err
}
selfAddr1 := &net.UDPAddr{IP: mappedAddr1.IP, Port: mappedAddr1.Port}
// Socket 2: get mapped address
rawConn2, err := t.newStunConn()
if err != nil {
return err
}
conn2, err := NewStunClientConn(rawConn2)
if err != nil {
rawConn2.Close()
return err
}
defer conn2.Close()
localAddr2 := rawConn2.LocalAddr()
startBackgroundReader(conn2)
resp2, _, err := t.doTransactionWithRetry(conn2, localAddr2, t.TestServer, t.Attempts,
stun.TransactionID, stun.BindingRequest)
if err != nil {
return err
}
var mappedAddr2 stun.XORMappedAddress
if err := mappedAddr2.GetFrom(resp2); err != nil {
return err
}
selfAddr2 := &net.UDPAddr{IP: mappedAddr2.IP, Port: mappedAddr2.Port}
// Socket 1 sends to MA2 to open the NAT filter for MA2 on socket 1's side.
// Without this, a hairpinned packet from socket 2 (appearing as MA2) would be
// filtered by endpoint-dependent filtering on socket 1.
openMsg := stun.MustBuild(stun.TransactionID, stun.BindingRequest)
openMsg.Encode()
conn1.WriteTo(openMsg.Raw, selfAddr2)
// Build hairpin test messages: register on conn1's processor, send from conn2.
// Hairpinned packets arrive at conn1 from MA2 (now allowed by filter).
type result struct {
msg stun.Message
addr net.Addr
}
ch := make(chan result, t.Attempts)
var txIDs []stunTransactionID
var firstMsg *stun.Message
for i := 0; i < t.Attempts; i++ {
msg := stun.MustBuild(stun.TransactionID, stun.BindingRequest)
if i == 0 {
firstMsg = msg
}
msg.Encode()
// Register on conn1's processor (hairpinned packet arrives at conn1)
conn1.processor.AddPendingTransactionListener(msg.TransactionID, func(_ [stun.TransactionIDSize]byte, respMsg stun.Message, respAddr net.Addr) {
ch <- result{msg: respMsg, addr: respAddr}
})
txIDs = append(txIDs, msg.TransactionID)
// Send from conn2 to MA1 (socket 1's mapped address)
if _, err := conn2.WriteTo(msg.Raw, selfAddr1); err != nil {
for _, id := range txIDs {
conn1.processor.CancelTransaction(id)
}
return err
}
}
// Wait for hairpinned packet on conn1
var respResult *result
select {
case r := <-ch:
respResult = &r
case <-time.After(t.Timeout):
}
// Cancel all remaining pending transactions
for _, id := range txIDs {
conn1.processor.CancelTransaction(id)
}
t.HairpinBehaviour = NATYesOrNoUnknownType_No
if respResult != nil {
respMsg := respResult.msg
t.recordTransaction(TestConducted{
Req: *firstMsg,
ReqSentTo: selfAddr1,
ReqSentFrom: localAddr2,
Resp: &respMsg,
RespFrom: respResult.addr,
})
if respMsg.Type == stun.BindingRequest {
t.HairpinBehaviour = NATYesOrNoUnknownType_Yes
}
return nil
}
t.recordTransaction(TestConducted{
Req: *firstMsg,
ReqSentTo: selfAddr1,
ReqSentFrom: localAddr2,
})
return nil
}
// TestAll runs all NAT behavior tests in parallel, then calculates derived values.
func (t *NATTypeTest) TestAll() error {
err := task.Run(context.Background(),
t.TestFilterBehaviour,
t.TestMappingBehaviour,
t.TestHairpinBehaviour,
t.TestMappingBehaviourWithSecondaryServer,
)
if err != nil {
return err
}
return t.CalcReminderValues()
}
// CalcReminderValues derives additional NAT properties from the collected test transcripts.
func (t *NATTypeTest) CalcReminderValues() error {
t.transcriptMux.Lock()
transcripts := make([]TestConducted, len(t.testsTranscript))
copy(transcripts, t.testsTranscript)
t.transcriptMux.Unlock()
type addrKey struct {
ip string
port int
}
var mappedAddrs []addrKey
for _, tc := range transcripts {
if tc.Resp == nil {
continue
}
var addr stun.XORMappedAddress
if err := addr.GetFrom(tc.Resp); err != nil {
continue
}
mappedAddrs = append(mappedAddrs, addrKey{ip: addr.IP.String(), port: addr.Port})
}
// Collect unique mapped source IPs
seenIPs := make(map[string]struct{})
t.SourceIPs = nil
for _, m := range mappedAddrs {
if _, ok := seenIPs[m.ip]; !ok {
seenIPs[m.ip] = struct{}{}
t.SourceIPs = append(t.SourceIPs, net.ParseIP(m.ip))
}
}
// Need at least 2 mapped addresses to draw any meaningful comparison
if len(mappedAddrs) < 2 {
t.SingleSourceIPSourceNATMapping = NATYesOrNoUnknownType_Unknown
t.PreserveSourceIPPortWhenDestNATMapping = NATYesOrNoUnknownType_Unknown
t.PreserveSourcePortWhenSourceNATMapping = NATYesOrNoUnknownType_Unknown
return nil
}
// SingleSourceIPSourceNATMapping: check if all mapped IPs are the same
allSameIP := true
for _, m := range mappedAddrs[1:] {
if m.ip != mappedAddrs[0].ip {
allSameIP = false
break
}
}
if allSameIP {
t.SingleSourceIPSourceNATMapping = NATYesOrNoUnknownType_Yes
} else {
t.SingleSourceIPSourceNATMapping = NATYesOrNoUnknownType_No
}
allSendToMatchRespFrom := true
validPairCount := 0
for _, tc := range transcripts {
if tc.Resp == nil || tc.RespFrom == nil || tc.ReqSentTo == nil {
continue
}
if value, ok := tc.Req.Attributes.Get(stun.AttrChangeRequest); ok {
if len(value.Value) != 4 || (value.Value[0] == 0 && value.Value[1] == 0 && value.Value[2] == 0 && value.Value[3] == 0) {
continue
}
} else {
continue
}
validPairCount++
if tc.RespFrom.String() != tc.ReqSentTo.String() {
allSendToMatchRespFrom = false
break
}
}
switch {
case validPairCount < 1:
t.PreserveSourceIPPortWhenDestNATMapping = NATYesOrNoUnknownType_Unknown
case allSendToMatchRespFrom:
t.PreserveSourceIPPortWhenDestNATMapping = NATYesOrNoUnknownType_No
default:
t.PreserveSourceIPPortWhenDestNATMapping = NATYesOrNoUnknownType_Yes
}
// PreserveSourcePortWhenSourceNATMapping: check if mapped port matches local source port
preserves := true
validCount := 0
for _, tc := range transcripts {
if tc.Resp == nil || tc.ReqSentFrom == nil {
continue
}
localUDP, ok := tc.ReqSentFrom.(*net.UDPAddr)
if !ok || localUDP.Port == 0 {
continue
}
var addr stun.XORMappedAddress
if err := addr.GetFrom(tc.Resp); err != nil {
continue
}
validCount++
if addr.Port != localUDP.Port {
preserves = false
}
}
if validCount >= 2 {
if preserves {
t.PreserveSourcePortWhenSourceNATMapping = NATYesOrNoUnknownType_Yes
} else {
t.PreserveSourcePortWhenSourceNATMapping = NATYesOrNoUnknownType_No
}
} else {
t.PreserveSourcePortWhenSourceNATMapping = NATYesOrNoUnknownType_Unknown
}
return nil
}
@@ -0,0 +1,77 @@
package stun
import (
"sync"
"time"
"github.com/pion/stun/v3"
"github.com/v2fly/v2ray-core/v5/common/net"
)
type stunTransactionID = [stun.TransactionIDSize]byte
type pendingTransaction struct {
handler PendingTransactionHandler
createdAt time.Time
}
type Processor struct {
pendingStunRequest map[stunTransactionID]pendingTransaction
closed bool
mux sync.Mutex
}
func NewProcessor() *Processor {
return &Processor{
pendingStunRequest: make(map[stunTransactionID]pendingTransaction),
}
}
func (p *Processor) HandleStunPacket(b []byte, addr net.Addr) {
var msg stun.Message
if err := stun.Decode(b, &msg); err != nil {
return
}
p.mux.Lock()
pt, ok := p.pendingStunRequest[msg.TransactionID]
if ok {
delete(p.pendingStunRequest, msg.TransactionID)
}
p.mux.Unlock()
if ok {
pt.handler(msg.TransactionID, msg, addr)
}
}
type PendingTransactionHandler func(transactionID [stun.TransactionIDSize]byte, msg stun.Message, addr net.Addr)
func (p *Processor) AddPendingTransactionListener(transactionID [stun.TransactionIDSize]byte, handler PendingTransactionHandler) {
p.mux.Lock()
defer p.mux.Unlock()
p.pendingStunRequest[transactionID] = pendingTransaction{
handler: handler,
createdAt: time.Now(),
}
}
func (p *Processor) CancelTransaction(transactionID [stun.TransactionIDSize]byte) {
p.mux.Lock()
defer p.mux.Unlock()
delete(p.pendingStunRequest, transactionID)
}
func (p *Processor) ExpiredTransaction(newerThanThisTimeOrExpire time.Time) int {
p.mux.Lock()
defer p.mux.Unlock()
expired := 0
for id, pt := range p.pendingStunRequest {
if pt.createdAt.Before(newerThanThisTimeOrExpire) {
delete(p.pendingStunRequest, id)
expired++
}
}
return expired
}
@@ -0,0 +1,64 @@
package stun
import (
"errors"
"time"
"github.com/pion/stun/v3"
"github.com/v2fly/v2ray-core/v5/common/net"
)
var ErrTimeout = errors.New("STUN transaction timed out")
func NewStunClientConn(conn net.PacketConn) (*StunClientConn, error) {
processor := NewProcessor()
filtered, err := NewFilteredConnection(conn, processor.HandleStunPacket)
if err != nil {
return nil, err
}
return &StunClientConn{
PacketConn: filtered,
processor: processor,
}, nil
}
type StunClientConn struct {
net.PacketConn
processor *Processor
}
func (conn *StunClientConn) ExecuteSTUNMessage(msg stun.Message, dest net.Addr, timeout time.Duration) (resp stun.Message, addr net.Addr, err error) {
type result struct {
msg stun.Message
addr net.Addr
}
ch := make(chan result, 1)
_, _, err = conn.ExecuteSTUNMessageAsync(msg, dest, func(_ [stun.TransactionIDSize]byte, respMsg stun.Message, respAddr net.Addr) {
ch <- result{msg: respMsg, addr: respAddr}
})
if err != nil {
return resp, nil, err
}
select {
case r := <-ch:
return r.msg, r.addr, nil
case <-time.After(timeout):
conn.processor.CancelTransaction(msg.TransactionID)
return resp, nil, ErrTimeout
}
}
func (conn *StunClientConn) ExecuteSTUNMessageAsync(msg stun.Message, dest net.Addr, callback PendingTransactionHandler) (resp stun.Message, addr net.Addr, err error) {
msg.Encode()
conn.processor.AddPendingTransactionListener(msg.TransactionID, callback)
if _, err = conn.WriteTo(msg.Raw, dest); err != nil {
conn.processor.CancelTransaction(msg.TransactionID)
return resp, nil, err
}
return resp, nil, nil
}
@@ -0,0 +1,264 @@
package stuncli
// Mostly machine generated code
import (
"flag"
"fmt"
"net"
"time"
"github.com/v2fly/v2ray-core/v5/common/buf"
stunlib "github.com/v2fly/v2ray-core/v5/common/natTraversal/stun"
vnet "github.com/v2fly/v2ray-core/v5/common/net"
"github.com/v2fly/v2ray-core/v5/main/commands/all/engineering"
"github.com/v2fly/v2ray-core/v5/main/commands/base"
"github.com/v2fly/v2ray-core/v5/proxy/socks"
)
var (
server *string
server2 *string
timeout *int
attempts *int
socks5udp *string
)
var cmdStunTest = &base.Command{
UsageLine: "{{.Exec}} engineering stun-nat-type-discovery",
Short: "run STUN NAT type tests",
Long: `
Run STUN NAT behavior discovery tests (RFC 5780) against a STUN server.
Tests NAT filtering, mapping, and hairpin behavior, then reports results.
The STUN server must support RFC 5780 (OTHER-ADDRESS and CHANGE-REQUEST)
for full test coverage.
Usage:
{{.Exec}} engineering stun-test -server <host:port> [-server2 <host:port>] [-timeout <ms>] [-attempts <n>] [-socks5udp <host:port>]
Options:
-server <host:port>
The STUN server address (required)
-server2 <host:port>
A secondary STUN server address for cross-server mapping stability test
-timeout <ms>
Timeout per test in milliseconds (default: 3000)
-attempts <n>
Number of parallel requests per test for UDP loss resilience (default: 3)
-socks5udp <host:port>
SOCKS5 UDP relay address (skips TCP handshake, sends UDP directly)
Example:
{{.Exec}} engineering stun-test -server stun.example.com:3478
{{.Exec}} engineering stun-test -server stun.example.com:3478 -server2 stun2.example.com:3478
{{.Exec}} engineering stun-test -server stun.example.com:3478 -socks5udp 127.0.0.1:1080
`,
Flag: func() flag.FlagSet {
fs := flag.NewFlagSet("", flag.ExitOnError)
server = fs.String("server", "", "STUN server address (host:port)")
server2 = fs.String("server2", "", "secondary STUN server address (host:port)")
timeout = fs.Int("timeout", 3000, "timeout per test in milliseconds")
attempts = fs.Int("attempts", 3, "number of parallel requests per test")
socks5udp = fs.String("socks5udp", "", "SOCKS5 UDP relay address (host:port)")
return *fs
}(),
Run: executeStunTest,
}
func init() {
engineering.AddCommand(cmdStunTest)
}
// socks5UDPConn wraps a PacketConn to encapsulate/decapsulate SOCKS5 UDP packets.
// All outgoing packets are wrapped in a SOCKS5 UDP header and sent to the relay.
// All incoming packets are unwrapped, with the real source address extracted from the header.
type socks5UDPConn struct {
net.PacketConn
relayAddr net.Addr
}
func (c *socks5UDPConn) WriteTo(p []byte, addr net.Addr) (int, error) {
udpAddr := addr.(*net.UDPAddr)
dest := vnet.UDPDestination(vnet.IPAddress(udpAddr.IP), vnet.Port(udpAddr.Port))
packet, err := socks.EncodeUDPPacketFromAddress(dest, p)
if err != nil {
return 0, err
}
defer packet.Release()
_, err = c.PacketConn.WriteTo(packet.Bytes(), c.relayAddr)
if err != nil {
return 0, err
}
return len(p), nil
}
func (c *socks5UDPConn) ReadFrom(p []byte) (int, net.Addr, error) {
// Allocate enough space for SOCKS5 header + payload
rawBuf := make([]byte, len(p)+256)
n, _, err := c.PacketConn.ReadFrom(rawBuf)
if err != nil {
return 0, nil, err
}
packet := buf.FromBytes(rawBuf[:n])
req, err := socks.DecodeUDPPacket(packet)
if err != nil {
return 0, nil, err
}
// After DecodeUDPPacket, packet.Bytes() contains the payload
dataN := copy(p, packet.Bytes())
srcAddr := &net.UDPAddr{
IP: req.Address.IP(),
Port: int(req.Port),
}
return dataN, srcAddr, nil
}
func natDependantTypeString(t stunlib.NATDependantType) string {
switch t {
case stunlib.Unknown:
return "Unknown"
case stunlib.Independent:
return "Independent"
case stunlib.EndpointDependent:
return "Endpoint Dependent"
case stunlib.EndpointPortDependent:
return "Endpoint+Port Dependent"
case stunlib.EndpointPortDependentPinned:
return "Endpoint+Port Dependent (Pinned)"
default:
return fmt.Sprintf("Unknown(%d)", t)
}
}
func natYesOrNoString(t stunlib.NATYesOrNoUnknownType) string {
switch t {
case stunlib.NATYesOrNoUnknownType_Unknown:
return "Unknown"
case stunlib.NATYesOrNoUnknownType_Yes:
return "Yes"
case stunlib.NATYesOrNoUnknownType_No:
return "No"
default:
return fmt.Sprintf("Unknown(%d)", t)
}
}
func executeStunTest(cmd *base.Command, args []string) {
err := cmd.Flag.Parse(args)
if err != nil {
base.Fatalf("failed to parse flags: %v", err)
}
if *server == "" {
base.Fatalf("-server is required")
}
host, portStr, err := net.SplitHostPort(*server)
if err != nil {
base.Fatalf("invalid server address %q: %v", *server, err)
}
ips, err := net.ResolveIPAddr("ip", host)
if err != nil {
base.Fatalf("failed to resolve %q: %v", host, err)
}
port, err := net.LookupPort("udp", portStr)
if err != nil {
base.Fatalf("invalid port %q: %v", portStr, err)
}
serverAddr := &net.UDPAddr{IP: ips.IP, Port: port}
// Resolve secondary STUN server address if provided
var server2Addr *net.UDPAddr
if *server2 != "" {
host2, portStr2, err := net.SplitHostPort(*server2)
if err != nil {
base.Fatalf("invalid server2 address %q: %v", *server2, err)
}
ips2, err := net.ResolveIPAddr("ip", host2)
if err != nil {
base.Fatalf("failed to resolve server2 host %q: %v", host2, err)
}
port2, err := net.LookupPort("udp", portStr2)
if err != nil {
base.Fatalf("invalid server2 port %q: %v", portStr2, err)
}
server2Addr = &net.UDPAddr{IP: ips2.IP, Port: port2}
}
// Resolve SOCKS5 UDP relay address if provided
var relayAddr *net.UDPAddr
if *socks5udp != "" {
rHost, rPortStr, err := net.SplitHostPort(*socks5udp)
if err != nil {
base.Fatalf("invalid socks5udp address %q: %v", *socks5udp, err)
}
rIPs, err := net.ResolveIPAddr("ip", rHost)
if err != nil {
base.Fatalf("failed to resolve socks5udp host %q: %v", rHost, err)
}
rPort, err := net.LookupPort("udp", rPortStr)
if err != nil {
base.Fatalf("invalid socks5udp port %q: %v", rPortStr, err)
}
relayAddr = &net.UDPAddr{IP: rIPs.IP, Port: rPort}
}
fmt.Printf("STUN server: %s\n", serverAddr)
if server2Addr != nil {
fmt.Printf("STUN server 2: %s\n", server2Addr)
}
if relayAddr != nil {
fmt.Printf("SOCKS5 UDP relay: %s\n", relayAddr)
}
fmt.Printf("Timeout: %dms, Attempts: %d\n\n", *timeout, *attempts)
newConn := func() (net.PacketConn, error) {
conn, err := net.ListenPacket("udp", ":0")
if err != nil {
return nil, err
}
if relayAddr != nil {
return &socks5UDPConn{PacketConn: conn, relayAddr: relayAddr}, nil
}
return conn, nil
}
var secondaryServer net.Addr
if server2Addr != nil {
secondaryServer = server2Addr
}
test := stunlib.NewNATTypeTest(
newConn,
serverAddr,
secondaryServer,
time.Duration(*timeout)*time.Millisecond,
*attempts,
)
fmt.Println("Running tests...")
if err := test.TestAll(); err != nil {
base.Fatalf("test failed: %v", err)
}
fmt.Println()
fmt.Println("=== NAT Behavior Test Results ===")
fmt.Printf(" Filter Behaviour: %s\n", natDependantTypeString(test.FilterBehaviour))
fmt.Printf(" Mapping Behaviour: %s\n", natDependantTypeString(test.MappingBehaviour))
fmt.Printf(" Hairpin Behaviour: %s\n", natYesOrNoString(test.HairpinBehaviour))
fmt.Printf(" Stable Mapping on Secondary Server: %s\n", natYesOrNoString(test.StableMappingOnSecondaryServer))
fmt.Println()
fmt.Println("=== Derived Properties ===")
fmt.Printf(" Preserve Source Port (Source NAT): %s\n", natYesOrNoString(test.PreserveSourcePortWhenSourceNATMapping))
fmt.Printf(" Single Source IP (Source NAT): %s\n", natYesOrNoString(test.SingleSourceIPSourceNATMapping))
fmt.Printf(" Preserve Source Addr (Dest NAT Reply): %s\n", natYesOrNoString(test.PreserveSourceIPPortWhenDestNATMapping))
fmt.Println()
fmt.Println("=== Source IPs ===")
for _, ip := range test.SourceIPs {
fmt.Printf(" %s\n", ip)
}
}
@@ -28,6 +28,7 @@ type Config struct {
EnableSpoofing bool `protobuf:"varint,9,opt,name=enable_spoofing,json=enableSpoofing,proto3" json:"enable_spoofing,omitempty"`
SocketSettings *internet.SocketConfig `protobuf:"bytes,10,opt,name=socket_settings,json=socketSettings,proto3" json:"socket_settings,omitempty"`
PreferIpv6ForUdp bool `protobuf:"varint,11,opt,name=prefer_ipv6_for_udp,json=preferIpv6ForUdp,proto3" json:"prefer_ipv6_for_udp,omitempty"`
DualStackUdp bool `protobuf:"varint,12,opt,name=dual_stack_udp,json=dualStackUdp,proto3" json:"dual_stack_udp,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -118,11 +119,18 @@ func (x *Config) GetPreferIpv6ForUdp() bool {
return false
}
func (x *Config) GetDualStackUdp() bool {
if x != nil {
return x.DualStackUdp
}
return false
}
var File_common_packetswitch_gvisorstack_config_proto protoreflect.FileDescriptor
const file_common_packetswitch_gvisorstack_config_proto_rawDesc = "" +
"\n" +
",common/packetswitch/gvisorstack/config.proto\x12*v2ray.core.common.packetswitch.gvisorstack\x1a$app/router/routercommon/common.proto\x1a\x1ftransport/internet/config.proto\x1a common/protoext/extensions.proto\"\x9d\x03\n" +
",common/packetswitch/gvisorstack/config.proto\x12*v2ray.core.common.packetswitch.gvisorstack\x1a$app/router/routercommon/common.proto\x1a\x1ftransport/internet/config.proto\x1a common/protoext/extensions.proto\"\xc3\x03\n" +
"\x06Config\x12\x10\n" +
"\x03mtu\x18\x02 \x01(\rR\x03mtu\x12\x1d\n" +
"\n" +
@@ -133,7 +141,8 @@ const file_common_packetswitch_gvisorstack_config_proto_rawDesc = "" +
"\x0fenable_spoofing\x18\t \x01(\bR\x0eenableSpoofing\x12T\n" +
"\x0fsocket_settings\x18\n" +
" \x01(\v2+.v2ray.core.transport.internet.SocketConfigR\x0esocketSettings\x12-\n" +
"\x13prefer_ipv6_for_udp\x18\v \x01(\bR\x10preferIpv6ForUdpB\x9f\x01\n" +
"\x13prefer_ipv6_for_udp\x18\v \x01(\bR\x10preferIpv6ForUdp\x12$\n" +
"\x0edual_stack_udp\x18\f \x01(\bR\fdualStackUdpB\x9f\x01\n" +
".com.v2ray.core.common.packetswitch.gvisorstackP\x01Z>github.com/v2fly/v2ray-core/v5/common/packetswitch/gvisorstack\xaa\x02*V2Ray.Core.Common.Packetswitch.Gvisorstackb\x06proto3"
var (
@@ -19,4 +19,5 @@ message Config {
bool enable_spoofing = 9;
v2ray.core.transport.internet.SocketConfig socket_settings = 10;
bool prefer_ipv6_for_udp = 11;
bool dual_stack_udp = 12;
}
@@ -9,6 +9,7 @@ import (
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
"github.com/v2fly/v2ray-core/v5/common/dualStack/fusedPacketConn"
"github.com/v2fly/v2ray-core/v5/common/net"
)
@@ -116,6 +117,20 @@ func (w *WrappedStack) ListenUDP(ctx context.Context, localAddress net.Destinati
return udpConn, nil
}
if w.config.DualStackUdp {
udpConn4, err := gonet.DialUDP(w.stack, nil, nil, ipv4.ProtocolNumber)
if err != nil {
return nil, fmt.Errorf("failed to create IPv4 UDP conn for dual stack: %w", err)
}
udpConn6, err := gonet.DialUDP(w.stack, nil, nil, ipv6.ProtocolNumber)
if err != nil {
udpConn4.Close()
return nil, fmt.Errorf("failed to create IPv6 UDP conn for dual stack: %w", err)
}
preferIPv6 := w.config.GetPreferIpv6ForUdp()
return fusedPacketConn.NewFusedPacketConn(udpConn4, udpConn6, int(w.config.Mtu), preferIPv6), nil
}
// If not specified, let the stack choose the local address (pass nil laddr).
// Default network selection honors PreferIpv6ForUdp if configured.
defaultNet := ipv4.ProtocolNumber
+5 -1
View File
@@ -22,6 +22,7 @@ require (
github.com/mustafaturan/bus v1.0.2
github.com/pelletier/go-toml v1.9.5
github.com/pion/dtls/v2 v2.2.12
github.com/pion/stun/v3 v3.1.1
github.com/pion/transport/v2 v2.2.10
github.com/pires/go-proxyproto v0.11.0
github.com/quic-go/quic-go v0.59.0
@@ -73,16 +74,19 @@ require (
github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 // indirect
github.com/mustafaturan/monoton v1.0.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/dtls/v3 v3.0.10 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/sctp v1.8.7 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/rs/cors v1.7.0 // indirect
github.com/secure-io/siv-go v0.0.0-20180922214919-5ff40651e2c4 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/xtaci/smux v1.5.24 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/mod v0.32.0 // indirect
+10 -1
View File
@@ -387,13 +387,18 @@ github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi
github.com/pion/dtls/v2 v2.0.0-rc.7/go.mod h1:U199DvHpRBN0muE9+tVN4TMy1jvEhZIZ63lk4xkvVSk=
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/sctp v1.7.6/go.mod h1:ichkYQ5tlgCQwEwvgfdcAolqx1nHbYCxo4D7zK/K0X8=
github.com/pion/sctp v1.8.7 h1:JnABvFakZueGAn4KU/4PSKg+GWbF6QWbKTWZOSGJjXw=
github.com/pion/sctp v1.8.7/go.mod h1:g1Ul+ARqZq5JEmoFy87Q/4CePtKnTJ1QCL9dBBdN6AU=
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
github.com/pion/transport v0.8.10/go.mod h1:tBmha/UCjpum5hqTWhfAEs3CO4/tHSg0MYRhSzR+CZ8=
github.com/pion/transport/v2 v2.1.0/go.mod h1:AdSw4YBZVDkZm8fpoz+fclXyQwANWmZAlDuQdctTThQ=
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
@@ -401,6 +406,8 @@ github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQp
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -529,6 +536,8 @@ github.com/v2fly/struc v0.0.0-20241227015403-8e8fa1badfd6/go.mod h1:a/FYYQz8bW7w
github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xiaokangwang/VLite v0.0.0-20220418190619-cff95160a432 h1:I/ATawgO2RerCq9ACwL0wBB8xNXZdE3J+93MCEHReRs=
github.com/xiaokangwang/VLite v0.0.0-20220418190619-cff95160a432/go.mod h1:QN7Go2ftTVfx0aCTh9RXHV8pkpi0FtmbwQw40dy61wQ=
+5
View File
@@ -0,0 +1,5 @@
//go:build !android
package all
import _ "github.com/v2fly/v2ray-core/v5/common/natTraversal/stun/stuncli"

Some files were not shown because too many files have changed in this diff Show More