mirror of
https://github.com/bolucat/Archive.git
synced 2026-04-22 16:07:49 +08:00
Update On Tue Mar 10 20:02:08 CET 2026
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+13
-4
@@ -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>
|
||||
|
||||
+1
-1
@@ -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',
|
||||
)}
|
||||
>
|
||||
|
||||
+119
-79
@@ -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>
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ export function SettingsTitle({
|
||||
|
||||
const id = useId()
|
||||
|
||||
const showTopTitle = offset.top > 60
|
||||
const showTopTitle = offset.top > 40
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+994
-191
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+33
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user