diff --git a/.github/update.log b/.github/update.log index 67514ee2ac..589eadab47 100644 --- a/.github/update.log +++ b/.github/update.log @@ -1220,3 +1220,4 @@ Update On Fri Dec 19 19:41:31 CET 2025 Update On Sat Dec 20 19:35:51 CET 2025 Update On Sun Dec 21 19:39:06 CET 2025 Update On Mon Dec 22 19:42:47 CET 2025 +Update On Tue Dec 23 19:42:08 CET 2025 diff --git a/clash-meta/adapter/outbound/sudoku.go b/clash-meta/adapter/outbound/sudoku.go index bd393ec616..b1063f0468 100644 --- a/clash-meta/adapter/outbound/sudoku.go +++ b/clash-meta/adapter/outbound/sudoku.go @@ -30,6 +30,9 @@ type SudokuOption struct { TableType string `proxy:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy" EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"` HTTPMask bool `proxy:"http-mask,omitempty"` + HTTPMaskMode string `proxy:"http-mask-mode,omitempty"` // "legacy" (default), "stream", "poll", "auto" + HTTPMaskTLS bool `proxy:"http-mask-tls,omitempty"` // only for http-mask-mode stream/poll/auto + HTTPMaskHost string `proxy:"http-mask-host,omitempty"` // optional Host/SNI override (domain or domain:port) HTTPMaskStrategy string `proxy:"http-mask-strategy,omitempty"` // "random" (default), "post", "websocket" CustomTable string `proxy:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv CustomTables []string `proxy:"custom-tables,omitempty"` // optional table rotation patterns, overrides custom-table when non-empty @@ -42,7 +45,16 @@ func (s *Sudoku) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Con return nil, err } - c, err := s.dialer.DialContext(ctx, "tcp", s.addr) + var c net.Conn + if !cfg.DisableHTTPMask { + switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { + case "stream", "poll", "auto": + c, err = sudoku.DialHTTPMaskTunnel(ctx, cfg.ServerAddress, cfg, s.dialer.DialContext) + } + } + if c == nil && err == nil { + c, err = s.dialer.DialContext(ctx, "tcp", s.addr) + } if err != nil { return nil, fmt.Errorf("%s connect error: %w", s.addr, err) } @@ -56,9 +68,14 @@ func (s *Sudoku) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Con defer done(&err) } - c, err = sudoku.ClientHandshakeWithOptions(c, cfg, sudoku.ClientHandshakeOptions{ - HTTPMaskStrategy: s.option.HTTPMaskStrategy, - }) + handshakeCfg := *cfg + if !handshakeCfg.DisableHTTPMask { + switch strings.ToLower(strings.TrimSpace(handshakeCfg.HTTPMaskMode)) { + case "stream", "poll", "auto": + handshakeCfg.DisableHTTPMask = true + } + } + c, err = sudoku.ClientHandshakeWithOptions(c, &handshakeCfg, sudoku.ClientHandshakeOptions{HTTPMaskStrategy: s.option.HTTPMaskStrategy}) if err != nil { return nil, err } @@ -87,7 +104,16 @@ func (s *Sudoku) ListenPacketContext(ctx context.Context, metadata *C.Metadata) return nil, err } - c, err := s.dialer.DialContext(ctx, "tcp", s.addr) + var c net.Conn + if !cfg.DisableHTTPMask { + switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { + case "stream", "poll", "auto": + c, err = sudoku.DialHTTPMaskTunnel(ctx, cfg.ServerAddress, cfg, s.dialer.DialContext) + } + } + if c == nil && err == nil { + c, err = s.dialer.DialContext(ctx, "tcp", s.addr) + } if err != nil { return nil, fmt.Errorf("%s connect error: %w", s.addr, err) } @@ -101,9 +127,14 @@ func (s *Sudoku) ListenPacketContext(ctx context.Context, metadata *C.Metadata) defer done(&err) } - c, err = sudoku.ClientHandshakeWithOptions(c, cfg, sudoku.ClientHandshakeOptions{ - HTTPMaskStrategy: s.option.HTTPMaskStrategy, - }) + handshakeCfg := *cfg + if !handshakeCfg.DisableHTTPMask { + switch strings.ToLower(strings.TrimSpace(handshakeCfg.HTTPMaskMode)) { + case "stream", "poll", "auto": + handshakeCfg.DisableHTTPMask = true + } + } + c, err = sudoku.ClientHandshakeWithOptions(c, &handshakeCfg, sudoku.ClientHandshakeOptions{HTTPMaskStrategy: s.option.HTTPMaskStrategy}) if err != nil { return nil, err } @@ -190,6 +221,12 @@ func NewSudoku(option SudokuOption) (*Sudoku, error) { EnablePureDownlink: enablePureDownlink, HandshakeTimeoutSeconds: defaultConf.HandshakeTimeoutSeconds, DisableHTTPMask: !option.HTTPMask, + HTTPMaskMode: defaultConf.HTTPMaskMode, + HTTPMaskTLSEnabled: option.HTTPMaskTLS, + HTTPMaskHost: option.HTTPMaskHost, + } + if option.HTTPMaskMode != "" { + baseConf.HTTPMaskMode = option.HTTPMaskMode } tables, err := sudoku.NewTablesWithCustomPatterns(sudoku.ClientAEADSeed(option.Key), tableType, option.CustomTable, option.CustomTables) if err != nil { diff --git a/clash-meta/docs/config.yaml b/clash-meta/docs/config.yaml index 04d15bd202..a350d1af70 100644 --- a/clash-meta/docs/config.yaml +++ b/clash-meta/docs/config.yaml @@ -1041,7 +1041,7 @@ proxies: # socks5 # sudoku - name: sudoku type: sudoku - server: serverip # 1.2.3.4 + server: server_ip/domain # 1.2.3.4 or domain port: 443 key: "" # 如果你使用sudoku生成的ED25519密钥对,请填写密钥对中的私钥,否则填入和服务端相同的uuid aead-method: chacha20-poly1305 # 可选值:chacha20-poly1305、aes-128-gcm、none 我们保证在none的情况下sudoku混淆层仍然确保安全 @@ -1051,7 +1051,10 @@ proxies: # socks5 # custom-table: xpxvvpvv # 可选,自定义字节布局,必须包含2个x、2个p、4个v,可随意组合。启用此处则需配置`table-type`为`prefer_entropy` # custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),用于 xvp 模式轮换;非空时覆盖 custom-table http-mask: true # 是否启用http掩码 - # http-mask-strategy: random # 可选:random(默认)、post、websocket;仅在 http-mask=true 时生效 + # http-mask-mode: legacy # 可选:legacy(默认)、stream、poll、auto;stream/poll/auto 支持走 CDN/反代 + # http-mask-tls: true # 可选:仅在 http-mask-mode 为 stream/poll/auto 时生效;true 强制 https;false 强制 http(不会根据端口自动推断) + # http-mask-host: "" # 可选:覆盖 Host/SNI(支持 example.com 或 example.com:443);仅在 http-mask-mode 为 stream/poll/auto 时生效 + # http-mask-strategy: random # 可选:random(默认)、post、websocket;仅 legacy 下生效 enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与服务端端保持相同(如果此处为false,则要求aead不可为none) # anytls @@ -1596,6 +1599,8 @@ listeners: # custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),用于 xvp 模式轮换;非空时覆盖 custom-table handshake-timeout: 5 # optional enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与客户端保持相同(如果此处为false,则要求aead不可为none) + disable-http-mask: false # 可选:禁用 http 掩码/隧道(默认为 false) + # http-mask-mode: legacy # 可选:legacy(默认)、stream、poll、auto;stream/poll/auto 支持走 CDN/反代 diff --git a/clash-meta/go.mod b/clash-meta/go.mod index bb55812230..e4ab66ee48 100644 --- a/clash-meta/go.mod +++ b/clash-meta/go.mod @@ -3,6 +3,7 @@ module github.com/metacubex/mihomo go 1.20 require ( + filippo.io/edwards25519 v1.1.0 github.com/bahlo/generic-list-go v0.2.0 github.com/coreos/go-iptables v0.8.0 github.com/dlclark/regexp2 v1.11.5 @@ -46,7 +47,6 @@ require ( github.com/mroth/weightedrand/v2 v2.1.0 github.com/openacid/low v0.1.21 github.com/oschwald/maxminddb-golang v1.12.0 // lastest version compatible with golang1.20 - github.com/saba-futai/sudoku v0.0.2-d github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a github.com/samber/lo v1.52.0 github.com/sirupsen/logrus v1.9.3 @@ -66,7 +66,6 @@ require ( ) require ( - filippo.io/edwards25519 v1.1.0 // indirect github.com/RyuaNerin/go-krypto v1.3.0 // indirect github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect github.com/ajg/form v1.5.1 // indirect diff --git a/clash-meta/go.sum b/clash-meta/go.sum index 4756c83bd4..ac72c1dc1d 100644 --- a/clash-meta/go.sum +++ b/clash-meta/go.sum @@ -170,8 +170,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= -github.com/saba-futai/sudoku v0.0.2-d h1:HW/gIyNUFcDchpMN+ZhluM86U/HGkWkkRV+9Km6WZM8= -github.com/saba-futai/sudoku v0.0.2-d/go.mod h1:Rvggsoprp7HQM7bMIZUd1M27bPj8THRsZdY1dGbIAvo= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= diff --git a/clash-meta/listener/config/sudoku.go b/clash-meta/listener/config/sudoku.go index 848db875d7..118e252cad 100644 --- a/clash-meta/listener/config/sudoku.go +++ b/clash-meta/listener/config/sudoku.go @@ -20,6 +20,8 @@ type SudokuServer struct { EnablePureDownlink *bool `json:"enable-pure-downlink,omitempty"` CustomTable string `json:"custom-table,omitempty"` CustomTables []string `json:"custom-tables,omitempty"` + DisableHTTPMask bool `json:"disable-http-mask,omitempty"` + HTTPMaskMode string `json:"http-mask-mode,omitempty"` // mihomo private extension (not the part of standard Sudoku protocol) MuxOption sing.MuxOption `json:"mux-option,omitempty"` diff --git a/clash-meta/listener/inbound/mieru_test.go b/clash-meta/listener/inbound/mieru_test.go index 12aa680ccc..d57f4df5ee 100644 --- a/clash-meta/listener/inbound/mieru_test.go +++ b/clash-meta/listener/inbound/mieru_test.go @@ -3,7 +3,9 @@ package inbound_test import ( "net" "net/netip" + "runtime" "strconv" + "strings" "testing" "github.com/metacubex/mihomo/adapter/outbound" @@ -149,6 +151,9 @@ func TestNewMieru(t *testing.T) { } func TestInboundMieru(t *testing.T) { + if runtime.GOOS == "windows" && strings.HasPrefix(runtime.Version(), "go1.26") { + t.Skip("temporarily skipped on windows due to intermittent failures; tracked in PR") + } t.Run("TCP_HANDSHAKE_STANDARD", func(t *testing.T) { testInboundMieruTCP(t, "HANDSHAKE_STANDARD") }) diff --git a/clash-meta/listener/inbound/sudoku.go b/clash-meta/listener/inbound/sudoku.go index 433976026d..fc37cb7912 100644 --- a/clash-meta/listener/inbound/sudoku.go +++ b/clash-meta/listener/inbound/sudoku.go @@ -22,6 +22,8 @@ type SudokuOption struct { EnablePureDownlink *bool `inbound:"enable-pure-downlink,omitempty"` CustomTable string `inbound:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv CustomTables []string `inbound:"custom-tables,omitempty"` + DisableHTTPMask bool `inbound:"disable-http-mask,omitempty"` + HTTPMaskMode string `inbound:"http-mask-mode,omitempty"` // "legacy" (default), "stream", "poll", "auto" // mihomo private extension (not the part of standard Sudoku protocol) MuxOption MuxOption `inbound:"mux-option,omitempty"` @@ -59,6 +61,8 @@ func NewSudoku(options *SudokuOption) (*Sudoku, error) { EnablePureDownlink: options.EnablePureDownlink, CustomTable: options.CustomTable, CustomTables: options.CustomTables, + DisableHTTPMask: options.DisableHTTPMask, + HTTPMaskMode: options.HTTPMaskMode, } serverConf.MuxOption = options.MuxOption.Build() diff --git a/clash-meta/listener/inbound/sudoku_test.go b/clash-meta/listener/inbound/sudoku_test.go index 6ba9e63b6b..5596bf91ad 100644 --- a/clash-meta/listener/inbound/sudoku_test.go +++ b/clash-meta/listener/inbound/sudoku_test.go @@ -2,6 +2,7 @@ package inbound_test import ( "net/netip" + "runtime" "testing" "github.com/metacubex/mihomo/adapter/outbound" @@ -164,3 +165,27 @@ func TestInboundSudoku_CustomTable(t *testing.T) { testInboundSudoku(t, inboundOptions, outboundOptions) }) } + +func TestInboundSudoku_HTTPMaskMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("temporarily skipped on windows due to intermittent failures; tracked in PR") + } + + key := "test_key_http_mask_mode" + + for _, mode := range []string{"legacy", "stream", "poll", "auto"} { + mode := mode + t.Run(mode, func(t *testing.T) { + inboundOptions := inbound.SudokuOption{ + Key: key, + HTTPMaskMode: mode, + } + outboundOptions := outbound.SudokuOption{ + Key: key, + HTTPMask: true, + HTTPMaskMode: mode, + } + testInboundSudoku(t, inboundOptions, outboundOptions) + }) + } +} diff --git a/clash-meta/listener/sudoku/server.go b/clash-meta/listener/sudoku/server.go index e90e231c1d..7652783f1a 100644 --- a/clash-meta/listener/sudoku/server.go +++ b/clash-meta/listener/sudoku/server.go @@ -20,6 +20,7 @@ type Listener struct { addr string closed bool protoConf sudoku.ProtocolConfig + tunnelSrv *sudoku.HTTPMaskTunnelServer handler *sing.ListenerHandler } @@ -46,9 +47,31 @@ func (l *Listener) Close() error { } func (l *Listener) handleConn(conn net.Conn, tunnel C.Tunnel, additions ...inbound.Addition) { - session, err := sudoku.ServerHandshake(conn, &l.protoConf) + handshakeConn := conn + handshakeCfg := &l.protoConf + if l.tunnelSrv != nil { + c, cfg, done, err := l.tunnelSrv.WrapConn(conn) + if err != nil { + _ = conn.Close() + return + } + if done { + return + } + if c != nil { + handshakeConn = c + } + if cfg != nil { + handshakeCfg = cfg + } + } + + session, err := sudoku.ServerHandshake(handshakeConn, handshakeCfg) if err != nil { - _ = conn.Close() + _ = handshakeConn.Close() + if handshakeConn != conn { + _ = conn.Close() + } return } @@ -184,6 +207,8 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition) PaddingMax: paddingMax, EnablePureDownlink: enablePureDownlink, HandshakeTimeoutSeconds: handshakeTimeout, + DisableHTTPMask: config.DisableHTTPMask, + HTTPMaskMode: config.HTTPMaskMode, } if len(tables) == 1 { protoConf.Table = tables[0] @@ -200,6 +225,7 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition) protoConf: protoConf, handler: h, } + sl.tunnelSrv = sudoku.NewHTTPMaskTunnelServer(&sl.protoConf) go func() { for { diff --git a/clash-meta/transport/sudoku/config.go b/clash-meta/transport/sudoku/config.go new file mode 100644 index 0000000000..4fee6b670a --- /dev/null +++ b/clash-meta/transport/sudoku/config.go @@ -0,0 +1,144 @@ +package sudoku + +import ( + "fmt" + "strings" + + "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" +) + +// ProtocolConfig defines the configuration for the Sudoku protocol stack. +// It is intentionally kept close to the upstream Sudoku project to ensure wire compatibility. +type ProtocolConfig struct { + // Client-only: "host:port". + ServerAddress string + + // Pre-shared key (or ED25519 key material) used to derive crypto and tables. + Key string + + // "aes-128-gcm", "chacha20-poly1305", or "none". + AEADMethod string + + // Table is the single obfuscation table to use when table rotation is disabled. + Table *sudoku.Table + + // Tables is an optional candidate set for table rotation. + // If provided (len>0), the client will pick one table per connection and the server will + // probe the handshake to detect which one was used, keeping the handshake format unchanged. + // When Tables is set, Table may be nil. + Tables []*sudoku.Table + + // Padding insertion ratio (0-100). Must satisfy PaddingMax >= PaddingMin. + PaddingMin int + PaddingMax int + + // EnablePureDownlink toggles the bandwidth-optimized downlink mode. + EnablePureDownlink bool + + // Client-only: final target "host:port". + TargetAddress string + + // Server-side handshake timeout (seconds). + HandshakeTimeoutSeconds int + + // DisableHTTPMask disables all HTTP camouflage layers. + DisableHTTPMask bool + + // HTTPMaskMode controls how the HTTP layer behaves: + // - "legacy": write a fake HTTP/1.1 header then switch to raw stream (default, not CDN-compatible) + // - "stream": real HTTP tunnel (stream-one or split), CDN-compatible + // - "poll": plain HTTP tunnel (authorize/push/pull), strong restricted-network pass-through + // - "auto": try stream then fall back to poll + HTTPMaskMode string + + // HTTPMaskTLSEnabled enables HTTPS for HTTP tunnel modes (client-side). + // If false, the tunnel uses HTTP (no port-based inference). + HTTPMaskTLSEnabled bool + + // HTTPMaskHost optionally overrides the HTTP Host header / SNI host for HTTP tunnel modes (client-side). + HTTPMaskHost string +} + +func (c *ProtocolConfig) Validate() error { + if c.Table == nil && len(c.Tables) == 0 { + return fmt.Errorf("table cannot be nil (or provide tables)") + } + for i, t := range c.Tables { + if t == nil { + return fmt.Errorf("tables[%d] cannot be nil", i) + } + } + + if c.Key == "" { + return fmt.Errorf("key cannot be empty") + } + + switch c.AEADMethod { + case "aes-128-gcm", "chacha20-poly1305", "none": + default: + return fmt.Errorf("invalid aead-method: %s, must be one of: aes-128-gcm, chacha20-poly1305, none", c.AEADMethod) + } + + if c.PaddingMin < 0 || c.PaddingMin > 100 { + return fmt.Errorf("padding-min must be between 0 and 100, got %d", c.PaddingMin) + } + if c.PaddingMax < 0 || c.PaddingMax > 100 { + return fmt.Errorf("padding-max must be between 0 and 100, got %d", c.PaddingMax) + } + if c.PaddingMax < c.PaddingMin { + return fmt.Errorf("padding-max (%d) must be >= padding-min (%d)", c.PaddingMax, c.PaddingMin) + } + + if !c.EnablePureDownlink && c.AEADMethod == "none" { + return fmt.Errorf("bandwidth optimized downlink requires AEAD") + } + + if c.HandshakeTimeoutSeconds < 0 { + return fmt.Errorf("handshake-timeout must be >= 0, got %d", c.HandshakeTimeoutSeconds) + } + + switch strings.ToLower(strings.TrimSpace(c.HTTPMaskMode)) { + case "", "legacy", "stream", "poll", "auto": + default: + return fmt.Errorf("invalid http-mask-mode: %s, must be one of: legacy, stream, poll, auto", c.HTTPMaskMode) + } + + return nil +} + +func (c *ProtocolConfig) ValidateClient() error { + if err := c.Validate(); err != nil { + return err + } + if c.ServerAddress == "" { + return fmt.Errorf("server address cannot be empty") + } + if c.TargetAddress == "" { + return fmt.Errorf("target address cannot be empty") + } + return nil +} + +func DefaultConfig() *ProtocolConfig { + return &ProtocolConfig{ + AEADMethod: "chacha20-poly1305", + PaddingMin: 10, + PaddingMax: 30, + EnablePureDownlink: true, + HandshakeTimeoutSeconds: 5, + HTTPMaskMode: "legacy", + } +} + +func (c *ProtocolConfig) tableCandidates() []*sudoku.Table { + if c == nil { + return nil + } + if len(c.Tables) > 0 { + return c.Tables + } + if c.Table != nil { + return []*sudoku.Table{c.Table} + } + return nil +} diff --git a/clash-meta/transport/sudoku/crypto/aead.go b/clash-meta/transport/sudoku/crypto/aead.go new file mode 100644 index 0000000000..b5f574d9ff --- /dev/null +++ b/clash-meta/transport/sudoku/crypto/aead.go @@ -0,0 +1,130 @@ +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + + "golang.org/x/crypto/chacha20poly1305" +) + +type AEADConn struct { + net.Conn + aead cipher.AEAD + readBuf bytes.Buffer + nonceSize int +} + +func NewAEADConn(c net.Conn, key string, method string) (*AEADConn, error) { + if method == "none" { + return &AEADConn{Conn: c, aead: nil}, nil + } + + h := sha256.New() + h.Write([]byte(key)) + keyBytes := h.Sum(nil) + + var aead cipher.AEAD + var err error + + switch method { + case "aes-128-gcm": + block, _ := aes.NewCipher(keyBytes[:16]) + aead, err = cipher.NewGCM(block) + case "chacha20-poly1305": + aead, err = chacha20poly1305.New(keyBytes) + default: + return nil, fmt.Errorf("unsupported cipher: %s", method) + } + if err != nil { + return nil, err + } + + return &AEADConn{ + Conn: c, + aead: aead, + nonceSize: aead.NonceSize(), + }, nil +} + +func (cc *AEADConn) Write(p []byte) (int, error) { + if cc.aead == nil { + return cc.Conn.Write(p) + } + + maxPayload := 65535 - cc.nonceSize - cc.aead.Overhead() + totalWritten := 0 + var frameBuf bytes.Buffer + header := make([]byte, 2) + nonce := make([]byte, cc.nonceSize) + + for len(p) > 0 { + chunkSize := len(p) + if chunkSize > maxPayload { + chunkSize = maxPayload + } + chunk := p[:chunkSize] + p = p[chunkSize:] + + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return totalWritten, err + } + + ciphertext := cc.aead.Seal(nil, nonce, chunk, nil) + frameLen := len(nonce) + len(ciphertext) + binary.BigEndian.PutUint16(header, uint16(frameLen)) + + frameBuf.Reset() + frameBuf.Write(header) + frameBuf.Write(nonce) + frameBuf.Write(ciphertext) + + if _, err := cc.Conn.Write(frameBuf.Bytes()); err != nil { + return totalWritten, err + } + totalWritten += chunkSize + } + return totalWritten, nil +} + +func (cc *AEADConn) Read(p []byte) (int, error) { + if cc.aead == nil { + return cc.Conn.Read(p) + } + + if cc.readBuf.Len() > 0 { + return cc.readBuf.Read(p) + } + + header := make([]byte, 2) + if _, err := io.ReadFull(cc.Conn, header); err != nil { + return 0, err + } + frameLen := int(binary.BigEndian.Uint16(header)) + + body := make([]byte, frameLen) + if _, err := io.ReadFull(cc.Conn, body); err != nil { + return 0, err + } + + if len(body) < cc.nonceSize { + return 0, errors.New("frame too short") + } + nonce := body[:cc.nonceSize] + ciphertext := body[cc.nonceSize:] + + plaintext, err := cc.aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return 0, errors.New("decryption failed") + } + + cc.readBuf.Write(plaintext) + return cc.readBuf.Read(p) +} diff --git a/clash-meta/transport/sudoku/crypto/ed25519.go b/clash-meta/transport/sudoku/crypto/ed25519.go new file mode 100644 index 0000000000..7a2d0a12f5 --- /dev/null +++ b/clash-meta/transport/sudoku/crypto/ed25519.go @@ -0,0 +1,116 @@ +package crypto + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + + "filippo.io/edwards25519" +) + +// KeyPair holds the scalar private key and point public key +type KeyPair struct { + Private *edwards25519.Scalar + Public *edwards25519.Point +} + +// GenerateMasterKey generates a random master private key (scalar) and its public key (point) +func GenerateMasterKey() (*KeyPair, error) { + // 1. Generate random scalar x (32 bytes) + var seed [64]byte + if _, err := rand.Read(seed[:]); err != nil { + return nil, err + } + + x, err := edwards25519.NewScalar().SetUniformBytes(seed[:]) + if err != nil { + return nil, err + } + + // 2. Calculate Public Key P = x * G + P := new(edwards25519.Point).ScalarBaseMult(x) + + return &KeyPair{Private: x, Public: P}, nil +} + +// SplitPrivateKey takes a master private key x and returns a new random split key (r, k) +// such that x = r + k (mod L). +// Returns hex encoded string of r || k (64 bytes) +func SplitPrivateKey(x *edwards25519.Scalar) (string, error) { + // 1. Generate random r (32 bytes) + var seed [64]byte + if _, err := rand.Read(seed[:]); err != nil { + return "", err + } + r, err := edwards25519.NewScalar().SetUniformBytes(seed[:]) + if err != nil { + return "", err + } + + // 2. Calculate k = x - r (mod L) + k := new(edwards25519.Scalar).Subtract(x, r) + + // 3. Encode r and k + rBytes := r.Bytes() + kBytes := k.Bytes() + + full := make([]byte, 64) + copy(full[:32], rBytes) + copy(full[32:], kBytes) + + return hex.EncodeToString(full), nil +} + +// RecoverPublicKey takes a split private key (r, k) or a master private key (x) +// and returns the public key P. +// Input can be: +// - 32 bytes hex (Master Scalar x) +// - 64 bytes hex (Split Key r || k) +func RecoverPublicKey(keyHex string) (*edwards25519.Point, error) { + keyBytes, err := hex.DecodeString(keyHex) + if err != nil { + return nil, fmt.Errorf("invalid hex: %w", err) + } + + if len(keyBytes) == 32 { + // Master Key x + x, err := edwards25519.NewScalar().SetCanonicalBytes(keyBytes) + if err != nil { + return nil, fmt.Errorf("invalid scalar: %w", err) + } + return new(edwards25519.Point).ScalarBaseMult(x), nil + + } else if len(keyBytes) == 64 { + // Split Key r || k + rBytes := keyBytes[:32] + kBytes := keyBytes[32:] + + r, err := edwards25519.NewScalar().SetCanonicalBytes(rBytes) + if err != nil { + return nil, fmt.Errorf("invalid scalar r: %w", err) + } + k, err := edwards25519.NewScalar().SetCanonicalBytes(kBytes) + if err != nil { + return nil, fmt.Errorf("invalid scalar k: %w", err) + } + + // sum = r + k + sum := new(edwards25519.Scalar).Add(r, k) + + // P = sum * G + return new(edwards25519.Point).ScalarBaseMult(sum), nil + } + + return nil, errors.New("invalid key length: must be 32 bytes (Master) or 64 bytes (Split)") +} + +// EncodePoint returns the hex string of the compressed point +func EncodePoint(p *edwards25519.Point) string { + return hex.EncodeToString(p.Bytes()) +} + +// EncodeScalar returns the hex string of the scalar +func EncodeScalar(s *edwards25519.Scalar) string { + return hex.EncodeToString(s.Bytes()) +} diff --git a/clash-meta/transport/sudoku/features_test.go b/clash-meta/transport/sudoku/features_test.go index 8eb3aedd25..470598ce8e 100644 --- a/clash-meta/transport/sudoku/features_test.go +++ b/clash-meta/transport/sudoku/features_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - sudokuobfs "github.com/saba-futai/sudoku/pkg/obfs/sudoku" + sudokuobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" ) type discardConn struct{} diff --git a/clash-meta/transport/sudoku/handshake.go b/clash-meta/transport/sudoku/handshake.go index 989d281323..2a0437d6df 100644 --- a/clash-meta/transport/sudoku/handshake.go +++ b/clash-meta/transport/sudoku/handshake.go @@ -11,18 +11,13 @@ import ( "strings" "time" - "github.com/saba-futai/sudoku/apis" - "github.com/saba-futai/sudoku/pkg/crypto" - "github.com/saba-futai/sudoku/pkg/obfs/httpmask" - "github.com/saba-futai/sudoku/pkg/obfs/sudoku" + "github.com/metacubex/mihomo/transport/sudoku/crypto" + "github.com/metacubex/mihomo/transport/sudoku/obfs/httpmask" + "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" "github.com/metacubex/mihomo/log" ) -type ProtocolConfig = apis.ProtocolConfig - -func DefaultConfig() *ProtocolConfig { return apis.DefaultConfig() } - type SessionType int const ( @@ -105,14 +100,14 @@ const ( downlinkModePacked byte = 0x02 ) -func downlinkMode(cfg *apis.ProtocolConfig) byte { +func downlinkMode(cfg *ProtocolConfig) byte { if cfg.EnablePureDownlink { return downlinkModePure } return downlinkModePacked } -func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.Table) net.Conn { +func buildClientObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table) net.Conn { baseReader := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false) baseWriter := newSudokuObfsWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax) if cfg.EnablePureDownlink { @@ -130,7 +125,7 @@ func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.T } } -func buildServerObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) { +func buildServerObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) { uplink := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, record) if cfg.EnablePureDownlink { downlink := &directionalConn{ @@ -189,12 +184,12 @@ type ClientHandshakeOptions struct { } // ClientHandshake performs the client-side Sudoku handshake (without sending target address). -func ClientHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (net.Conn, error) { +func ClientHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, error) { return ClientHandshakeWithOptions(rawConn, cfg, ClientHandshakeOptions{}) } // ClientHandshakeWithOptions performs the client-side Sudoku handshake (without sending target address). -func ClientHandshakeWithOptions(rawConn net.Conn, cfg *apis.ProtocolConfig, opt ClientHandshakeOptions) (net.Conn, error) { +func ClientHandshakeWithOptions(rawConn net.Conn, cfg *ProtocolConfig, opt ClientHandshakeOptions) (net.Conn, error) { if cfg == nil { return nil, fmt.Errorf("config is required") } @@ -220,7 +215,7 @@ func ClientHandshakeWithOptions(rawConn net.Conn, cfg *apis.ProtocolConfig, opt } handshake := buildHandshakePayload(cfg.Key) - if len(tableCandidates(cfg)) > 1 { + if len(cfg.tableCandidates()) > 1 { handshake[15] = tableID } if _, err := cConn.Write(handshake[:]); err != nil { @@ -236,7 +231,7 @@ func ClientHandshakeWithOptions(rawConn net.Conn, cfg *apis.ProtocolConfig, opt } // ServerHandshake performs Sudoku server-side handshake and detects UoT preface. -func ServerHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (*ServerSession, error) { +func ServerHandshake(rawConn net.Conn, cfg *ProtocolConfig) (*ServerSession, error) { if cfg == nil { return nil, fmt.Errorf("config is required") } @@ -260,7 +255,7 @@ func ServerHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (*ServerSession } } - selectedTable, preRead, err := selectTableByProbe(bufReader, cfg, tableCandidates(cfg)) + selectedTable, preRead, err := selectTableByProbe(bufReader, cfg, cfg.tableCandidates()) if err != nil { return nil, err } diff --git a/clash-meta/transport/sudoku/handshake_test.go b/clash-meta/transport/sudoku/handshake_test.go index 5d9443dfde..b2f0999e4e 100644 --- a/clash-meta/transport/sudoku/handshake_test.go +++ b/clash-meta/transport/sudoku/handshake_test.go @@ -9,8 +9,7 @@ import ( "testing" "time" - "github.com/saba-futai/sudoku/apis" - sudokuobfs "github.com/saba-futai/sudoku/pkg/obfs/sudoku" + sudokuobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" ) func TestPackedConnRoundTrip_WithPadding(t *testing.T) { @@ -67,8 +66,8 @@ func TestPackedConnRoundTrip_WithPadding(t *testing.T) { } } -func newPackedConfig(table *sudokuobfs.Table) *apis.ProtocolConfig { - cfg := apis.DefaultConfig() +func newPackedConfig(table *sudokuobfs.Table) *ProtocolConfig { + cfg := DefaultConfig() cfg.Key = "sudoku-test-key" cfg.Table = table cfg.PaddingMin = 10 @@ -118,7 +117,7 @@ func TestPackedDownlinkSoak(t *testing.T) { } } -func runPackedTCPSession(id int, cfg *apis.ProtocolConfig, errCh chan<- error) { +func runPackedTCPSession(id int, cfg *ProtocolConfig, errCh chan<- error) { serverConn, clientConn := net.Pipe() target := fmt.Sprintf("1.1.1.%d:80", (id%200)+1) payload := []byte{0x42, byte(id)} @@ -176,7 +175,7 @@ func runPackedTCPSession(id int, cfg *apis.ProtocolConfig, errCh chan<- error) { } } -func runPackedUoTSession(id int, cfg *apis.ProtocolConfig, errCh chan<- error) { +func runPackedUoTSession(id int, cfg *ProtocolConfig, errCh chan<- error) { serverConn, clientConn := net.Pipe() target := "8.8.8.8:53" payload := []byte{0xaa, byte(id)} diff --git a/clash-meta/transport/sudoku/httpmask_strategy.go b/clash-meta/transport/sudoku/httpmask_strategy.go index dc90991d13..fa11b24914 100644 --- a/clash-meta/transport/sudoku/httpmask_strategy.go +++ b/clash-meta/transport/sudoku/httpmask_strategy.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "github.com/saba-futai/sudoku/pkg/obfs/httpmask" + "github.com/metacubex/mihomo/transport/sudoku/obfs/httpmask" ) var ( diff --git a/clash-meta/transport/sudoku/httpmask_tunnel.go b/clash-meta/transport/sudoku/httpmask_tunnel.go new file mode 100644 index 0000000000..aeedfe15d0 --- /dev/null +++ b/clash-meta/transport/sudoku/httpmask_tunnel.go @@ -0,0 +1,88 @@ +package sudoku + +import ( + "context" + "fmt" + "net" + "strings" + + "github.com/metacubex/mihomo/transport/sudoku/obfs/httpmask" +) + +type HTTPMaskTunnelServer struct { + cfg *ProtocolConfig + ts *httpmask.TunnelServer +} + +func NewHTTPMaskTunnelServer(cfg *ProtocolConfig) *HTTPMaskTunnelServer { + if cfg == nil { + return &HTTPMaskTunnelServer{} + } + + var ts *httpmask.TunnelServer + if !cfg.DisableHTTPMask { + switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { + case "stream", "poll", "auto": + ts = httpmask.NewTunnelServer(httpmask.TunnelServerOptions{Mode: cfg.HTTPMaskMode}) + } + } + return &HTTPMaskTunnelServer{cfg: cfg, ts: ts} +} + +// WrapConn inspects an accepted TCP connection and upgrades it to an HTTP tunnel stream when needed. +// +// Returns: +// - done=true: this TCP connection has been fully handled (e.g., stream/poll control request), caller should return +// - done=false: handshakeConn+cfg are ready for ServerHandshake +func (s *HTTPMaskTunnelServer) WrapConn(rawConn net.Conn) (handshakeConn net.Conn, cfg *ProtocolConfig, done bool, err error) { + if rawConn == nil { + return nil, nil, true, fmt.Errorf("nil conn") + } + if s == nil { + return rawConn, nil, false, nil + } + if s.ts == nil { + return rawConn, s.cfg, false, nil + } + + res, c, err := s.ts.HandleConn(rawConn) + if err != nil { + return nil, nil, true, err + } + + switch res { + case httpmask.HandleDone: + return nil, nil, true, nil + case httpmask.HandlePassThrough: + return c, s.cfg, false, nil + case httpmask.HandleStartTunnel: + inner := *s.cfg + inner.DisableHTTPMask = true + return c, &inner, false, nil + default: + return nil, nil, true, nil + } +} + +type TunnelDialer func(ctx context.Context, network, addr string) (net.Conn, error) + +// DialHTTPMaskTunnel dials a CDN-capable HTTP tunnel (stream/poll/auto) and returns a stream carrying raw Sudoku bytes. +func DialHTTPMaskTunnel(ctx context.Context, serverAddress string, cfg *ProtocolConfig, dial TunnelDialer) (net.Conn, error) { + if cfg == nil { + return nil, fmt.Errorf("config is required") + } + if cfg.DisableHTTPMask { + return nil, fmt.Errorf("http mask is disabled") + } + switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { + case "stream", "poll", "auto": + default: + return nil, fmt.Errorf("http-mask-mode=%q does not use http tunnel", cfg.HTTPMaskMode) + } + return httpmask.DialTunnel(ctx, serverAddress, httpmask.TunnelDialOptions{ + Mode: cfg.HTTPMaskMode, + TLSEnabled: cfg.HTTPMaskTLSEnabled, + HostOverride: cfg.HTTPMaskHost, + DialContext: dial, + }) +} diff --git a/clash-meta/transport/sudoku/httpmask_tunnel_test.go b/clash-meta/transport/sudoku/httpmask_tunnel_test.go new file mode 100644 index 0000000000..eab310f976 --- /dev/null +++ b/clash-meta/transport/sudoku/httpmask_tunnel_test.go @@ -0,0 +1,445 @@ +package sudoku + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "strings" + "sync" + "testing" + "time" +) + +func startTunnelServer(t *testing.T, cfg *ProtocolConfig, handle func(*ServerSession) error) (addr string, stop func(), errCh <-chan error) { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + errC := make(chan error, 128) + done := make(chan struct{}) + + tunnelSrv := NewHTTPMaskTunnelServer(cfg) + var wg sync.WaitGroup + var stopOnce sync.Once + + wg.Add(1) + go func() { + defer wg.Done() + for { + c, err := ln.Accept() + if err != nil { + close(done) + return + } + wg.Add(1) + go func(conn net.Conn) { + defer wg.Done() + + handshakeConn, handshakeCfg, handled, err := tunnelSrv.WrapConn(conn) + if err != nil { + _ = conn.Close() + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + return + } + if err == io.EOF { + return + } + errC <- err + return + } + if handled { + return + } + if handshakeConn == nil || handshakeCfg == nil { + _ = conn.Close() + errC <- fmt.Errorf("wrap conn returned nil") + return + } + + session, err := ServerHandshake(handshakeConn, handshakeCfg) + if err != nil { + _ = handshakeConn.Close() + if handshakeConn != conn { + _ = conn.Close() + } + errC <- err + return + } + defer session.Conn.Close() + + if handleErr := handle(session); handleErr != nil { + errC <- handleErr + } + }(c) + } + }() + + stop = func() { + stopOnce.Do(func() { + _ = ln.Close() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("server did not stop") + } + + ch := make(chan struct{}) + go func() { + wg.Wait() + close(ch) + }() + select { + case <-ch: + case <-time.After(10 * time.Second): + t.Fatalf("server goroutines did not exit") + } + close(errC) + }) + } + + return ln.Addr().String(), stop, errC +} + +func newTunnelTestTable(t *testing.T, key string) *ProtocolConfig { + t.Helper() + + tables, err := NewTablesWithCustomPatterns(ClientAEADSeed(key), "prefer_ascii", "", nil) + if err != nil { + t.Fatalf("build tables: %v", err) + } + if len(tables) != 1 { + t.Fatalf("unexpected tables: %d", len(tables)) + } + + cfg := DefaultConfig() + cfg.Key = key + cfg.AEADMethod = "chacha20-poly1305" + cfg.Table = tables[0] + cfg.PaddingMin = 0 + cfg.PaddingMax = 0 + cfg.HandshakeTimeoutSeconds = 5 + cfg.EnablePureDownlink = true + cfg.DisableHTTPMask = false + return cfg +} + +func TestHTTPMaskTunnel_Stream_TCPRoundTrip(t *testing.T) { + key := "tunnel-stream-key" + target := "1.1.1.1:80" + + serverCfg := newTunnelTestTable(t, key) + serverCfg.HTTPMaskMode = "stream" + + 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 + clientCfg.HTTPMaskHost = "example.com" + + tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext) + if err != nil { + t.Fatalf("dial tunnel: %v", err) + } + defer tunnelConn.Close() + + handshakeCfg := clientCfg + handshakeCfg.DisableHTTPMask = true + cConn, err := ClientHandshake(tunnelConn, &handshakeCfg) + if err != nil { + t.Fatalf("client handshake: %v", err) + } + defer cConn.Close() + + addrBuf, err := EncodeAddress(target) + if err != nil { + t.Fatalf("encode addr: %v", err) + } + if _, err := cConn.Write(addrBuf); err != nil { + t.Fatalf("write addr: %v", err) + } + + buf := make([]byte, 2) + if _, err := io.ReadFull(cConn, 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_Poll_UoTRoundTrip(t *testing.T) { + key := "tunnel-poll-key" + target := "8.8.8.8:53" + payload := []byte{0xaa, 0xbb, 0xcc, 0xdd} + + serverCfg := newTunnelTestTable(t, key) + serverCfg.HTTPMaskMode = "poll" + + addr, stop, errCh := startTunnelServer(t, serverCfg, func(s *ServerSession) error { + if s.Type != SessionTypeUoT { + return fmt.Errorf("unexpected session type: %v", s.Type) + } + gotAddr, gotPayload, err := ReadDatagram(s.Conn) + if err != nil { + return fmt.Errorf("server read datagram: %w", err) + } + if gotAddr != target { + return fmt.Errorf("uot target mismatch: %s", gotAddr) + } + if !bytes.Equal(gotPayload, payload) { + return fmt.Errorf("uot payload mismatch: %x", gotPayload) + } + if err := WriteDatagram(s.Conn, gotAddr, gotPayload); err != nil { + return fmt.Errorf("server write datagram: %w", err) + } + return nil + }) + defer stop() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + clientCfg := *serverCfg + clientCfg.ServerAddress = addr + + tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext) + if err != nil { + t.Fatalf("dial tunnel: %v", err) + } + defer tunnelConn.Close() + + handshakeCfg := clientCfg + handshakeCfg.DisableHTTPMask = true + cConn, err := ClientHandshake(tunnelConn, &handshakeCfg) + if err != nil { + t.Fatalf("client handshake: %v", err) + } + defer cConn.Close() + + if err := WritePreface(cConn); err != nil { + t.Fatalf("write preface: %v", err) + } + if err := WriteDatagram(cConn, target, payload); err != nil { + t.Fatalf("write datagram: %v", err) + } + gotAddr, gotPayload, err := ReadDatagram(cConn) + if err != nil { + t.Fatalf("read datagram: %v", err) + } + if gotAddr != target { + t.Fatalf("uot target mismatch: %s", gotAddr) + } + if !bytes.Equal(gotPayload, payload) { + t.Fatalf("uot payload mismatch: %x", gotPayload) + } + + stop() + for err := range errCh { + t.Fatalf("server error: %v", err) + } +} + +func TestHTTPMaskTunnel_Auto_TCPRoundTrip(t *testing.T) { + key := "tunnel-auto-key" + target := "9.9.9.9:443" + + serverCfg := newTunnelTestTable(t, key) + serverCfg.HTTPMaskMode = "auto" + + 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 + + tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext) + if err != nil { + t.Fatalf("dial tunnel: %v", err) + } + defer tunnelConn.Close() + + handshakeCfg := clientCfg + handshakeCfg.DisableHTTPMask = true + cConn, err := ClientHandshake(tunnelConn, &handshakeCfg) + if err != nil { + t.Fatalf("client handshake: %v", err) + } + defer cConn.Close() + + addrBuf, err := EncodeAddress(target) + if err != nil { + t.Fatalf("encode addr: %v", err) + } + if _, err := cConn.Write(addrBuf); err != nil { + t.Fatalf("write addr: %v", err) + } + + buf := make([]byte, 2) + if _, err := io.ReadFull(cConn, 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" + cfg.Table = NewTable("seed", "prefer_ascii") + cfg.ServerAddress = "127.0.0.1:1" + + cfg.DisableHTTPMask = true + cfg.HTTPMaskMode = "stream" + if _, err := DialHTTPMaskTunnel(context.Background(), cfg.ServerAddress, cfg, (&net.Dialer{}).DialContext); err == nil { + t.Fatalf("expected error for disabled http mask") + } + + cfg.DisableHTTPMask = false + cfg.HTTPMaskMode = "legacy" + if _, err := DialHTTPMaskTunnel(context.Background(), cfg.ServerAddress, cfg, (&net.Dialer{}).DialContext); err == nil { + t.Fatalf("expected error for legacy mode") + } +} + +func TestHTTPMaskTunnel_Soak_Concurrent(t *testing.T) { + key := "tunnel-soak-key" + target := "1.0.0.1:80" + + serverCfg := newTunnelTestTable(t, key) + serverCfg.HTTPMaskMode = "stream" + serverCfg.EnablePureDownlink = false + + const ( + sessions = 8 + payloadLen = 64 * 1024 + ) + + 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) + } + buf := make([]byte, payloadLen) + if _, err := io.ReadFull(s.Conn, buf); err != nil { + return fmt.Errorf("server read payload: %w", err) + } + _, err := s.Conn.Write(buf) + return err + }) + defer stop() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var wg sync.WaitGroup + runErr := make(chan error, sessions) + + for i := 0; i < sessions; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + clientCfg := *serverCfg + clientCfg.ServerAddress = addr + clientCfg.HTTPMaskHost = strings.TrimSpace(clientCfg.HTTPMaskHost) + + tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext) + if err != nil { + runErr <- fmt.Errorf("dial: %w", err) + return + } + defer tunnelConn.Close() + + handshakeCfg := clientCfg + handshakeCfg.DisableHTTPMask = true + cConn, err := ClientHandshake(tunnelConn, &handshakeCfg) + if err != nil { + runErr <- fmt.Errorf("handshake: %w", err) + return + } + defer cConn.Close() + + addrBuf, err := EncodeAddress(target) + if err != nil { + runErr <- fmt.Errorf("encode addr: %w", err) + return + } + if _, err := cConn.Write(addrBuf); err != nil { + runErr <- fmt.Errorf("write addr: %w", err) + return + } + + payload := bytes.Repeat([]byte{byte(id)}, payloadLen) + if _, err := cConn.Write(payload); err != nil { + runErr <- fmt.Errorf("write payload: %w", err) + return + } + echo := make([]byte, payloadLen) + if _, err := io.ReadFull(cConn, echo); err != nil { + runErr <- fmt.Errorf("read echo: %w", err) + return + } + if !bytes.Equal(echo, payload) { + runErr <- fmt.Errorf("echo mismatch") + return + } + runErr <- nil + }(i) + } + + wg.Wait() + close(runErr) + + for err := range runErr { + if err != nil { + t.Fatalf("soak: %v", err) + } + } + + stop() + for err := range errCh { + t.Fatalf("server error: %v", err) + } +} diff --git a/clash-meta/transport/sudoku/obfs/httpmask/masker.go b/clash-meta/transport/sudoku/obfs/httpmask/masker.go new file mode 100644 index 0000000000..540a8911e0 --- /dev/null +++ b/clash-meta/transport/sudoku/obfs/httpmask/masker.go @@ -0,0 +1,246 @@ +package httpmask + +import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + "io" + "math/rand" + "net" + "strconv" + "strings" + "sync" + "time" +) + +var ( + userAgents = []string{ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36", + } + accepts = []string{ + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "application/json, text/plain, */*", + "application/octet-stream", + "*/*", + } + acceptLanguages = []string{ + "en-US,en;q=0.9", + "en-GB,en;q=0.9", + "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", + "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7", + "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", + } + acceptEncodings = []string{ + "gzip, deflate, br", + "gzip, deflate", + "br, gzip, deflate", + } + paths = []string{ + "/api/v1/upload", + "/data/sync", + "/uploads/raw", + "/api/report", + "/feed/update", + "/v2/events", + "/v1/telemetry", + "/session", + "/stream", + "/ws", + } + contentTypes = []string{ + "application/octet-stream", + "application/x-protobuf", + "application/json", + } +) + +var ( + rngPool = sync.Pool{ + New: func() interface{} { + return rand.New(rand.NewSource(time.Now().UnixNano())) + }, + } + headerBufPool = sync.Pool{ + New: func() interface{} { + b := make([]byte, 0, 1024) + return &b + }, + } +) + +// LooksLikeHTTPRequestStart reports whether peek4 looks like a supported HTTP/1.x request method prefix. +func LooksLikeHTTPRequestStart(peek4 []byte) bool { + if len(peek4) < 4 { + return false + } + // Common methods: "GET ", "POST", "HEAD", "PUT ", "OPTI" (OPTIONS), "PATC" (PATCH), "DELE" (DELETE) + return bytes.Equal(peek4, []byte("GET ")) || + bytes.Equal(peek4, []byte("POST")) || + bytes.Equal(peek4, []byte("HEAD")) || + bytes.Equal(peek4, []byte("PUT ")) || + bytes.Equal(peek4, []byte("OPTI")) || + bytes.Equal(peek4, []byte("PATC")) || + bytes.Equal(peek4, []byte("DELE")) +} + +func trimPortForHost(host string) string { + if host == "" { + return host + } + // Accept "example.com:443" / "1.2.3.4:443" / "[::1]:443" + h, _, err := net.SplitHostPort(host) + if err == nil && h != "" { + return h + } + // If it's not in host:port form, keep as-is. + return host +} + +func appendCommonHeaders(buf []byte, host string, r *rand.Rand) []byte { + ua := userAgents[r.Intn(len(userAgents))] + accept := accepts[r.Intn(len(accepts))] + lang := acceptLanguages[r.Intn(len(acceptLanguages))] + enc := acceptEncodings[r.Intn(len(acceptEncodings))] + + buf = append(buf, "Host: "...) + buf = append(buf, host...) + buf = append(buf, "\r\nUser-Agent: "...) + buf = append(buf, ua...) + buf = append(buf, "\r\nAccept: "...) + buf = append(buf, accept...) + buf = append(buf, "\r\nAccept-Language: "...) + buf = append(buf, lang...) + buf = append(buf, "\r\nAccept-Encoding: "...) + buf = append(buf, enc...) + buf = append(buf, "\r\nConnection: keep-alive\r\n"...) + + // A couple of common cache headers; keep them static for simplicity. + buf = append(buf, "Cache-Control: no-cache\r\nPragma: no-cache\r\n"...) + return buf +} + +// WriteRandomRequestHeader writes a plausible HTTP/1.1 request header as a mask. +func WriteRandomRequestHeader(w io.Writer, host string) error { + // Get RNG from pool + r := rngPool.Get().(*rand.Rand) + defer rngPool.Put(r) + + path := paths[r.Intn(len(paths))] + ctype := contentTypes[r.Intn(len(contentTypes))] + + // Use buffer pool + bufPtr := headerBufPool.Get().(*[]byte) + buf := *bufPtr + buf = buf[:0] + defer func() { + if cap(buf) <= 4096 { + *bufPtr = buf + headerBufPool.Put(bufPtr) + } + }() + + // Weighted template selection. Keep a conservative default (POST w/ Content-Length), + // but occasionally rotate to other realistic templates (e.g. WebSocket upgrade). + switch r.Intn(10) { + case 0, 1: // ~20% WebSocket-like upgrade + hostNoPort := trimPortForHost(host) + var keyBytes [16]byte + for i := 0; i < len(keyBytes); i++ { + keyBytes[i] = byte(r.Intn(256)) + } + wsKey := base64.StdEncoding.EncodeToString(keyBytes[:]) + + buf = append(buf, "GET "...) + buf = append(buf, path...) + buf = append(buf, " HTTP/1.1\r\n"...) + buf = appendCommonHeaders(buf, host, r) + buf = append(buf, "Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: "...) + buf = append(buf, wsKey...) + buf = append(buf, "\r\nOrigin: https://"...) + buf = append(buf, hostNoPort...) + buf = append(buf, "\r\n\r\n"...) + default: // ~80% POST upload + // Random Content-Length: 4KB–10MB. Small enough to look plausible, large enough + // to justify long-lived writes on keep-alive connections. + const minCL = int64(4 * 1024) + const maxCL = int64(10 * 1024 * 1024) + contentLength := minCL + r.Int63n(maxCL-minCL+1) + + buf = append(buf, "POST "...) + buf = append(buf, path...) + buf = append(buf, " HTTP/1.1\r\n"...) + buf = appendCommonHeaders(buf, host, r) + buf = append(buf, "Content-Type: "...) + buf = append(buf, ctype...) + buf = append(buf, "\r\nContent-Length: "...) + buf = strconv.AppendInt(buf, contentLength, 10) + // A couple of extra headers seen in real clients. + if r.Intn(2) == 0 { + buf = append(buf, "\r\nX-Requested-With: XMLHttpRequest"...) + } + if r.Intn(3) == 0 { + buf = append(buf, "\r\nReferer: https://"...) + buf = append(buf, trimPortForHost(host)...) + buf = append(buf, "/"...) + } + buf = append(buf, "\r\n\r\n"...) + } + + _, err := w.Write(buf) + return err +} + +// ConsumeHeader 读取并消耗 HTTP 头部,返回消耗的数据和剩余的 reader 数据 +// 如果不是 POST 请求或格式严重错误,返回 error +func ConsumeHeader(r *bufio.Reader) ([]byte, error) { + var consumed bytes.Buffer + + // 1. 读取请求行 + // Use ReadSlice to avoid allocation if line fits in buffer + line, err := r.ReadSlice('\n') + if err != nil { + return nil, err + } + consumed.Write(line) + + // Basic method validation: accept common HTTP/1.x methods used by our masker. + // Keep it strict enough to reject obvious garbage. + switch { + case bytes.HasPrefix(line, []byte("POST ")), + bytes.HasPrefix(line, []byte("GET ")), + bytes.HasPrefix(line, []byte("HEAD ")), + bytes.HasPrefix(line, []byte("PUT ")), + bytes.HasPrefix(line, []byte("DELETE ")), + bytes.HasPrefix(line, []byte("OPTIONS ")), + bytes.HasPrefix(line, []byte("PATCH ")): + default: + return consumed.Bytes(), fmt.Errorf("invalid method or garbage: %s", strings.TrimSpace(string(line))) + } + + // 2. 循环读取头部,直到遇到空行 + for { + line, err = r.ReadSlice('\n') + if err != nil { + return consumed.Bytes(), err + } + consumed.Write(line) + + // Check for empty line (\r\n or \n) + // ReadSlice includes the delimiter + n := len(line) + if n == 2 && line[0] == '\r' && line[1] == '\n' { + return consumed.Bytes(), nil + } + if n == 1 && line[0] == '\n' { + return consumed.Bytes(), nil + } + } +} diff --git a/clash-meta/transport/sudoku/obfs/httpmask/tunnel.go b/clash-meta/transport/sudoku/obfs/httpmask/tunnel.go new file mode 100644 index 0000000000..b4c880bbd2 --- /dev/null +++ b/clash-meta/transport/sudoku/obfs/httpmask/tunnel.go @@ -0,0 +1,1691 @@ +package httpmask + +import ( + "bufio" + "bytes" + "context" + crand "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + mrand "math/rand" + "net" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/metacubex/mihomo/component/ca" + + "github.com/metacubex/http" + "github.com/metacubex/http/httputil" + "github.com/metacubex/tls" +) + +type TunnelMode string + +const ( + TunnelModeLegacy TunnelMode = "legacy" + TunnelModeStream TunnelMode = "stream" + TunnelModePoll TunnelMode = "poll" + TunnelModeAuto TunnelMode = "auto" +) + +func normalizeTunnelMode(mode string) TunnelMode { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", string(TunnelModeLegacy): + return TunnelModeLegacy + case string(TunnelModeStream): + return TunnelModeStream + case string(TunnelModePoll): + return TunnelModePoll + case string(TunnelModeAuto): + return TunnelModeAuto + default: + // Be conservative: unknown => legacy + return TunnelModeLegacy + } +} + +type HandleResult int + +const ( + HandlePassThrough HandleResult = iota + HandleStartTunnel + HandleDone +) + +type TunnelDialOptions struct { + Mode string + TLSEnabled bool // when true, use HTTPS; otherwise, use HTTP (no port-based inference) + HostOverride string // optional Host header / SNI host (without scheme); accepts "example.com" or "example.com:443" + // DialContext overrides how the HTTP tunnel dials raw TCP/TLS connections. + // It must not be nil; passing nil is a programming error. + DialContext func(ctx context.Context, network, addr string) (net.Conn, error) +} + +// DialTunnel establishes a bidirectional stream over HTTP: +// - stream: a single streaming POST (request body uplink, response body downlink) +// - poll: authorize + push/pull polling tunnel (base64 framed) +// - auto: try stream then fall back to poll +// +// The returned net.Conn carries the raw Sudoku stream (no HTTP headers). +func DialTunnel(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + mode := normalizeTunnelMode(opts.Mode) + if mode == TunnelModeLegacy { + return nil, fmt.Errorf("legacy mode does not use http tunnel") + } + + switch mode { + case TunnelModeStream: + return dialStreamFn(ctx, serverAddress, opts) + case TunnelModePoll: + return dialPollFn(ctx, serverAddress, opts) + case TunnelModeAuto: + // "stream" can hang on some CDNs that buffer uploads until request body completes. + // Keep it on a short leash so we can fall back to poll within the caller's deadline. + streamCtx, cancelX := context.WithTimeout(ctx, 3*time.Second) + c, errX := dialStreamFn(streamCtx, serverAddress, opts) + cancelX() + if errX == nil { + return c, nil + } + c, errP := dialPollFn(ctx, serverAddress, opts) + if errP == nil { + return c, nil + } + return nil, fmt.Errorf("auto tunnel failed: stream: %v; poll: %w", errX, errP) + default: + return dialStreamFn(ctx, serverAddress, opts) + } +} + +var ( + dialStreamFn = dialStream + dialPollFn = dialPoll +) + +func canonicalHeaderHost(urlHost, scheme string) string { + host, port, err := net.SplitHostPort(urlHost) + if err != nil { + return urlHost + } + + defaultPort := "" + switch scheme { + case "https": + defaultPort = "443" + case "http": + defaultPort = "80" + } + if defaultPort == "" || port != defaultPort { + return urlHost + } + + // If we strip the port from an IPv6 literal, re-add brackets to keep the Host header valid. + if strings.Contains(host, ":") { + return "[" + host + "]" + } + return host +} + +func parseTunnelToken(body []byte) (string, error) { + s := strings.TrimSpace(string(body)) + idx := strings.Index(s, "token=") + if idx < 0 { + return "", errors.New("missing token") + } + 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 +} + +type httpStreamConn struct { + reader io.ReadCloser + writer *io.PipeWriter + cancel context.CancelFunc + + localAddr net.Addr + remoteAddr net.Addr +} + +func (c *httpStreamConn) Read(p []byte) (int, error) { return c.reader.Read(p) } +func (c *httpStreamConn) Write(p []byte) (int, error) { return c.writer.Write(p) } + +func (c *httpStreamConn) Close() error { + if c.cancel != nil { + c.cancel() + } + _ = c.writer.CloseWithError(io.ErrClosedPipe) + return c.reader.Close() +} + +func (c *httpStreamConn) LocalAddr() net.Addr { return c.localAddr } +func (c *httpStreamConn) RemoteAddr() net.Addr { return c.remoteAddr } + +func (c *httpStreamConn) SetDeadline(time.Time) error { return nil } +func (c *httpStreamConn) SetReadDeadline(time.Time) error { return nil } +func (c *httpStreamConn) SetWriteDeadline(time.Time) error { return nil } + +type httpClientTarget struct { + scheme string + urlHost string + headerHost string +} + +func newHTTPClient(serverAddress string, opts TunnelDialOptions, maxIdleConns int) (*http.Client, httpClientTarget, error) { + if opts.DialContext == nil { + panic("httpmask: DialContext is nil") + } + + scheme, urlHost, dialAddr, serverName, err := normalizeHTTPDialTarget(serverAddress, opts.TLSEnabled, opts.HostOverride) + if err != nil { + return nil, httpClientTarget{}, err + } + + transport := &http.Transport{ + ForceAttemptHTTP2: true, + DisableCompression: true, + MaxIdleConns: maxIdleConns, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + DialContext: func(dialCtx context.Context, network, _ string) (net.Conn, error) { + return opts.DialContext(dialCtx, network, dialAddr) + }, + } + if scheme == "https" { + transport.TLSClientConfig, err = ca.GetTLSConfig(ca.Option{TLSConfig: &tls.Config{ + ServerName: serverName, + MinVersion: tls.VersionTLS12, + }}) + if err != nil { + return nil, httpClientTarget{}, err + } + } + + return &http.Client{Transport: transport}, httpClientTarget{ + scheme: scheme, + urlHost: urlHost, + headerHost: canonicalHeaderHost(urlHost, scheme), + }, nil +} + +func dialStream(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + // Prefer split session (Cloudflare-friendly). Fall back to stream-one for older servers / environments. + c, errSplit := dialStreamSplit(ctx, serverAddress, opts) + if errSplit == nil { + return c, nil + } + c2, errOne := dialStreamOne(ctx, serverAddress, opts) + if errOne == nil { + return c2, nil + } + return nil, fmt.Errorf("dial stream failed: split: %v; stream-one: %w", errSplit, errOne) +} + +func dialStreamOne(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + client, target, err := newHTTPClient(serverAddress, opts, 16) + if err != nil { + return nil, err + } + + r := rngPool.Get().(*mrand.Rand) + path := paths[r.Intn(len(paths))] + ctype := contentTypes[r.Intn(len(contentTypes))] + rngPool.Put(r) + + u := url.URL{ + Scheme: target.scheme, + Host: target.urlHost, + Path: path, + } + + reqBodyR, reqBodyW := io.Pipe() + + ctx, cancel := context.WithCancel(ctx) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), reqBodyR) + if err != nil { + cancel() + _ = reqBodyW.Close() + return nil, err + } + req.Host = target.headerHost + + applyTunnelHeaders(req.Header, target.headerHost, TunnelModeStream) + req.Header.Set("Content-Type", ctype) + + resp, err := client.Do(req) + if err != nil { + cancel() + _ = reqBodyW.Close() + return nil, err + } + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024)) + cancel() + _ = reqBodyW.Close() + return nil, fmt.Errorf("stream bad status: %s (%s)", resp.Status, strings.TrimSpace(string(body))) + } + + return &httpStreamConn{ + reader: resp.Body, + writer: reqBodyW, + cancel: cancel, + localAddr: &net.TCPAddr{}, + remoteAddr: &net.TCPAddr{}, + }, nil +} + +type streamSplitConn struct { + ctx context.Context + cancel context.CancelFunc + + client *http.Client + pushURL string + pullURL string + closeURL string + headerHost string + + rxc chan []byte + closed chan struct{} + + writeCh chan []byte + + mu sync.Mutex + readBuf []byte + localAddr net.Addr + remoteAddr net.Addr +} + +func (c *streamSplitConn) Read(b []byte) (n int, err error) { + if len(c.readBuf) == 0 { + select { + case c.readBuf = <-c.rxc: + case <-c.closed: + return 0, io.ErrClosedPipe + } + } + n = copy(b, c.readBuf) + c.readBuf = c.readBuf[n:] + return n, nil +} + +func (c *streamSplitConn) Write(b []byte) (n int, err error) { + if len(b) == 0 { + return 0, nil + } + c.mu.Lock() + select { + case <-c.closed: + c.mu.Unlock() + return 0, io.ErrClosedPipe + default: + } + c.mu.Unlock() + + payload := make([]byte, len(b)) + copy(payload, b) + select { + case c.writeCh <- payload: + return len(b), nil + case <-c.closed: + return 0, io.ErrClosedPipe + } +} + +func (c *streamSplitConn) Close() error { + c.mu.Lock() + select { + case <-c.closed: + c.mu.Unlock() + return nil + default: + close(c.closed) + } + c.mu.Unlock() + + if c.cancel != nil { + c.cancel() + } + + // Best-effort session close signal (avoid leaking server-side sessions). + closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(closeCtx, http.MethodPost, c.closeURL, nil) + if err == nil { + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModeStream) + if resp, doErr := c.client.Do(req); doErr == nil && resp != nil { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() + } + } + + return nil +} + +func (c *streamSplitConn) LocalAddr() net.Addr { return c.localAddr } +func (c *streamSplitConn) RemoteAddr() net.Addr { return c.remoteAddr } + +func (c *streamSplitConn) SetDeadline(time.Time) error { return nil } +func (c *streamSplitConn) SetReadDeadline(time.Time) error { return nil } +func (c *streamSplitConn) SetWriteDeadline(time.Time) error { return nil } + +func dialStreamSplit(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + client, target, err := newHTTPClient(serverAddress, opts, 32) + if err != nil { + return nil, err + } + + authorizeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/session"}).String() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil) + if err != nil { + return nil, err + } + req.Host = target.headerHost + applyTunnelHeaders(req.Header, target.headerHost, TunnelModeStream) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("stream authorize bad status: %s (%s)", resp.Status, strings.TrimSpace(string(bodyBytes))) + } + + token, err := parseTunnelToken(bodyBytes) + if err != nil { + return nil, fmt.Errorf("stream authorize failed: %q", strings.TrimSpace(string(bodyBytes))) + } + if token == "" { + return nil, fmt.Errorf("stream authorize empty token") + } + + pushURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/api/v1/upload", RawQuery: "token=" + url.QueryEscape(token)}).String() + pullURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/stream", RawQuery: "token=" + url.QueryEscape(token)}).String() + closeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/api/v1/upload", RawQuery: "token=" + url.QueryEscape(token) + "&close=1"}).String() + + connCtx, cancel := context.WithCancel(context.Background()) + c := &streamSplitConn{ + ctx: connCtx, + cancel: cancel, + client: client, + pushURL: pushURL, + pullURL: pullURL, + closeURL: closeURL, + headerHost: target.headerHost, + rxc: make(chan []byte, 256), + closed: make(chan struct{}), + writeCh: make(chan []byte, 256), + localAddr: &net.TCPAddr{}, + remoteAddr: &net.TCPAddr{}, + } + + go c.pullLoop() + go c.pushLoop() + return c, nil +} + +func (c *streamSplitConn) pullLoop() { + const ( + requestTimeout = 30 * time.Second + readChunkSize = 32 * 1024 + idleBackoff = 25 * time.Millisecond + ) + + buf := make([]byte, readChunkSize) + for { + select { + case <-c.closed: + return + default: + } + + reqCtx, cancel := context.WithTimeout(c.ctx, requestTimeout) + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, c.pullURL, nil) + if err != nil { + cancel() + _ = c.Close() + return + } + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModeStream) + + resp, err := c.client.Do(req) + if err != nil { + cancel() + _ = c.Close() + return + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + cancel() + _ = c.Close() + return + } + + readAny := false + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + readAny = true + payload := make([]byte, n) + copy(payload, buf[:n]) + select { + case c.rxc <- payload: + case <-c.closed: + _ = resp.Body.Close() + cancel() + return + } + } + if rerr != nil { + _ = resp.Body.Close() + cancel() + if errors.Is(rerr, io.EOF) { + // Long-poll ended; retry. + break + } + _ = c.Close() + return + } + } + cancel() + if !readAny { + // Avoid tight loop if the server replied quickly with an empty body. + select { + case <-time.After(idleBackoff): + case <-c.closed: + return + } + } + } +} + +func (c *streamSplitConn) pushLoop() { + const ( + maxBatchBytes = 256 * 1024 + flushInterval = 5 * time.Millisecond + requestTimeout = 20 * time.Second + ) + + var ( + buf bytes.Buffer + timer = time.NewTimer(flushInterval) + ) + defer timer.Stop() + + flush := func() bool { + if buf.Len() == 0 { + return true + } + + reqCtx, cancel := context.WithTimeout(c.ctx, requestTimeout) + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.pushURL, bytes.NewReader(buf.Bytes())) + if err != nil { + cancel() + return false + } + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModeStream) + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := c.client.Do(req) + if err != nil { + cancel() + return false + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() + cancel() + if resp.StatusCode != http.StatusOK { + return false + } + + buf.Reset() + return true + } + + resetTimer := func() { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(flushInterval) + } + + resetTimer() + + for { + select { + case b, ok := <-c.writeCh: + if !ok { + _ = flush() + return + } + if len(b) == 0 { + continue + } + if buf.Len()+len(b) > maxBatchBytes { + if !flush() { + _ = c.Close() + return + } + resetTimer() + } + _, _ = buf.Write(b) + if buf.Len() >= maxBatchBytes { + if !flush() { + _ = c.Close() + return + } + resetTimer() + } + case <-timer.C: + if !flush() { + _ = c.Close() + return + } + resetTimer() + case <-c.closed: + _ = flush() + return + } + } +} + +type pollConn struct { + client *http.Client + pushURL string + pullURL string + closeURL string + headerHost string + + rxc chan []byte + closed chan struct{} + + writeCh chan []byte + + mu sync.Mutex + readBuf []byte + localAddr net.Addr + remoteAddr net.Addr +} + +func (c *pollConn) Read(b []byte) (n int, err error) { + if len(c.readBuf) == 0 { + select { + case c.readBuf = <-c.rxc: + case <-c.closed: + return 0, io.ErrClosedPipe + } + } + n = copy(b, c.readBuf) + c.readBuf = c.readBuf[n:] + return n, nil +} + +func (c *pollConn) Write(b []byte) (n int, err error) { + if len(b) == 0 { + return 0, nil + } + c.mu.Lock() + select { + case <-c.closed: + c.mu.Unlock() + return 0, io.ErrClosedPipe + default: + } + c.mu.Unlock() + + payload := make([]byte, len(b)) + copy(payload, b) + select { + case c.writeCh <- payload: + return len(b), nil + case <-c.closed: + return 0, io.ErrClosedPipe + } +} + +func (c *pollConn) Close() error { + c.mu.Lock() + select { + case <-c.closed: + c.mu.Unlock() + return nil + default: + close(c.closed) + } + c.mu.Unlock() + + close(c.writeCh) + + // Best-effort session close signal (avoid leaking server-side sessions). + req, err := http.NewRequest(http.MethodPost, c.closeURL, nil) + if err == nil { + req.Host = c.headerHost + req.Header.Set("X-Sudoku-Tunnel", string(TunnelModePoll)) + req.Header.Set("X-Sudoku-Version", "1") + _, _ = c.client.Do(req) + } + + return nil +} + +func (c *pollConn) LocalAddr() net.Addr { return c.localAddr } +func (c *pollConn) RemoteAddr() net.Addr { return c.remoteAddr } + +func (c *pollConn) SetDeadline(time.Time) error { return nil } +func (c *pollConn) SetReadDeadline(time.Time) error { return nil } +func (c *pollConn) SetWriteDeadline(time.Time) error { return nil } + +func dialPoll(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + client, target, err := newHTTPClient(serverAddress, opts, 32) + if err != nil { + return nil, err + } + + authorizeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/session"}).String() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil) + if err != nil { + return nil, err + } + req.Host = target.headerHost + applyTunnelHeaders(req.Header, target.headerHost, TunnelModePoll) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("poll authorize bad status: %s (%s)", resp.Status, strings.TrimSpace(string(bodyBytes))) + } + + token, err := parseTunnelToken(bodyBytes) + if err != nil { + return nil, fmt.Errorf("poll authorize failed: %q", strings.TrimSpace(string(bodyBytes))) + } + if token == "" { + return nil, fmt.Errorf("poll authorize empty token") + } + + pushURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/api/v1/upload", RawQuery: "token=" + url.QueryEscape(token)}).String() + pullURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/stream", RawQuery: "token=" + url.QueryEscape(token)}).String() + closeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/api/v1/upload", RawQuery: "token=" + url.QueryEscape(token) + "&close=1"}).String() + + c := &pollConn{ + client: client, + pushURL: pushURL, + pullURL: pullURL, + closeURL: closeURL, + headerHost: target.headerHost, + rxc: make(chan []byte, 128), + closed: make(chan struct{}), + writeCh: make(chan []byte, 256), + localAddr: &net.TCPAddr{}, + remoteAddr: &net.TCPAddr{}, + } + + go c.pullLoop() + go c.pushLoop() + return c, nil +} + +func (c *pollConn) pullLoop() { + for { + select { + case <-c.closed: + return + default: + } + + req, err := http.NewRequest(http.MethodGet, c.pullURL, nil) + if err != nil { + _ = c.Close() + return + } + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModePoll) + + resp, err := c.client.Do(req) + if err != nil { + _ = c.Close() + return + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + _ = c.Close() + return + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + payload, err := base64.StdEncoding.DecodeString(line) + if err != nil { + _ = resp.Body.Close() + _ = c.Close() + return + } + select { + case c.rxc <- payload: + case <-c.closed: + _ = resp.Body.Close() + return + } + } + _ = resp.Body.Close() + if err := scanner.Err(); err != nil { + _ = c.Close() + return + } + } +} + +func (c *pollConn) pushLoop() { + const ( + maxBatchBytes = 64 * 1024 + flushInterval = 5 * time.Millisecond + maxLineRawBytes = 16 * 1024 + ) + + var ( + buf bytes.Buffer + pendingRaw int + timer = time.NewTimer(flushInterval) + ) + defer timer.Stop() + + flush := func() bool { + if buf.Len() == 0 { + return true + } + + req, err := http.NewRequest(http.MethodPost, c.pushURL, bytes.NewReader(buf.Bytes())) + if err != nil { + return false + } + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModePoll) + req.Header.Set("Content-Type", "text/plain") + + resp, err := c.client.Do(req) + if err != nil { + return false + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false + } + + buf.Reset() + pendingRaw = 0 + return true + } + + resetTimer := func() { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(flushInterval) + } + + resetTimer() + + for { + select { + case b, ok := <-c.writeCh: + if !ok { + _ = flush() + return + } + if len(b) == 0 { + continue + } + + // Split large writes into multiple base64 lines to cap per-line size. + for len(b) > 0 { + chunk := b + if len(chunk) > maxLineRawBytes { + chunk = b[:maxLineRawBytes] + } + b = b[len(chunk):] + + encLen := base64.StdEncoding.EncodedLen(len(chunk)) + if pendingRaw+len(chunk) > maxBatchBytes || buf.Len()+encLen+1 > maxBatchBytes*2 { + if !flush() { + _ = c.Close() + return + } + } + + tmp := make([]byte, base64.StdEncoding.EncodedLen(len(chunk))) + base64.StdEncoding.Encode(tmp, chunk) + buf.Write(tmp) + buf.WriteByte('\n') + pendingRaw += len(chunk) + } + + if pendingRaw >= maxBatchBytes { + if !flush() { + _ = c.Close() + return + } + resetTimer() + } + case <-timer.C: + if !flush() { + _ = c.Close() + return + } + resetTimer() + case <-c.closed: + _ = flush() + return + } + } +} + +func normalizeHTTPDialTarget(serverAddress string, tlsEnabled bool, hostOverride string) (scheme, urlHost, dialAddr, serverName string, err error) { + host, port, err := net.SplitHostPort(serverAddress) + if err != nil { + return "", "", "", "", fmt.Errorf("invalid server address %q: %w", serverAddress, err) + } + + if hostOverride != "" { + // Allow "example.com" or "example.com:443" + if h, p, splitErr := net.SplitHostPort(hostOverride); splitErr == nil { + if h != "" { + hostOverride = h + } + if p != "" { + port = p + } + } + serverName = hostOverride + urlHost = net.JoinHostPort(hostOverride, port) + } else { + serverName = host + urlHost = net.JoinHostPort(host, port) + } + + if tlsEnabled { + scheme = "https" + } else { + scheme = "http" + } + + dialAddr = net.JoinHostPort(host, port) + return scheme, urlHost, dialAddr, trimPortForHost(serverName), nil +} + +func applyTunnelHeaders(h http.Header, host string, mode TunnelMode) { + r := rngPool.Get().(*mrand.Rand) + ua := userAgents[r.Intn(len(userAgents))] + accept := accepts[r.Intn(len(accepts))] + lang := acceptLanguages[r.Intn(len(acceptLanguages))] + enc := acceptEncodings[r.Intn(len(acceptEncodings))] + rngPool.Put(r) + + h.Set("User-Agent", ua) + h.Set("Accept", accept) + h.Set("Accept-Language", lang) + h.Set("Accept-Encoding", enc) + h.Set("Cache-Control", "no-cache") + h.Set("Pragma", "no-cache") + h.Set("Connection", "keep-alive") + h.Set("Host", host) + h.Set("X-Sudoku-Tunnel", string(mode)) + h.Set("X-Sudoku-Version", "1") +} + +type TunnelServerOptions struct { + Mode string + // PullReadTimeout controls how long the server long-poll waits for tunnel downlink data before replying with a keepalive newline. + PullReadTimeout time.Duration + // SessionTTL is a best-effort TTL to prevent leaked sessions. 0 uses a conservative default. + SessionTTL time.Duration +} + +type TunnelServer struct { + mode TunnelMode + + pullReadTimeout time.Duration + sessionTTL time.Duration + + mu sync.Mutex + sessions map[string]*tunnelSession +} + +type tunnelSession struct { + conn net.Conn + lastActive time.Time +} + +func NewTunnelServer(opts TunnelServerOptions) *TunnelServer { + mode := normalizeTunnelMode(opts.Mode) + if mode == TunnelModeLegacy { + // Server-side "legacy" means: don't accept stream/poll tunnels; only passthrough. + } + timeout := opts.PullReadTimeout + if timeout <= 0 { + timeout = 10 * time.Second + } + ttl := opts.SessionTTL + if ttl <= 0 { + ttl = 2 * time.Minute + } + return &TunnelServer{ + mode: mode, + pullReadTimeout: timeout, + sessionTTL: ttl, + sessions: make(map[string]*tunnelSession), + } +} + +// HandleConn inspects rawConn. If it is an HTTP tunnel request (X-Sudoku-Tunnel header), it is handled here and: +// - returns HandleStartTunnel + a net.Conn that carries the raw Sudoku stream (stream mode or poll session pipe) +// - or returns HandleDone if the HTTP request is a poll control request (push/pull) and no Sudoku handshake should run on this TCP conn +// +// If it is not an HTTP tunnel request (or server mode is legacy), it returns HandlePassThrough with a conn that replays any pre-read bytes. +func (s *TunnelServer) HandleConn(rawConn net.Conn) (HandleResult, net.Conn, error) { + if rawConn == nil { + return HandleDone, nil, errors.New("nil conn") + } + + // Small header read deadline to avoid stalling Accept loops. The actual Sudoku handshake has its own deadlines. + _ = rawConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + var first [4]byte + n, err := io.ReadFull(rawConn, first[:]) + if err != nil { + _ = rawConn.SetReadDeadline(time.Time{}) + // Even if short-read, preserve bytes for downstream handlers. + if n > 0 { + return HandlePassThrough, newPreBufferedConn(rawConn, first[:n]), nil + } + return HandleDone, nil, err + } + pc := newPreBufferedConn(rawConn, first[:]) + br := bufio.NewReader(pc) + + if !LooksLikeHTTPRequestStart(first[:]) { + _ = rawConn.SetReadDeadline(time.Time{}) + return HandlePassThrough, pc, nil + } + + req, headerBytes, buffered, err := readHTTPHeader(br) + _ = rawConn.SetReadDeadline(time.Time{}) + if err != nil { + // Not a valid HTTP request; hand it back to the legacy path with replay. + prefix := make([]byte, 0, len(first)+len(headerBytes)+len(buffered)) + if len(headerBytes) == 0 || !bytes.HasPrefix(headerBytes, first[:]) { + prefix = append(prefix, first[:]...) + } + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil + } + + tunnelHeader := strings.ToLower(strings.TrimSpace(req.headers["x-sudoku-tunnel"])) + if tunnelHeader == "" { + // Not our tunnel; replay full bytes to legacy handler. + prefix := make([]byte, 0, len(headerBytes)+len(buffered)) + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil + } + if s.mode == TunnelModeLegacy { + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + switch TunnelMode(tunnelHeader) { + case TunnelModeStream: + if s.mode != TunnelModeStream && s.mode != TunnelModeAuto { + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + return s.handleStream(rawConn, req, buffered) + case TunnelModePoll: + if s.mode != TunnelModePoll && s.mode != TunnelModeAuto { + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + return s.handlePoll(rawConn, req, buffered) + default: + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } +} + +type httpRequestHeader struct { + method string + target string // path + query + proto string + headers map[string]string // lower-case keys +} + +func readHTTPHeader(r *bufio.Reader) (*httpRequestHeader, []byte, []byte, error) { + const maxHeaderBytes = 32 * 1024 + + var consumed bytes.Buffer + readLine := func() ([]byte, error) { + line, err := r.ReadSlice('\n') + if len(line) > 0 { + if consumed.Len()+len(line) > maxHeaderBytes { + return line, fmt.Errorf("http header too large") + } + consumed.Write(line) + } + return line, err + } + + // Request line + line, err := readLine() + if err != nil { + return nil, consumed.Bytes(), readAllBuffered(r), err + } + lineStr := strings.TrimRight(string(line), "\r\n") + parts := strings.SplitN(lineStr, " ", 3) + if len(parts) != 3 { + return nil, consumed.Bytes(), readAllBuffered(r), fmt.Errorf("invalid request line") + } + req := &httpRequestHeader{ + method: parts[0], + target: parts[1], + proto: parts[2], + headers: make(map[string]string), + } + + // Headers + for { + line, err = readLine() + if err != nil { + return nil, consumed.Bytes(), readAllBuffered(r), err + } + trimmed := strings.TrimRight(string(line), "\r\n") + if trimmed == "" { + break + } + k, v, ok := strings.Cut(trimmed, ":") + if !ok { + continue + } + k = strings.ToLower(strings.TrimSpace(k)) + v = strings.TrimSpace(v) + if k == "" { + continue + } + // Keep the first value; we only care about a small set. + if _, exists := req.headers[k]; !exists { + req.headers[k] = v + } + } + + return req, consumed.Bytes(), readAllBuffered(r), nil +} + +func readAllBuffered(r *bufio.Reader) []byte { + n := r.Buffered() + if n <= 0 { + return nil + } + b, err := r.Peek(n) + if err != nil { + return nil + } + out := make([]byte, n) + copy(out, b) + return out +} + +type preBufferedConn struct { + net.Conn + buf []byte +} + +func newPreBufferedConn(conn net.Conn, pre []byte) net.Conn { + cpy := make([]byte, len(pre)) + copy(cpy, pre) + return &preBufferedConn{Conn: conn, buf: cpy} +} + +func (p *preBufferedConn) Read(b []byte) (int, error) { + if len(p.buf) > 0 { + n := copy(b, p.buf) + p.buf = p.buf[n:] + return n, nil + } + return p.Conn.Read(b) +} + +type bodyConn struct { + net.Conn + reader io.Reader + writer io.WriteCloser + tail io.Writer + flush func() error +} + +func (c *bodyConn) Read(p []byte) (int, error) { return c.reader.Read(p) } +func (c *bodyConn) Write(p []byte) (int, error) { + n, err := c.writer.Write(p) + if c.flush != nil { + _ = c.flush() + } + return n, err +} + +func (c *bodyConn) Close() error { + var firstErr error + if c.writer != nil { + if err := c.writer.Close(); err != nil && firstErr == nil { + firstErr = err + } + // NewChunkedWriter does not write the final CRLF. Ensure a clean terminator. + if c.tail != nil { + _, _ = c.tail.Write([]byte("\r\n")) + } else { + _, _ = c.Conn.Write([]byte("\r\n")) + } + if c.flush != nil { + _ = c.flush() + } + } + if err := c.Conn.Close(); err != nil && firstErr == nil { + firstErr = err + } + return firstErr +} + +func (s *TunnelServer) handleStream(rawConn net.Conn, req *httpRequestHeader, buffered []byte) (HandleResult, net.Conn, error) { + u, err := url.ParseRequestURI(req.target) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + // Only accept plausible paths to reduce accidental exposure. + if !isAllowedPath(req.target) { + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + token := u.Query().Get("token") + closeFlag := u.Query().Get("close") == "1" + + 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 == "" && u.Path == "/session" { + return s.authorizeSession(rawConn) + } + // Stream split-session: GET /stream?token=... => downlink poll. + if token != "" && u.Path == "/stream" { + return s.streamPull(rawConn, token) + } + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + + case http.MethodPost: + // Stream split-session: POST /api/v1/upload?token=... => uplink push. + if token != "" && u.Path == "/api/v1/upload" { + if closeFlag { + s.closeSession(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil + } + bodyReader, err := newRequestBodyReader(newPreBufferedConn(rawConn, buffered), req.headers) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + return s.streamPush(rawConn, token, bodyReader) + } + + // Stream-one: single full-duplex POST. + if err := writeTunnelResponseHeader(rawConn); err != nil { + _ = rawConn.Close() + return HandleDone, nil, err + } + + bodyReader, err := newRequestBodyReader(newPreBufferedConn(rawConn, buffered), req.headers) + if err != nil { + _ = rawConn.Close() + return HandleDone, nil, err + } + + bw := bufio.NewWriterSize(rawConn, 32*1024) + chunked := httputil.NewChunkedWriter(bw) + stream := &bodyConn{ + Conn: rawConn, + reader: bodyReader, + writer: chunked, + tail: bw, + flush: bw.Flush, + } + return HandleStartTunnel, stream, nil + + default: + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } +} + +func isAllowedPath(target string) bool { + u, err := url.ParseRequestURI(target) + if err != nil { + return false + } + for _, p := range paths { + if u.Path == p { + return true + } + } + return false +} + +func newRequestBodyReader(conn net.Conn, headers map[string]string) (io.Reader, error) { + br := bufio.NewReaderSize(conn, 32*1024) + + te := strings.ToLower(headers["transfer-encoding"]) + if strings.Contains(te, "chunked") { + return httputil.NewChunkedReader(br), nil + } + if clStr := headers["content-length"]; clStr != "" { + n, err := strconv.ParseInt(strings.TrimSpace(clStr), 10, 64) + if err != nil || n < 0 { + return nil, fmt.Errorf("invalid content-length") + } + return io.LimitReader(br, n), nil + } + return br, nil +} + +func writeTunnelResponseHeader(w io.Writer) error { + _, err := io.WriteString(w, + "HTTP/1.1 200 OK\r\n"+ + "Content-Type: application/octet-stream\r\n"+ + "Transfer-Encoding: chunked\r\n"+ + "Cache-Control: no-store\r\n"+ + "Pragma: no-cache\r\n"+ + "Connection: keep-alive\r\n"+ + "X-Accel-Buffering: no\r\n"+ + "\r\n") + return err +} + +func writeSimpleHTTPResponse(w io.Writer, code int, body string) error { + if body == "" { + body = http.StatusText(code) + } + body = strings.TrimRight(body, "\r\n") + _, err := io.WriteString(w, + fmt.Sprintf("HTTP/1.1 %d %s\r\nContent-Type: text/plain\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", + code, http.StatusText(code), len(body), body)) + return err +} + +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. + _, 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)) + return err +} + +func (s *TunnelServer) handlePoll(rawConn net.Conn, req *httpRequestHeader, buffered []byte) (HandleResult, net.Conn, error) { + u, err := url.ParseRequestURI(req.target) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + if !isAllowedPath(req.target) { + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + token := u.Query().Get("token") + closeFlag := u.Query().Get("close") == "1" + switch strings.ToUpper(req.method) { + case http.MethodGet: + if token == "" { + return s.authorizeSession(rawConn) + } + return s.pollPull(rawConn, token) + case http.MethodPost: + if token == "" { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "missing token") + _ = rawConn.Close() + return HandleDone, nil, nil + } + if closeFlag { + s.closeSession(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil + } + bodyReader, err := newRequestBodyReader(newPreBufferedConn(rawConn, buffered), req.headers) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + return s.pollPush(rawConn, token, bodyReader) + default: + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } +} + +func (s *TunnelServer) authorizeSession(rawConn net.Conn) (HandleResult, net.Conn, error) { + token, err := newSessionToken() + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusInternalServerError, "internal error") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + c1, c2 := net.Pipe() + + s.mu.Lock() + s.sessions[token] = &tunnelSession{conn: c2, lastActive: time.Now()} + s.mu.Unlock() + + go s.reapSessionLater(token) + + _ = writeTokenHTTPResponse(rawConn, token) + _ = rawConn.Close() + return HandleStartTunnel, c1, nil +} + +func newSessionToken() (string, error) { + var b [16]byte + if _, err := crand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +func (s *TunnelServer) reapSessionLater(token string) { + ttl := s.sessionTTL + if ttl <= 0 { + return + } + timer := time.NewTimer(ttl) + defer timer.Stop() + <-timer.C + + s.mu.Lock() + sess, ok := s.sessions[token] + if !ok { + s.mu.Unlock() + return + } + if time.Since(sess.lastActive) < ttl { + s.mu.Unlock() + return + } + delete(s.sessions, token) + s.mu.Unlock() + _ = sess.conn.Close() +} + +func (s *TunnelServer) getSession(token string) (*tunnelSession, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.sessions[token] + if !ok { + return nil, false + } + sess.lastActive = time.Now() + return sess, true +} + +func (s *TunnelServer) closeSession(token string) { + s.mu.Lock() + sess, ok := s.sessions[token] + if ok { + delete(s.sessions, token) + } + s.mu.Unlock() + if ok { + _ = sess.conn.Close() + } +} + +func (s *TunnelServer) pollPush(rawConn net.Conn, token string, body io.Reader) (HandleResult, net.Conn, error) { + sess, ok := s.getSession(token) + if !ok { + _ = writeSimpleHTTPResponse(rawConn, http.StatusForbidden, "forbidden") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + payload, err := io.ReadAll(io.LimitReader(body, 1<<20)) // 1MiB per request cap + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + lines := bytes.Split(payload, []byte{'\n'}) + for _, line := range lines { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + decoded := make([]byte, base64.StdEncoding.DecodedLen(len(line))) + n, decErr := base64.StdEncoding.Decode(decoded, line) + if decErr != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + if n == 0 { + continue + } + _ = sess.conn.SetWriteDeadline(time.Now().Add(30 * time.Second)) + _, werr := sess.conn.Write(decoded[:n]) + _ = sess.conn.SetWriteDeadline(time.Time{}) + if werr != nil { + s.closeSession(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusGone, "gone") + _ = rawConn.Close() + return HandleDone, nil, nil + } + } + + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil +} + +func (s *TunnelServer) streamPush(rawConn net.Conn, token string, body io.Reader) (HandleResult, net.Conn, error) { + sess, ok := s.getSession(token) + if !ok { + _ = writeSimpleHTTPResponse(rawConn, http.StatusForbidden, "forbidden") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + const maxUploadBytes = 1 << 20 + payload, err := io.ReadAll(io.LimitReader(body, maxUploadBytes+1)) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + if len(payload) > maxUploadBytes { + _ = writeSimpleHTTPResponse(rawConn, http.StatusRequestEntityTooLarge, "too large") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + if len(payload) > 0 { + _ = sess.conn.SetWriteDeadline(time.Now().Add(30 * time.Second)) + _, werr := sess.conn.Write(payload) + _ = sess.conn.SetWriteDeadline(time.Time{}) + if werr != nil { + s.closeSession(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusGone, "gone") + _ = rawConn.Close() + return HandleDone, nil, nil + } + } + + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil +} + +func (s *TunnelServer) streamPull(rawConn net.Conn, token string) (HandleResult, net.Conn, error) { + sess, ok := s.getSession(token) + if !ok { + _ = writeSimpleHTTPResponse(rawConn, http.StatusForbidden, "forbidden") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + // Streaming response (chunked) with raw bytes (no base64 framing). + if err := writeTunnelResponseHeader(rawConn); err != nil { + _ = rawConn.Close() + return HandleDone, nil, err + } + + bw := bufio.NewWriterSize(rawConn, 32*1024) + cw := httputil.NewChunkedWriter(bw) + defer func() { + _ = cw.Close() + _, _ = bw.WriteString("\r\n") + _ = bw.Flush() + _ = rawConn.Close() + }() + + buf := make([]byte, 32*1024) + for { + _ = sess.conn.SetReadDeadline(time.Now().Add(s.pullReadTimeout)) + n, err := sess.conn.Read(buf) + if n > 0 { + _, _ = cw.Write(buf[:n]) + _ = bw.Flush() + } + if err != nil { + if errors.Is(err, os.ErrDeadlineExceeded) { + // End this long-poll response; client will re-issue. + return HandleDone, nil, nil + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed) { + return HandleDone, nil, nil + } + s.closeSession(token) + return HandleDone, nil, nil + } + } +} + +func (s *TunnelServer) pollPull(rawConn net.Conn, token string) (HandleResult, net.Conn, error) { + sess, ok := s.getSession(token) + if !ok { + _ = writeSimpleHTTPResponse(rawConn, http.StatusForbidden, "forbidden") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + // Streaming response (chunked) with base64 lines. + if err := writeTunnelResponseHeader(rawConn); err != nil { + _ = rawConn.Close() + return HandleDone, nil, err + } + + bw := bufio.NewWriterSize(rawConn, 32*1024) + cw := httputil.NewChunkedWriter(bw) + defer func() { + _ = cw.Close() + _, _ = bw.WriteString("\r\n") + _ = bw.Flush() + _ = rawConn.Close() + }() + + buf := make([]byte, 32*1024) + for { + _ = sess.conn.SetReadDeadline(time.Now().Add(s.pullReadTimeout)) + n, err := sess.conn.Read(buf) + if n > 0 { + line := make([]byte, base64.StdEncoding.EncodedLen(n)) + base64.StdEncoding.Encode(line, buf[:n]) + _, _ = cw.Write(append(line, '\n')) + _ = bw.Flush() + } + if err != nil { + if errors.Is(err, os.ErrDeadlineExceeded) { + // Keepalive: send an empty line then end this long-poll response. + _, _ = cw.Write([]byte("\n")) + _ = bw.Flush() + return HandleDone, nil, nil + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed) { + return HandleDone, nil, nil + } + s.closeSession(token) + return HandleDone, nil, nil + } + } +} diff --git a/clash-meta/transport/sudoku/obfs/sudoku/conn.go b/clash-meta/transport/sudoku/obfs/sudoku/conn.go new file mode 100644 index 0000000000..d09c8a68f2 --- /dev/null +++ b/clash-meta/transport/sudoku/obfs/sudoku/conn.go @@ -0,0 +1,212 @@ +package sudoku + +import ( + "bufio" + "bytes" + crypto_rand "crypto/rand" + "encoding/binary" + "errors" + "math/rand" + "net" + "sync" +) + +const IOBufferSize = 32 * 1024 + +var perm4 = [24][4]byte{ + {0, 1, 2, 3}, + {0, 1, 3, 2}, + {0, 2, 1, 3}, + {0, 2, 3, 1}, + {0, 3, 1, 2}, + {0, 3, 2, 1}, + {1, 0, 2, 3}, + {1, 0, 3, 2}, + {1, 2, 0, 3}, + {1, 2, 3, 0}, + {1, 3, 0, 2}, + {1, 3, 2, 0}, + {2, 0, 1, 3}, + {2, 0, 3, 1}, + {2, 1, 0, 3}, + {2, 1, 3, 0}, + {2, 3, 0, 1}, + {2, 3, 1, 0}, + {3, 0, 1, 2}, + {3, 0, 2, 1}, + {3, 1, 0, 2}, + {3, 1, 2, 0}, + {3, 2, 0, 1}, + {3, 2, 1, 0}, +} + +type Conn struct { + net.Conn + table *Table + reader *bufio.Reader + recorder *bytes.Buffer + recording bool + recordLock sync.Mutex + + rawBuf []byte + pendingData []byte + hintBuf []byte + + rng *rand.Rand + paddingRate float32 +} + +func NewConn(c net.Conn, table *Table, pMin, pMax int, record bool) *Conn { + var seedBytes [8]byte + if _, err := crypto_rand.Read(seedBytes[:]); err != nil { + binary.BigEndian.PutUint64(seedBytes[:], uint64(rand.Int63())) + } + seed := int64(binary.BigEndian.Uint64(seedBytes[:])) + localRng := rand.New(rand.NewSource(seed)) + + min := float32(pMin) / 100.0 + rng := float32(pMax-pMin) / 100.0 + rate := min + localRng.Float32()*rng + + sc := &Conn{ + Conn: c, + table: table, + reader: bufio.NewReaderSize(c, IOBufferSize), + rawBuf: make([]byte, IOBufferSize), + pendingData: make([]byte, 0, 4096), + hintBuf: make([]byte, 0, 4), + rng: localRng, + paddingRate: rate, + } + if record { + sc.recorder = new(bytes.Buffer) + sc.recording = true + } + return sc +} + +func (sc *Conn) StopRecording() { + sc.recordLock.Lock() + sc.recording = false + sc.recorder = nil + sc.recordLock.Unlock() +} + +func (sc *Conn) GetBufferedAndRecorded() []byte { + if sc == nil { + return nil + } + + sc.recordLock.Lock() + defer sc.recordLock.Unlock() + + var recorded []byte + if sc.recorder != nil { + recorded = sc.recorder.Bytes() + } + + buffered := sc.reader.Buffered() + if buffered > 0 { + peeked, _ := sc.reader.Peek(buffered) + full := make([]byte, len(recorded)+len(peeked)) + copy(full, recorded) + copy(full[len(recorded):], peeked) + return full + } + return recorded +} + +func (sc *Conn) Write(p []byte) (n int, err error) { + if len(p) == 0 { + return 0, nil + } + + outCapacity := len(p) * 6 + out := make([]byte, 0, outCapacity) + pads := sc.table.PaddingPool + padLen := len(pads) + + for _, b := range p { + if sc.rng.Float32() < sc.paddingRate { + out = append(out, pads[sc.rng.Intn(padLen)]) + } + + puzzles := sc.table.EncodeTable[b] + puzzle := puzzles[sc.rng.Intn(len(puzzles))] + + perm := perm4[sc.rng.Intn(len(perm4))] + for _, idx := range perm { + if sc.rng.Float32() < sc.paddingRate { + out = append(out, pads[sc.rng.Intn(padLen)]) + } + out = append(out, puzzle[idx]) + } + } + + if sc.rng.Float32() < sc.paddingRate { + out = append(out, pads[sc.rng.Intn(padLen)]) + } + + _, err = sc.Conn.Write(out) + return len(p), err +} + +func (sc *Conn) Read(p []byte) (n int, err error) { + if len(sc.pendingData) > 0 { + n = copy(p, sc.pendingData) + if n == len(sc.pendingData) { + sc.pendingData = sc.pendingData[:0] + } else { + sc.pendingData = sc.pendingData[n:] + } + return n, nil + } + + for { + if len(sc.pendingData) > 0 { + break + } + + nr, rErr := sc.reader.Read(sc.rawBuf) + if nr > 0 { + chunk := sc.rawBuf[:nr] + sc.recordLock.Lock() + if sc.recording { + sc.recorder.Write(chunk) + } + sc.recordLock.Unlock() + + for _, b := range chunk { + if !sc.table.layout.isHint(b) { + continue + } + + sc.hintBuf = append(sc.hintBuf, b) + if len(sc.hintBuf) == 4 { + key := packHintsToKey([4]byte{sc.hintBuf[0], sc.hintBuf[1], sc.hintBuf[2], sc.hintBuf[3]}) + val, ok := sc.table.DecodeMap[key] + if !ok { + return 0, errors.New("INVALID_SUDOKU_MAP_MISS") + } + sc.pendingData = append(sc.pendingData, val) + sc.hintBuf = sc.hintBuf[:0] + } + } + } + + if rErr != nil { + return 0, rErr + } + if len(sc.pendingData) > 0 { + break + } + } + + n = copy(p, sc.pendingData) + if n == len(sc.pendingData) { + sc.pendingData = sc.pendingData[:0] + } else { + sc.pendingData = sc.pendingData[n:] + } + return n, nil +} diff --git a/clash-meta/transport/sudoku/obfs/sudoku/grid.go b/clash-meta/transport/sudoku/obfs/sudoku/grid.go new file mode 100644 index 0000000000..3e802989d3 --- /dev/null +++ b/clash-meta/transport/sudoku/obfs/sudoku/grid.go @@ -0,0 +1,46 @@ +package sudoku + +// Grid represents a 4x4 sudoku grid +type Grid [16]uint8 + +// GenerateAllGrids generates all valid 4x4 Sudoku grids +func GenerateAllGrids() []Grid { + var grids []Grid + var g Grid + var backtrack func(int) + + backtrack = func(idx int) { + if idx == 16 { + grids = append(grids, g) + return + } + row, col := idx/4, idx%4 + br, bc := (row/2)*2, (col/2)*2 + for num := uint8(1); num <= 4; num++ { + valid := true + for i := 0; i < 4; i++ { + if g[row*4+i] == num || g[i*4+col] == num { + valid = false + break + } + } + if valid { + for r := 0; r < 2; r++ { + for c := 0; c < 2; c++ { + if g[(br+r)*4+(bc+c)] == num { + valid = false + break + } + } + } + } + if valid { + g[idx] = num + backtrack(idx + 1) + g[idx] = 0 + } + } + } + backtrack(0) + return grids +} diff --git a/clash-meta/transport/sudoku/obfs/sudoku/layout.go b/clash-meta/transport/sudoku/obfs/sudoku/layout.go new file mode 100644 index 0000000000..72c569f5cd --- /dev/null +++ b/clash-meta/transport/sudoku/obfs/sudoku/layout.go @@ -0,0 +1,204 @@ +package sudoku + +import ( + "fmt" + "math/bits" + "sort" + "strings" +) + +type byteLayout struct { + name string + hintMask byte + hintValue byte + padMarker byte + paddingPool []byte + + encodeHint func(val, pos byte) byte + encodeGroup func(group byte) byte + decodeGroup func(b byte) (byte, bool) +} + +func (l *byteLayout) isHint(b byte) bool { + return (b & l.hintMask) == l.hintValue +} + +// resolveLayout picks the byte layout based on ASCII preference and optional custom pattern. +// ASCII always wins if requested. Custom patterns are ignored when ASCII is preferred. +func resolveLayout(mode string, customPattern string) (*byteLayout, error) { + switch strings.ToLower(mode) { + case "ascii", "prefer_ascii": + return newASCIILayout(), nil + case "entropy", "prefer_entropy", "": + // fallback to entropy unless a custom pattern is provided + default: + return nil, fmt.Errorf("invalid ascii mode: %s", mode) + } + + if strings.TrimSpace(customPattern) != "" { + return newCustomLayout(customPattern) + } + return newEntropyLayout(), nil +} + +func newASCIILayout() *byteLayout { + padding := make([]byte, 0, 32) + for i := 0; i < 32; i++ { + padding = append(padding, byte(0x20+i)) + } + return &byteLayout{ + name: "ascii", + hintMask: 0x40, + hintValue: 0x40, + padMarker: 0x3F, + paddingPool: padding, + encodeHint: func(val, pos byte) byte { + return 0x40 | ((val & 0x03) << 4) | (pos & 0x0F) + }, + encodeGroup: func(group byte) byte { + return 0x40 | (group & 0x3F) + }, + decodeGroup: func(b byte) (byte, bool) { + if (b & 0x40) == 0 { + return 0, false + } + return b & 0x3F, true + }, + } +} + +func newEntropyLayout() *byteLayout { + padding := make([]byte, 0, 16) + for i := 0; i < 8; i++ { + padding = append(padding, byte(0x80+i)) + padding = append(padding, byte(0x10+i)) + } + return &byteLayout{ + name: "entropy", + hintMask: 0x90, + hintValue: 0x00, + padMarker: 0x80, + paddingPool: padding, + encodeHint: func(val, pos byte) byte { + return ((val & 0x03) << 5) | (pos & 0x0F) + }, + encodeGroup: func(group byte) byte { + v := group & 0x3F + return ((v & 0x30) << 1) | (v & 0x0F) + }, + decodeGroup: func(b byte) (byte, bool) { + if (b & 0x90) != 0 { + return 0, false + } + return ((b >> 1) & 0x30) | (b & 0x0F), true + }, + } +} + +func newCustomLayout(pattern string) (*byteLayout, error) { + cleaned := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(pattern), " ", "")) + if len(cleaned) != 8 { + return nil, fmt.Errorf("custom table must have 8 symbols, got %d", len(cleaned)) + } + + var xBits, pBits, vBits []uint8 + for i, c := range cleaned { + bit := uint8(7 - i) + switch c { + case 'x': + xBits = append(xBits, bit) + case 'p': + pBits = append(pBits, bit) + case 'v': + vBits = append(vBits, bit) + default: + return nil, fmt.Errorf("invalid char %q in custom table", c) + } + } + + if len(xBits) != 2 || len(pBits) != 2 || len(vBits) != 4 { + return nil, fmt.Errorf("custom table must contain exactly 2 x, 2 p, 4 v") + } + + xMask := byte(0) + for _, b := range xBits { + xMask |= 1 << b + } + + encodeBits := func(val, pos byte, dropX int) byte { + var out byte + out |= xMask + if dropX >= 0 { + out &^= 1 << xBits[dropX] + } + if (val & 0x02) != 0 { + out |= 1 << pBits[0] + } + if (val & 0x01) != 0 { + out |= 1 << pBits[1] + } + for i, bit := range vBits { + if (pos>>(3-uint8(i)))&0x01 == 1 { + out |= 1 << bit + } + } + return out + } + + decodeGroup := func(b byte) (byte, bool) { + if (b & xMask) != xMask { + return 0, false + } + var val, pos byte + if b&(1<= 5 { + if _, ok := paddingSet[b]; !ok { + paddingSet[b] = struct{}{} + padding = append(padding, b) + } + } + } + } + } + sort.Slice(padding, func(i, j int) bool { return padding[i] < padding[j] }) + if len(padding) == 0 { + return nil, fmt.Errorf("custom table produced empty padding pool") + } + + return &byteLayout{ + name: fmt.Sprintf("custom(%s)", cleaned), + hintMask: xMask, + hintValue: xMask, + padMarker: padding[0], + paddingPool: padding, + encodeHint: func(val, pos byte) byte { + return encodeBits(val, pos, -1) + }, + encodeGroup: func(group byte) byte { + val := (group >> 4) & 0x03 + pos := group & 0x0F + return encodeBits(val, pos, -1) + }, + decodeGroup: decodeGroup, + }, nil +} diff --git a/clash-meta/transport/sudoku/obfs/sudoku/packed.go b/clash-meta/transport/sudoku/obfs/sudoku/packed.go new file mode 100644 index 0000000000..567afe73bc --- /dev/null +++ b/clash-meta/transport/sudoku/obfs/sudoku/packed.go @@ -0,0 +1,332 @@ +package sudoku + +import ( + "bufio" + crypto_rand "crypto/rand" + "encoding/binary" + "io" + "math/rand" + "net" + "sync" +) + +const ( + // 每次从 RNG 获取批量随机数的缓存大小,减少 RNG 函数调用开销 + RngBatchSize = 128 +) + +// 1. 使用 12字节->16组 的块处理优化 Write (减少循环开销) +// 2. 使用浮点随机概率判断 Padding,与纯 Sudoku 保持流量特征一致 +// 3. Read 使用 copy 移动避免底层数组泄漏 +type PackedConn struct { + net.Conn + table *Table + reader *bufio.Reader + + // 读缓冲 + rawBuf []byte + pendingData []byte // 解码后尚未被 Read 取走的字节 + + // 写缓冲与状态 + writeMu sync.Mutex + writeBuf []byte + bitBuf uint64 // 暂存的位数据 + bitCount int // 暂存的位数 + + // 读状态 + readBitBuf uint64 + readBits int + + // 随机数与填充控制 - 使用浮点随机,与 Conn 一致 + rng *rand.Rand + paddingRate float32 // 与 Conn 保持一致的随机概率模型 + padMarker byte + padPool []byte +} + +func NewPackedConn(c net.Conn, table *Table, pMin, pMax int) *PackedConn { + var seedBytes [8]byte + if _, err := crypto_rand.Read(seedBytes[:]); err != nil { + binary.BigEndian.PutUint64(seedBytes[:], uint64(rand.Int63())) + } + seed := int64(binary.BigEndian.Uint64(seedBytes[:])) + localRng := rand.New(rand.NewSource(seed)) + + // 与 Conn 保持一致的 padding 概率计算 + min := float32(pMin) / 100.0 + rng := float32(pMax-pMin) / 100.0 + rate := min + localRng.Float32()*rng + + pc := &PackedConn{ + Conn: c, + table: table, + reader: bufio.NewReaderSize(c, IOBufferSize), + rawBuf: make([]byte, IOBufferSize), + pendingData: make([]byte, 0, 4096), + writeBuf: make([]byte, 0, 4096), + rng: localRng, + paddingRate: rate, + } + + pc.padMarker = table.layout.padMarker + for _, b := range table.PaddingPool { + if b != pc.padMarker { + pc.padPool = append(pc.padPool, b) + } + } + if len(pc.padPool) == 0 { + pc.padPool = append(pc.padPool, pc.padMarker) + } + return pc +} + +// maybeAddPadding 内联辅助:根据浮点概率插入 padding +func (pc *PackedConn) maybeAddPadding(out []byte) []byte { + if pc.rng.Float32() < pc.paddingRate { + out = append(out, pc.getPaddingByte()) + } + return out +} + +// Write 极致优化版 - 批量处理 12 字节 +func (pc *PackedConn) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + 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 + 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) + 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.maybeAddPadding(out) + out = append(out, pc.encodeGroup(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 + + g1 := (b1 >> 2) & 0x3F + g2 := ((b1 & 0x03) << 4) | ((b2 >> 4) & 0x0F) + 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)) + } + } + + // 4. 处理剩余的 3 字节块 + for i+2 < n { + b1, b2, b3 := p[i], p[i+1], p[i+2] + i += 3 + + g1 := (b1 >> 2) & 0x3F + g2 := ((b1 & 0x03) << 4) | ((b2 >> 4) & 0x0F) + 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)) + } + + // 5. 尾部处理 (Tail Path) - 处理剩余的 1 或 2 个字节 + for ; i < n; i++ { + b := p[i] + pc.bitBuf = (pc.bitBuf << 8) | uint64(b) + 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.maybeAddPadding(out) + out = append(out, pc.encodeGroup(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 = 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 + } + pc.writeBuf = out[:0] + return len(p), nil +} + +// Flush 处理最后不足 6 bit 的情况 +func (pc *PackedConn) Flush() error { + pc.writeMu.Lock() + defer pc.writeMu.Unlock() + + out := pc.writeBuf[:0] + if pc.bitCount > 0 { + group := byte(pc.bitBuf << (6 - pc.bitCount)) + pc.bitBuf = 0 + pc.bitCount = 0 + + out = append(out, pc.encodeGroup(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 err + } + 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] + } + return n, nil + } + + // 2. 循环读取直到解出数据或出错 + for { + nr, rErr := pc.reader.Read(pc.rawBuf) + if nr > 0 { + // 缓存频繁访问的变量 + rBuf := pc.readBitBuf + rBits := pc.readBits + padMarker := pc.padMarker + layout := pc.table.layout + + for _, b := range pc.rawBuf[:nr] { + if !layout.isHint(b) { + if b == padMarker { + rBuf = 0 + rBits = 0 + } + continue + } + + group, ok := layout.decodeGroup(b) + if !ok { + return 0, ErrInvalidSudokuMapMiss + } + + rBuf = (rBuf << 6) | uint64(group) + rBits += 6 + + if rBits >= 8 { + rBits -= 8 + val := byte(rBuf >> rBits) + pc.pendingData = append(pc.pendingData, val) + } + } + + pc.readBitBuf = rBuf + pc.readBits = rBits + } + + if rErr != nil { + if rErr == io.EOF { + pc.readBitBuf = 0 + pc.readBits = 0 + } + if len(pc.pendingData) > 0 { + break + } + return 0, rErr + } + + if len(pc.pendingData) > 0 { + break + } + } + + // 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 +} + +// 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) +} diff --git a/clash-meta/transport/sudoku/obfs/sudoku/table.go b/clash-meta/transport/sudoku/obfs/sudoku/table.go new file mode 100644 index 0000000000..d86e642fb8 --- /dev/null +++ b/clash-meta/transport/sudoku/obfs/sudoku/table.go @@ -0,0 +1,153 @@ +package sudoku + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + "log" + "math/rand" + "time" +) + +var ( + ErrInvalidSudokuMapMiss = errors.New("INVALID_SUDOKU_MAP_MISS") +) + +type Table struct { + EncodeTable [256][][4]byte + DecodeMap map[uint32]byte + PaddingPool []byte + IsASCII bool // 标记当前模式 + layout *byteLayout +} + +// NewTable initializes the obfuscation tables with built-in layouts. +// Equivalent to calling NewTableWithCustom(key, mode, ""). +func NewTable(key string, mode string) *Table { + t, err := NewTableWithCustom(key, mode, "") + if err != nil { + log.Panicf("failed to build table: %v", err) + } + return t +} + +// NewTableWithCustom initializes obfuscation tables using either predefined or custom layouts. +// mode: "prefer_ascii" or "prefer_entropy". If a custom pattern is provided, ASCII mode still takes precedence. +// The customPattern must contain 8 characters with exactly 2 x, 2 p, and 4 v (case-insensitive). +func NewTableWithCustom(key string, mode string, customPattern string) (*Table, error) { + start := time.Now() + + layout, err := resolveLayout(mode, customPattern) + if err != nil { + return nil, err + } + + t := &Table{ + DecodeMap: make(map[uint32]byte), + IsASCII: layout.name == "ascii", + layout: layout, + } + t.PaddingPool = append(t.PaddingPool, layout.paddingPool...) + + // 生成数独网格 (逻辑不变) + allGrids := GenerateAllGrids() + h := sha256.New() + h.Write([]byte(key)) + seed := int64(binary.BigEndian.Uint64(h.Sum(nil)[:8])) + rng := rand.New(rand.NewSource(seed)) + + shuffledGrids := make([]Grid, 288) + copy(shuffledGrids, allGrids) + rng.Shuffle(len(shuffledGrids), func(i, j int) { + shuffledGrids[i], shuffledGrids[j] = shuffledGrids[j], shuffledGrids[i] + }) + + // 预计算组合 + var combinations [][]int + var combine func(int, int, []int) + combine = func(s, k int, c []int) { + if k == 0 { + tmp := make([]int, len(c)) + copy(tmp, c) + combinations = append(combinations, tmp) + return + } + for i := s; i <= 16-k; i++ { + c = append(c, i) + combine(i+1, k-1, c) + c = c[:len(c)-1] + } + } + combine(0, 4, []int{}) + + // 构建映射表 + for byteVal := 0; byteVal < 256; byteVal++ { + targetGrid := shuffledGrids[byteVal] + for _, positions := range combinations { + var currentHints [4]byte + + // 1. 计算抽象提示 (Abstract Hints) + // 我们先计算出 val 和 pos,后面再根据模式编码成 byte + var rawParts [4]struct{ val, pos byte } + + for i, pos := range positions { + val := targetGrid[pos] // 1..4 + rawParts[i] = struct{ val, pos byte }{val, uint8(pos)} + } + + // 检查唯一性 (数独逻辑) + matchCount := 0 + for _, g := range allGrids { + match := true + for _, p := range rawParts { + if g[p.pos] != p.val { + match = false + break + } + } + if match { + matchCount++ + if matchCount > 1 { + break + } + } + } + + if matchCount == 1 { + // 唯一确定,生成最终编码字节 + for i, p := range rawParts { + currentHints[i] = t.layout.encodeHint(p.val-1, p.pos) + } + + t.EncodeTable[byteVal] = append(t.EncodeTable[byteVal], currentHints) + // 生成解码键 (需要对 Hints 进行排序以忽略传输顺序) + key := packHintsToKey(currentHints) + t.DecodeMap[key] = byte(byteVal) + } + } + } + log.Printf("[Init] Sudoku Tables initialized (%s) in %v", layout.name, time.Since(start)) + return t, nil +} + +func packHintsToKey(hints [4]byte) uint32 { + // Sorting network for 4 elements (Bubble sort unrolled) + // Swap if a > b + if hints[0] > hints[1] { + hints[0], hints[1] = hints[1], hints[0] + } + if hints[2] > hints[3] { + hints[2], hints[3] = hints[3], hints[2] + } + if hints[0] > hints[2] { + hints[0], hints[2] = hints[2], hints[0] + } + if hints[1] > hints[3] { + hints[1], hints[3] = hints[3], hints[1] + } + if hints[1] > hints[2] { + hints[1], hints[2] = hints[2], hints[1] + } + + return uint32(hints[0])<<24 | uint32(hints[1])<<16 | uint32(hints[2])<<8 | uint32(hints[3]) +} diff --git a/clash-meta/transport/sudoku/obfs/sudoku/table_set.go b/clash-meta/transport/sudoku/obfs/sudoku/table_set.go new file mode 100644 index 0000000000..59d3c98f1c --- /dev/null +++ b/clash-meta/transport/sudoku/obfs/sudoku/table_set.go @@ -0,0 +1,38 @@ +package sudoku + +import "fmt" + +// TableSet is a small helper for managing multiple Sudoku tables (e.g. for per-connection rotation). +// It is intentionally decoupled from the tunnel/app layers. +type TableSet struct { + Tables []*Table +} + +// NewTableSet builds one or more tables from key/mode and a list of custom X/P/V patterns. +// If patterns is empty, it builds a single default table (customPattern=""). +func NewTableSet(key string, mode string, patterns []string) (*TableSet, error) { + if len(patterns) == 0 { + t, err := NewTableWithCustom(key, mode, "") + if err != nil { + return nil, err + } + return &TableSet{Tables: []*Table{t}}, nil + } + + tables := make([]*Table, 0, len(patterns)) + for i, pattern := range patterns { + t, err := NewTableWithCustom(key, mode, pattern) + if err != nil { + return nil, fmt.Errorf("build table[%d] (%q): %w", i, pattern, err) + } + tables = append(tables, t) + } + return &TableSet{Tables: tables}, nil +} + +func (ts *TableSet) Candidates() []*Table { + if ts == nil { + return nil + } + return ts.Tables +} diff --git a/clash-meta/transport/sudoku/obfs_writer.go b/clash-meta/transport/sudoku/obfs_writer.go index f980359119..3dc94b4eba 100644 --- a/clash-meta/transport/sudoku/obfs_writer.go +++ b/clash-meta/transport/sudoku/obfs_writer.go @@ -6,7 +6,7 @@ import ( "math/rand" "net" - "github.com/saba-futai/sudoku/pkg/obfs/sudoku" + "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" ) // perm4 matches github.com/saba-futai/sudoku/pkg/obfs/sudoku perm4. diff --git a/clash-meta/transport/sudoku/table_probe.go b/clash-meta/transport/sudoku/table_probe.go index f12c172226..8def6fd488 100644 --- a/clash-meta/transport/sudoku/table_probe.go +++ b/clash-meta/transport/sudoku/table_probe.go @@ -10,26 +10,12 @@ import ( "net" "time" - "github.com/saba-futai/sudoku/apis" - "github.com/saba-futai/sudoku/pkg/crypto" - "github.com/saba-futai/sudoku/pkg/obfs/sudoku" + "github.com/metacubex/mihomo/transport/sudoku/crypto" + "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" ) -func tableCandidates(cfg *apis.ProtocolConfig) []*sudoku.Table { - if cfg == nil { - return nil - } - if len(cfg.Tables) > 0 { - return cfg.Tables - } - if cfg.Table != nil { - return []*sudoku.Table{cfg.Table} - } - return nil -} - -func pickClientTable(cfg *apis.ProtocolConfig) (*sudoku.Table, byte, error) { - candidates := tableCandidates(cfg) +func pickClientTable(cfg *ProtocolConfig) (*sudoku.Table, byte, error) { + candidates := cfg.tableCandidates() if len(candidates) == 0 { return nil, 0, fmt.Errorf("no table configured") } @@ -62,7 +48,7 @@ func drainBuffered(r *bufio.Reader) ([]byte, error) { return out, err } -func probeHandshakeBytes(probe []byte, cfg *apis.ProtocolConfig, table *sudoku.Table) error { +func probeHandshakeBytes(probe []byte, cfg *ProtocolConfig, table *sudoku.Table) error { rc := &readOnlyConn{Reader: bytes.NewReader(probe)} _, obfsConn := buildServerObfsConn(rc, cfg, table, false) cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod) @@ -90,7 +76,7 @@ func probeHandshakeBytes(probe []byte, cfg *apis.ProtocolConfig, table *sudoku.T return nil } -func selectTableByProbe(r *bufio.Reader, cfg *apis.ProtocolConfig, tables []*sudoku.Table) (*sudoku.Table, []byte, error) { +func selectTableByProbe(r *bufio.Reader, cfg *ProtocolConfig, tables []*sudoku.Table) (*sudoku.Table, []byte, error) { const ( maxProbeBytes = 64 * 1024 readChunk = 4 * 1024 diff --git a/clash-meta/transport/sudoku/tables.go b/clash-meta/transport/sudoku/tables.go index 429a4ab327..2630ea5256 100644 --- a/clash-meta/transport/sudoku/tables.go +++ b/clash-meta/transport/sudoku/tables.go @@ -3,7 +3,7 @@ package sudoku import ( "strings" - "github.com/saba-futai/sudoku/pkg/obfs/sudoku" + "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" ) // NewTablesWithCustomPatterns builds one or more obfuscation tables from x/v/p custom patterns. diff --git a/clash-nyanpasu/backend/Cargo.lock b/clash-nyanpasu/backend/Cargo.lock index 636dc4bc94..2fe58e4f60 100644 --- a/clash-nyanpasu/backend/Cargo.lock +++ b/clash-nyanpasu/backend/Cargo.lock @@ -2344,7 +2344,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7716,9 +7716,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.12.26" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", @@ -7938,7 +7938,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8243,9 +8243,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.146" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "217ca874ae0207aac254aa02c957ded05585a90892cc8d87f9e5fa49669dadd8" dependencies = [ "indexmap 2.12.1", "itoa", @@ -9607,7 +9607,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.0.8", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/clash-nyanpasu/locales/en.json b/clash-nyanpasu/backend/tauri/locales/en.json similarity index 100% rename from clash-nyanpasu/locales/en.json rename to clash-nyanpasu/backend/tauri/locales/en.json diff --git a/clash-nyanpasu/locales/ru.json b/clash-nyanpasu/backend/tauri/locales/ru.json similarity index 100% rename from clash-nyanpasu/locales/ru.json rename to clash-nyanpasu/backend/tauri/locales/ru.json diff --git a/clash-nyanpasu/locales/zh-CN.json b/clash-nyanpasu/backend/tauri/locales/zh-cn.json similarity index 100% rename from clash-nyanpasu/locales/zh-CN.json rename to clash-nyanpasu/backend/tauri/locales/zh-cn.json diff --git a/clash-nyanpasu/locales/zh-TW.json b/clash-nyanpasu/backend/tauri/locales/zh-tw.json similarity index 100% rename from clash-nyanpasu/locales/zh-TW.json rename to clash-nyanpasu/backend/tauri/locales/zh-tw.json diff --git a/clash-nyanpasu/backend/tauri/src/lib.rs b/clash-nyanpasu/backend/tauri/src/lib.rs index c438265685..517a63cb32 100644 --- a/clash-nyanpasu/backend/tauri/src/lib.rs +++ b/clash-nyanpasu/backend/tauri/src/lib.rs @@ -36,7 +36,7 @@ use tauri::{Emitter, Manager}; use tauri_specta::{collect_commands, collect_events}; use utils::resolve::{is_window_opened, reset_window_open_counter}; -rust_i18n::i18n!("../../locales"); +rust_i18n::i18n!("./locales"); #[cfg(feature = "deadlock-detection")] fn deadlock_detection() { @@ -110,7 +110,7 @@ pub fn run() -> std::io::Result<()> { let locale = utils::help::get_system_locale(); utils::help::mapping_to_i18n_key(&locale) }; - rust_i18n::set_locale(locale); + rust_i18n::set_locale(locale.to_lowercase().as_str()); if single_instance_result .as_ref() @@ -313,7 +313,7 @@ pub fn run() -> std::io::Result<()> { } let verge = { Config::verge().latest().language.clone().unwrap() }; - rust_i18n::set_locale(verge.as_str()); + rust_i18n::set_locale(verge.to_lowercase().as_str()); // show a dialog to print the single instance error // Hold the guard until the end of the program if acquired diff --git a/clash-nyanpasu/frontend/nyanpasu/package.json b/clash-nyanpasu/frontend/nyanpasu/package.json index c859e0516d..8edd98c26e 100644 --- a/clash-nyanpasu/frontend/nyanpasu/package.json +++ b/clash-nyanpasu/frontend/nyanpasu/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-use-controllable-state": "1.2.2", "@tailwindcss/postcss": "4.1.17", + "@tanstack/react-virtual": "^3.13.13", "@tanstack/router-zod-adapter": "1.81.5", "@tauri-apps/api": "2.8.0", "@types/json-schema": "7.0.15", @@ -69,7 +70,7 @@ "@csstools/normalize.css": "12.1.1", "@emotion/babel-plugin": "11.13.5", "@emotion/react": "11.14.0", - "@iconify/json": "2.2.420", + "@iconify/json": "2.2.421", "@monaco-editor/react": "4.7.0", "@tanstack/react-query": "5.90.12", "@tanstack/react-router": "1.134.15", diff --git a/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/experimental/logs/_modules/log-level-badge.tsx b/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/experimental/logs/_modules/log-level-badge.tsx new file mode 100644 index 0000000000..eaac1fe5e6 --- /dev/null +++ b/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/experimental/logs/_modules/log-level-badge.tsx @@ -0,0 +1,22 @@ +import { ComponentProps } from 'react' +import { cn } from '@nyanpasu/ui' + +export default function LogLevelBadge({ + className, + ...props +}: ComponentProps<'div'> & { children: string }) { + const childrenLower = props.children?.toLowerCase() + + return ( +
+ ) +} diff --git a/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/experimental/logs/route.tsx b/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/experimental/logs/route.tsx index ad64d824aa..5555ea7a4a 100644 --- a/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/experimental/logs/route.tsx +++ b/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/experimental/logs/route.tsx @@ -1,9 +1,94 @@ +import { useEffect } from 'react' +import { + AppContentScrollArea, + useScrollArea, +} from '@/components/ui/scroll-area' +import { useClashLogs } from '@nyanpasu/interface' +import { cn } from '@nyanpasu/ui' import { createFileRoute } from '@tanstack/react-router' +import { useVirtualizer } from '@tanstack/react-virtual' +import LogLevelBadge from './_modules/log-level-badge' export const Route = createFileRoute('/(experimental)/experimental/logs')({ component: RouteComponent, }) -function RouteComponent() { - return
Hello "/(experimental)/experimental/logs"!
+const InnerComponent = () => { + const { + query: { data: logs }, + } = useClashLogs() + + const { isBottom, viewportRef } = useScrollArea() + + const rowVirtualizer = useVirtualizer({ + count: logs?.length || 0, + getScrollElement: () => viewportRef.current, + estimateSize: () => 60, + overscan: 5, + measureElement: (element) => element?.getBoundingClientRect().height, + }) + + const virtualItems = rowVirtualizer.getVirtualItems() + + useEffect(() => { + if (isBottom && logs && logs.length > 0) { + rowVirtualizer.scrollToIndex(logs.length - 1, { + align: 'end', + behavior: 'smooth', + }) + } + }, [logs, isBottom, rowVirtualizer]) + + return ( +
+ {virtualItems.map((virtualItem) => { + const log = logs?.[virtualItem.index] + + if (!log) { + return null + } + + return ( +
+
+ {log.time} + {log.type} +
+ +
{log.payload}
+
+ ) + })} +
+ ) +} + +function RouteComponent() { + return ( + + + + ) } diff --git a/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/experimental/rules/route.tsx b/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/experimental/rules/route.tsx index 373a6f9e2e..1aaeb0bfc2 100644 --- a/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/experimental/rules/route.tsx +++ b/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/experimental/rules/route.tsx @@ -1,9 +1,85 @@ +import { + AppContentScrollArea, + useScrollArea, +} from '@/components/ui/scroll-area' +import { useClashRules } from '@nyanpasu/interface' +import { cn } from '@nyanpasu/ui' import { createFileRoute } from '@tanstack/react-router' +import { useVirtualizer } from '@tanstack/react-virtual' export const Route = createFileRoute('/(experimental)/experimental/rules')({ component: RouteComponent, }) -function RouteComponent() { - return
Hello "/(experimental)/experimental/rules"!
+const InnerComponent = () => { + const { data } = useClashRules() + + const rules = data?.rules + + const { viewportRef } = useScrollArea() + + const rowVirtualizer = useVirtualizer({ + count: rules?.length || 0, + getScrollElement: () => viewportRef.current, + estimateSize: () => 60, + overscan: 5, + measureElement: (element) => element?.getBoundingClientRect().height, + }) + + const virtualItems = rowVirtualizer.getVirtualItems() + + return ( +
+ {virtualItems.map((virtualItem) => { + const rule = rules?.[virtualItem.index] + + if (!rule) { + return null + } + + return ( +
+
{virtualItem.index + 1}
+ +
+
{rule.payload || '-'}
+ +
+
{rule.type}
+ +
{rule.proxy}
+
+
+
+ ) + })} +
+ ) +} + +function RouteComponent() { + return ( + + + + ) } diff --git a/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/route.tsx b/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/route.tsx index 661073eb68..13959311db 100644 --- a/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/route.tsx +++ b/clash-nyanpasu/frontend/nyanpasu/src/pages/(experimental)/route.tsx @@ -24,7 +24,7 @@ const AppContent = () => { function RouteComponent() { return (
{ e.preventDefault() }} diff --git a/clash-nyanpasu/package.json b/clash-nyanpasu/package.json index 2a4020277e..be017f1290 100644 --- a/clash-nyanpasu/package.json +++ b/clash-nyanpasu/package.json @@ -67,8 +67,8 @@ "@types/fs-extra": "11.0.4", "@types/lodash-es": "4.17.12", "@types/node": "24.10.4", - "@typescript-eslint/eslint-plugin": "8.50.0", - "@typescript-eslint/parser": "8.50.0", + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", "autoprefixer": "10.4.23", "conventional-changelog-conventionalcommits": "9.1.0", "cross-env": "10.1.0", @@ -107,7 +107,7 @@ "tailwindcss": "4.1.17", "tsx": "4.21.0", "typescript": "5.9.3", - "typescript-eslint": "8.50.0" + "typescript-eslint": "8.50.1" }, "packageManager": "pnpm@10.26.1", "engines": { diff --git a/clash-nyanpasu/pnpm-lock.yaml b/clash-nyanpasu/pnpm-lock.yaml index 5c339fa644..a5ace395ab 100644 --- a/clash-nyanpasu/pnpm-lock.yaml +++ b/clash-nyanpasu/pnpm-lock.yaml @@ -50,11 +50,11 @@ importers: specifier: 24.10.4 version: 24.10.4 '@typescript-eslint/eslint-plugin': - specifier: 8.50.0 - version: 8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.50.1 + version: 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: 8.50.0 - version: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.50.1 + version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) autoprefixer: specifier: 10.4.23 version: 10.4.23(postcss@8.5.6) @@ -75,13 +75,13 @@ importers: version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-import-resolver-alias: specifier: 1.1.2 - version: 1.1.2(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))) + version: 1.1.2(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))) eslint-plugin-html: specifier: 8.1.3 version: 8.1.3 eslint-plugin-import: specifier: 2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) + version: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-n: specifier: 17.23.1 version: 17.23.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -111,7 +111,7 @@ importers: version: 16.2.7 neostandard: specifier: 0.12.2 - version: 0.12.2(@typescript-eslint/utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 0.12.2(@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) npm-run-all2: specifier: 8.0.4 version: 8.0.4 @@ -170,8 +170,8 @@ importers: specifier: 5.9.3 version: 5.9.3 typescript-eslint: - specifier: 8.50.0 - version: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.50.1 + version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) frontend/interface: dependencies: @@ -275,6 +275,9 @@ importers: '@tailwindcss/postcss': specifier: 4.1.17 version: 4.1.17 + '@tanstack/react-virtual': + specifier: ^3.13.13 + version: 3.13.13(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/router-zod-adapter': specifier: 1.81.5 version: 1.81.5(@tanstack/react-router@1.134.15(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(zod@4.1.13) @@ -382,8 +385,8 @@ importers: specifier: 11.14.0 version: 11.14.0(@types/react@19.2.7)(react@19.2.0) '@iconify/json': - specifier: 2.2.420 - version: 2.2.420 + specifier: 2.2.421 + version: 2.2.421 '@monaco-editor/react': specifier: 4.7.0 version: 4.7.0(monaco-editor@0.54.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -605,8 +608,8 @@ importers: specifier: 11.0.13 version: 11.0.13 p-retry: - specifier: 7.1.0 - version: 7.1.0 + specifier: 7.1.1 + version: 7.1.1 semver: specifier: 7.7.3 version: 7.7.3 @@ -1906,8 +1909,8 @@ packages: prettier-plugin-ember-template-tag: optional: true - '@iconify/json@2.2.420': - resolution: {integrity: sha512-3dgvkB8eiUA/PkGHaRB+x17MS1u22V2O1YtFpdpdFpbXp/fAdloOMbxeMXXgNlcGgjk7x4hUIeDunic/diHYqg==} + '@iconify/json@2.2.421': + resolution: {integrity: sha512-jc0BOjmdVz4DxfYlvJWEBl8FIdOtgRCDZkyOEDFlHqCnfw6Yyr41fDaiECU/FSzkqjwNuKu5H10N7bLN+JPQGA==} '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -3537,6 +3540,12 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.13': + resolution: {integrity: sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.13.9': resolution: {integrity: sha512-SPWC8kwG/dWBf7Py7cfheAPOxuvIv4fFQ54PdmYbg7CpXfsKxkucak43Q0qKsxVthhUJQ1A7CIMAIplq4BjVwA==} peerDependencies: @@ -3602,6 +3611,9 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@tanstack/virtual-core@3.13.13': + resolution: {integrity: sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==} + '@tanstack/virtual-core@3.13.9': resolution: {integrity: sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ==} @@ -3942,16 +3954,16 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.50.0': - resolution: {integrity: sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==} + '@typescript-eslint/eslint-plugin@8.50.1': + resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.50.0 + '@typescript-eslint/parser': ^8.50.1 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.50.0': - resolution: {integrity: sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==} + '@typescript-eslint/parser@8.50.1': + resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -3963,8 +3975,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.50.0': - resolution: {integrity: sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==} + '@typescript-eslint/project-service@8.50.1': + resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' @@ -3973,8 +3985,8 @@ packages: resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.50.0': - resolution: {integrity: sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==} + '@typescript-eslint/scope-manager@8.50.1': + resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.46.2': @@ -3983,20 +3995,20 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.46.3': - resolution: {integrity: sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.50.0': resolution: {integrity: sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.50.0': - resolution: {integrity: sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==} + '@typescript-eslint/tsconfig-utils@8.50.1': + resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.50.1': + resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -4010,22 +4022,22 @@ packages: resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.46.3': - resolution: {integrity: sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.50.0': resolution: {integrity: sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.50.1': + resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.46.2': resolution: {integrity: sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.50.0': - resolution: {integrity: sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==} + '@typescript-eslint/typescript-estree@8.50.1': + resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' @@ -4037,8 +4049,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.50.0': - resolution: {integrity: sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==} + '@typescript-eslint/utils@8.50.1': + resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -4048,8 +4060,8 @@ packages: resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.50.0': - resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==} + '@typescript-eslint/visitor-keys@8.50.1': + resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@uidotdev/usehooks@2.4.1': @@ -7135,8 +7147,8 @@ packages: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - p-retry@7.1.0: - resolution: {integrity: sha512-xL4PiFRQa/f9L9ZvR4/gUCRNus4N8YX80ku8kv9Jqz+ZokkiZLM0bcvX0gm1F3PDi9SPRsww1BDsTWgE6Y1GLQ==} + p-retry@7.1.1: + resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} engines: {node: '>=20'} package-manager-detector@1.3.0: @@ -8421,8 +8433,8 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} - typescript-eslint@8.50.0: - resolution: {integrity: sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==} + typescript-eslint@8.50.1: + resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -10535,7 +10547,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@iconify/json@2.2.420': + '@iconify/json@2.2.421': dependencies: '@iconify/types': 2.0.0 pathe: 2.0.3 @@ -12111,6 +12123,12 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + '@tanstack/react-virtual@3.13.13(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/virtual-core': 3.13.13 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + '@tanstack/react-virtual@3.13.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@tanstack/virtual-core': 3.13.9 @@ -12207,6 +12225,8 @@ snapshots: '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.13.13': {} + '@tanstack/virtual-core@3.13.9': {} '@tanstack/virtual-file-routes@1.133.19': {} @@ -12562,14 +12582,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.50.0 - '@typescript-eslint/type-utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.50.1 + '@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.50.1 eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -12578,12 +12598,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.50.0 - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/scope-manager': 8.50.1 + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.50.1 debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 @@ -12592,17 +12612,17 @@ snapshots: '@typescript-eslint/project-service@8.46.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.3(typescript@5.9.3) - '@typescript-eslint/types': 8.46.3 + '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) + '@typescript-eslint/types': 8.50.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.50.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.50.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) - '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) + '@typescript-eslint/types': 8.50.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -12613,28 +12633,28 @@ snapshots: '@typescript-eslint/types': 8.46.2 '@typescript-eslint/visitor-keys': 8.46.2 - '@typescript-eslint/scope-manager@8.50.0': + '@typescript-eslint/scope-manager@8.50.1': dependencies: - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/visitor-keys': 8.50.1 '@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.46.3(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.50.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) @@ -12646,10 +12666,10 @@ snapshots: '@typescript-eslint/types@8.46.2': {} - '@typescript-eslint/types@8.46.3': {} - '@typescript-eslint/types@8.50.0': {} + '@typescript-eslint/types@8.50.1': {} + '@typescript-eslint/typescript-estree@8.46.2(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.46.2(typescript@5.9.3) @@ -12666,12 +12686,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.50.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.50.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/project-service': 8.50.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/visitor-keys': 8.50.1 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.3 @@ -12692,12 +12712,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.8.0(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.50.0 - '@typescript-eslint/types': 8.50.0 - '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.50.1 + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -12708,9 +12728,9 @@ snapshots: '@typescript-eslint/types': 8.46.2 eslint-visitor-keys: 4.2.1 - '@typescript-eslint/visitor-keys@8.50.0': + '@typescript-eslint/visitor-keys@8.50.1': dependencies: - '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/types': 8.50.1 eslint-visitor-keys: 4.2.1 '@uidotdev/usehooks@2.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': @@ -14419,9 +14439,9 @@ snapshots: optionalDependencies: unrs-resolver: 1.10.1 - eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))): + eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))): dependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) eslint-import-resolver-node@0.3.9: dependencies: @@ -14431,7 +14451,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -14442,16 +14462,16 @@ snapshots: tinyglobby: 0.2.14 unrs-resolver: 1.10.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: @@ -14468,7 +14488,7 @@ snapshots: dependencies: htmlparser2: 10.0.0 - eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@typescript-eslint/types': 8.41.0 comment-parser: 1.4.1 @@ -14481,12 +14501,12 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.10.1 optionalDependencies: - '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -14497,7 +14517,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -14509,7 +14529,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -16107,20 +16127,20 @@ snapshots: sax: 1.3.0 optional: true - neostandard@0.12.2(@typescript-eslint/utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + neostandard@0.12.2(@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@humanwhocodes/gitignore-to-minimatch': 1.0.2 '@stylistic/eslint-plugin': 2.11.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-n: 17.23.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-promise: 7.2.1(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) find-up: 5.0.0 globals: 15.15.0 peowly: 1.3.2 - typescript-eslint: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - '@typescript-eslint/utils' - eslint-import-resolver-node @@ -16340,7 +16360,7 @@ snapshots: dependencies: p-limit: 4.0.0 - p-retry@7.1.0: + p-retry@7.1.1: dependencies: is-network-error: 1.1.0 @@ -17694,12 +17714,12 @@ snapshots: dependencies: is-typedarray: 1.0.0 - typescript-eslint@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: diff --git a/clash-nyanpasu/scripts/package.json b/clash-nyanpasu/scripts/package.json index fcde64aa22..2767d352c8 100644 --- a/clash-nyanpasu/scripts/package.json +++ b/clash-nyanpasu/scripts/package.json @@ -8,7 +8,7 @@ "@types/semver": "7.7.1", "figlet": "1.9.4", "filesize": "11.0.13", - "p-retry": "7.1.0", + "p-retry": "7.1.1", "semver": "7.7.3", "zod": "4.1.13" }, diff --git a/lede/include/kernel-6.12 b/lede/include/kernel-6.12 index 0a9808b6be..463c22204a 100644 --- a/lede/include/kernel-6.12 +++ b/lede/include/kernel-6.12 @@ -1,2 +1,2 @@ -LINUX_VERSION-6.12 = .62 -LINUX_KERNEL_HASH-6.12.62 = 13e2c685ac8fab5dd992dd105732554dae514aef350c2a8c7418e7b74eb62c13 +LINUX_VERSION-6.12 = .63 +LINUX_KERNEL_HASH-6.12.63 = 9502c5ffe4b894383c97abfccf74430a84732f04ee476b9c0d87635b29df7db3 diff --git a/lede/target/linux/bcm27xx/patches-6.12/950-0073-ASoC-Add-support-for-all-the-downstream-rpi-sound-ca.patch b/lede/target/linux/bcm27xx/patches-6.12/950-0073-ASoC-Add-support-for-all-the-downstream-rpi-sound-ca.patch index 61b880bb43..3bf4122503 100644 --- a/lede/target/linux/bcm27xx/patches-6.12/950-0073-ASoC-Add-support-for-all-the-downstream-rpi-sound-ca.patch +++ b/lede/target/linux/bcm27xx/patches-6.12/950-0073-ASoC-Add-support-for-all-the-downstream-rpi-sound-ca.patch @@ -15556,7 +15556,7 @@ Signed-off-by: j-schambacher imply SND_SOC_MAX98088 imply SND_SOC_MAX98090 imply SND_SOC_MAX98095 -@@ -175,6 +177,7 @@ config SND_SOC_ALL_CODECS +@@ -176,6 +178,7 @@ config SND_SOC_ALL_CODECS imply SND_SOC_PCM179X_SPI imply SND_SOC_PCM186X_I2C imply SND_SOC_PCM186X_SPI @@ -15564,7 +15564,7 @@ Signed-off-by: j-schambacher imply SND_SOC_PCM3008 imply SND_SOC_PCM3060_I2C imply SND_SOC_PCM3060_SPI -@@ -267,6 +270,7 @@ config SND_SOC_ALL_CODECS +@@ -268,6 +271,7 @@ config SND_SOC_ALL_CODECS imply SND_SOC_TLV320ADCX140 imply SND_SOC_TLV320AIC23_I2C imply SND_SOC_TLV320AIC23_SPI @@ -15572,7 +15572,7 @@ Signed-off-by: j-schambacher imply SND_SOC_TLV320AIC26 imply SND_SOC_TLV320AIC31XX imply SND_SOC_TLV320AIC32X4_I2C -@@ -424,12 +428,12 @@ config SND_SOC_AD193X +@@ -425,12 +429,12 @@ config SND_SOC_AD193X tristate config SND_SOC_AD193X_SPI @@ -15587,7 +15587,7 @@ Signed-off-by: j-schambacher depends on I2C select SND_SOC_AD193X -@@ -1229,6 +1233,13 @@ config SND_SOC_LOCHNAGAR_SC +@@ -1230,6 +1234,13 @@ config SND_SOC_LOCHNAGAR_SC This driver support the sound card functionality of the Cirrus Logic Lochnagar audio development board. @@ -15601,7 +15601,7 @@ Signed-off-by: j-schambacher config SND_SOC_MADERA tristate default y if SND_SOC_CS47L15=y -@@ -1638,6 +1649,10 @@ config SND_SOC_RT5616 +@@ -1639,6 +1650,10 @@ config SND_SOC_RT5616 tristate "Realtek RT5616 CODEC" depends on I2C @@ -15612,7 +15612,7 @@ Signed-off-by: j-schambacher config SND_SOC_RT5631 tristate "Realtek ALC5631/RT5631 CODEC" depends on I2C -@@ -1995,6 +2010,9 @@ config SND_SOC_TFA9879 +@@ -1996,6 +2011,9 @@ config SND_SOC_TFA9879 tristate "NXP Semiconductors TFA9879 amplifier" depends on I2C @@ -15622,7 +15622,7 @@ Signed-off-by: j-schambacher config SND_SOC_TFA989X tristate "NXP/Goodix TFA989X (TFA1) amplifiers" depends on I2C -@@ -2596,4 +2614,8 @@ config SND_SOC_LPASS_TX_MACRO +@@ -2601,4 +2619,8 @@ config SND_SOC_LPASS_TX_MACRO select SND_SOC_LPASS_MACRO_COMMON tristate "Qualcomm TX Macro in LPASS(Low Power Audio SubSystem)" @@ -15633,7 +15633,7 @@ Signed-off-by: j-schambacher endmenu --- a/sound/soc/codecs/Makefile +++ b/sound/soc/codecs/Makefile -@@ -820,3 +820,12 @@ obj-$(CONFIG_SND_SOC_LPASS_TX_MACRO) += +@@ -822,3 +822,12 @@ obj-$(CONFIG_SND_SOC_LPASS_TX_MACRO) += # Mux obj-$(CONFIG_SND_SOC_SIMPLE_MUX) += snd-soc-simple-mux.o diff --git a/lede/target/linux/bcm27xx/patches-6.12/950-0348-usb-dwc3-Set-DMA-and-coherent-masks-early.patch b/lede/target/linux/bcm27xx/patches-6.12/950-0348-usb-dwc3-Set-DMA-and-coherent-masks-early.patch index 97030141eb..d93a73f7f6 100644 --- a/lede/target/linux/bcm27xx/patches-6.12/950-0348-usb-dwc3-Set-DMA-and-coherent-masks-early.patch +++ b/lede/target/linux/bcm27xx/patches-6.12/950-0348-usb-dwc3-Set-DMA-and-coherent-masks-early.patch @@ -351,7 +351,7 @@ Signed-off-by: Jonathan Bell --- a/drivers/usb/dwc3/host.c +++ b/drivers/usb/dwc3/host.c -@@ -126,10 +126,12 @@ out: +@@ -129,10 +129,12 @@ out: int dwc3_host_init(struct dwc3 *dwc) { @@ -364,7 +364,7 @@ Signed-off-by: Jonathan Bell /* * Some platforms need to power off all Root hub ports immediately after DWC3 set to host -@@ -141,7 +143,12 @@ int dwc3_host_init(struct dwc3 *dwc) +@@ -144,7 +146,12 @@ int dwc3_host_init(struct dwc3 *dwc) if (irq < 0) return irq; diff --git a/lede/target/linux/bcm27xx/patches-6.12/950-0380-irqchip-irq-brcmstb-l2-Add-config-for-2711-controlle.patch b/lede/target/linux/bcm27xx/patches-6.12/950-0380-irqchip-irq-brcmstb-l2-Add-config-for-2711-controlle.patch index b203e7a341..5909f5d66b 100644 --- a/lede/target/linux/bcm27xx/patches-6.12/950-0380-irqchip-irq-brcmstb-l2-Add-config-for-2711-controlle.patch +++ b/lede/target/linux/bcm27xx/patches-6.12/950-0380-irqchip-irq-brcmstb-l2-Add-config-for-2711-controlle.patch @@ -52,7 +52,7 @@ Signed-off-by: Dom Cobley /* L2 intc private data structure */ struct brcmstb_l2_intc_data { struct irq_domain *domain; -@@ -299,11 +309,18 @@ static int __init brcmstb_l2_lvl_intc_of +@@ -295,11 +305,18 @@ static int brcmstb_l2_lvl_intc_of_init(s return brcmstb_l2_intc_of_init(np, parent, &l2_lvl_intc_init); } diff --git a/lede/target/linux/generic/config-6.12 b/lede/target/linux/generic/config-6.12 index c013e17558..8cbb53735c 100644 --- a/lede/target/linux/generic/config-6.12 +++ b/lede/target/linux/generic/config-6.12 @@ -6225,6 +6225,7 @@ CONFIG_SND_SOC_INTEL_SST_TOPLEVEL=y # CONFIG_SND_SOC_MT8365 is not set # CONFIG_SND_SOC_MTK_BTCVSD is not set # CONFIG_SND_SOC_NAU8315 is not set +# CONFIG_SND_SOC_NAU8325 is not set # CONFIG_SND_SOC_NAU8540 is not set # CONFIG_SND_SOC_NAU8810 is not set # CONFIG_SND_SOC_NAU8821 is not set diff --git a/lede/target/linux/generic/hack-6.12/902-debloat_proc.patch b/lede/target/linux/generic/hack-6.12/902-debloat_proc.patch index 5b344a1982..4b74ad24fb 100644 --- a/lede/target/linux/generic/hack-6.12/902-debloat_proc.patch +++ b/lede/target/linux/generic/hack-6.12/902-debloat_proc.patch @@ -409,7 +409,7 @@ Signed-off-by: Felix Fietkau --- a/net/ipv4/inet_timewait_sock.c +++ b/net/ipv4/inet_timewait_sock.c -@@ -296,7 +296,7 @@ void __inet_twsk_schedule(struct inet_ti +@@ -285,7 +285,7 @@ void __inet_twsk_schedule(struct inet_ti */ if (!rearm) { diff --git a/lede/target/linux/generic/pending-6.12/700-netfilter-nft_flow_offload-handle-netdevice-events-f.patch b/lede/target/linux/generic/pending-6.12/700-netfilter-nft_flow_offload-handle-netdevice-events-f.patch index 301defb49a..59868b2a53 100644 --- a/lede/target/linux/generic/pending-6.12/700-netfilter-nft_flow_offload-handle-netdevice-events-f.patch +++ b/lede/target/linux/generic/pending-6.12/700-netfilter-nft_flow_offload-handle-netdevice-events-f.patch @@ -55,7 +55,7 @@ Signed-off-by: Pablo Neira Ayuso } --- a/net/netfilter/nft_flow_offload.c +++ b/net/netfilter/nft_flow_offload.c -@@ -487,47 +487,14 @@ static struct nft_expr_type nft_flow_off +@@ -494,47 +494,14 @@ static struct nft_expr_type nft_flow_off .owner = THIS_MODULE, }; diff --git a/lede/target/linux/qualcommax/patches-6.12/0113-remoteproc-qcom-Add-secure-PIL-support.patch b/lede/target/linux/qualcommax/patches-6.12/0113-remoteproc-qcom-Add-secure-PIL-support.patch index fc35733fa2..6b11f04808 100644 --- a/lede/target/linux/qualcommax/patches-6.12/0113-remoteproc-qcom-Add-secure-PIL-support.patch +++ b/lede/target/linux/qualcommax/patches-6.12/0113-remoteproc-qcom-Add-secure-PIL-support.patch @@ -25,7 +25,7 @@ Signed-off-by: Nikhil Prakash V @@ -86,6 +87,9 @@ #define TCSR_WCSS_CLK_ENABLE 0x14 - #define MAX_HALT_REG 3 + #define MAX_HALT_REG 4 + +#define WCNSS_PAS_ID 6 + diff --git a/lede/target/linux/qualcommax/patches-6.12/0116-remoteproc-qcom-Update-regmap-offsets-for-halt-regis.patch b/lede/target/linux/qualcommax/patches-6.12/0116-remoteproc-qcom-Update-regmap-offsets-for-halt-regis.patch index 7989dfa5c9..c9795b8028 100644 --- a/lede/target/linux/qualcommax/patches-6.12/0116-remoteproc-qcom-Update-regmap-offsets-for-halt-regis.patch +++ b/lede/target/linux/qualcommax/patches-6.12/0116-remoteproc-qcom-Update-regmap-offsets-for-halt-regis.patch @@ -13,15 +13,6 @@ Signed-off-by: Sricharan R --- a/drivers/remoteproc/qcom_q6v5_wcss.c +++ b/drivers/remoteproc/qcom_q6v5_wcss.c -@@ -86,7 +86,7 @@ - #define TCSR_WCSS_CLK_MASK 0x1F - #define TCSR_WCSS_CLK_ENABLE 0x14 - --#define MAX_HALT_REG 3 -+#define MAX_HALT_REG 4 - - #define WCNSS_PAS_ID 6 - @@ -155,6 +155,7 @@ struct wcss_data { u32 version; bool aon_reset_required; @@ -48,19 +39,6 @@ Signed-off-by: Sricharan R } return 0; -@@ -929,9 +933,9 @@ static int q6v5_wcss_init_mmio(struct q6 - return -EINVAL; - } - -- wcss->halt_q6 = halt_reg[0]; -- wcss->halt_wcss = halt_reg[1]; -- wcss->halt_nc = halt_reg[2]; -+ wcss->halt_q6 = halt_reg[1]; -+ wcss->halt_wcss = halt_reg[2]; -+ wcss->halt_nc = halt_reg[3]; - - return 0; - } @@ -1173,6 +1177,7 @@ static const struct wcss_data wcss_ipq80 .crash_reason_smem = WCSS_CRASH_REASON, .aon_reset_required = true, diff --git a/mihomo/adapter/outbound/sudoku.go b/mihomo/adapter/outbound/sudoku.go index bd393ec616..b1063f0468 100644 --- a/mihomo/adapter/outbound/sudoku.go +++ b/mihomo/adapter/outbound/sudoku.go @@ -30,6 +30,9 @@ type SudokuOption struct { TableType string `proxy:"table-type,omitempty"` // "prefer_ascii" or "prefer_entropy" EnablePureDownlink *bool `proxy:"enable-pure-downlink,omitempty"` HTTPMask bool `proxy:"http-mask,omitempty"` + HTTPMaskMode string `proxy:"http-mask-mode,omitempty"` // "legacy" (default), "stream", "poll", "auto" + HTTPMaskTLS bool `proxy:"http-mask-tls,omitempty"` // only for http-mask-mode stream/poll/auto + HTTPMaskHost string `proxy:"http-mask-host,omitempty"` // optional Host/SNI override (domain or domain:port) HTTPMaskStrategy string `proxy:"http-mask-strategy,omitempty"` // "random" (default), "post", "websocket" CustomTable string `proxy:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv CustomTables []string `proxy:"custom-tables,omitempty"` // optional table rotation patterns, overrides custom-table when non-empty @@ -42,7 +45,16 @@ func (s *Sudoku) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Con return nil, err } - c, err := s.dialer.DialContext(ctx, "tcp", s.addr) + var c net.Conn + if !cfg.DisableHTTPMask { + switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { + case "stream", "poll", "auto": + c, err = sudoku.DialHTTPMaskTunnel(ctx, cfg.ServerAddress, cfg, s.dialer.DialContext) + } + } + if c == nil && err == nil { + c, err = s.dialer.DialContext(ctx, "tcp", s.addr) + } if err != nil { return nil, fmt.Errorf("%s connect error: %w", s.addr, err) } @@ -56,9 +68,14 @@ func (s *Sudoku) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Con defer done(&err) } - c, err = sudoku.ClientHandshakeWithOptions(c, cfg, sudoku.ClientHandshakeOptions{ - HTTPMaskStrategy: s.option.HTTPMaskStrategy, - }) + handshakeCfg := *cfg + if !handshakeCfg.DisableHTTPMask { + switch strings.ToLower(strings.TrimSpace(handshakeCfg.HTTPMaskMode)) { + case "stream", "poll", "auto": + handshakeCfg.DisableHTTPMask = true + } + } + c, err = sudoku.ClientHandshakeWithOptions(c, &handshakeCfg, sudoku.ClientHandshakeOptions{HTTPMaskStrategy: s.option.HTTPMaskStrategy}) if err != nil { return nil, err } @@ -87,7 +104,16 @@ func (s *Sudoku) ListenPacketContext(ctx context.Context, metadata *C.Metadata) return nil, err } - c, err := s.dialer.DialContext(ctx, "tcp", s.addr) + var c net.Conn + if !cfg.DisableHTTPMask { + switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { + case "stream", "poll", "auto": + c, err = sudoku.DialHTTPMaskTunnel(ctx, cfg.ServerAddress, cfg, s.dialer.DialContext) + } + } + if c == nil && err == nil { + c, err = s.dialer.DialContext(ctx, "tcp", s.addr) + } if err != nil { return nil, fmt.Errorf("%s connect error: %w", s.addr, err) } @@ -101,9 +127,14 @@ func (s *Sudoku) ListenPacketContext(ctx context.Context, metadata *C.Metadata) defer done(&err) } - c, err = sudoku.ClientHandshakeWithOptions(c, cfg, sudoku.ClientHandshakeOptions{ - HTTPMaskStrategy: s.option.HTTPMaskStrategy, - }) + handshakeCfg := *cfg + if !handshakeCfg.DisableHTTPMask { + switch strings.ToLower(strings.TrimSpace(handshakeCfg.HTTPMaskMode)) { + case "stream", "poll", "auto": + handshakeCfg.DisableHTTPMask = true + } + } + c, err = sudoku.ClientHandshakeWithOptions(c, &handshakeCfg, sudoku.ClientHandshakeOptions{HTTPMaskStrategy: s.option.HTTPMaskStrategy}) if err != nil { return nil, err } @@ -190,6 +221,12 @@ func NewSudoku(option SudokuOption) (*Sudoku, error) { EnablePureDownlink: enablePureDownlink, HandshakeTimeoutSeconds: defaultConf.HandshakeTimeoutSeconds, DisableHTTPMask: !option.HTTPMask, + HTTPMaskMode: defaultConf.HTTPMaskMode, + HTTPMaskTLSEnabled: option.HTTPMaskTLS, + HTTPMaskHost: option.HTTPMaskHost, + } + if option.HTTPMaskMode != "" { + baseConf.HTTPMaskMode = option.HTTPMaskMode } tables, err := sudoku.NewTablesWithCustomPatterns(sudoku.ClientAEADSeed(option.Key), tableType, option.CustomTable, option.CustomTables) if err != nil { diff --git a/mihomo/docs/config.yaml b/mihomo/docs/config.yaml index 04d15bd202..a350d1af70 100644 --- a/mihomo/docs/config.yaml +++ b/mihomo/docs/config.yaml @@ -1041,7 +1041,7 @@ proxies: # socks5 # sudoku - name: sudoku type: sudoku - server: serverip # 1.2.3.4 + server: server_ip/domain # 1.2.3.4 or domain port: 443 key: "" # 如果你使用sudoku生成的ED25519密钥对,请填写密钥对中的私钥,否则填入和服务端相同的uuid aead-method: chacha20-poly1305 # 可选值:chacha20-poly1305、aes-128-gcm、none 我们保证在none的情况下sudoku混淆层仍然确保安全 @@ -1051,7 +1051,10 @@ proxies: # socks5 # custom-table: xpxvvpvv # 可选,自定义字节布局,必须包含2个x、2个p、4个v,可随意组合。启用此处则需配置`table-type`为`prefer_entropy` # custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),用于 xvp 模式轮换;非空时覆盖 custom-table http-mask: true # 是否启用http掩码 - # http-mask-strategy: random # 可选:random(默认)、post、websocket;仅在 http-mask=true 时生效 + # http-mask-mode: legacy # 可选:legacy(默认)、stream、poll、auto;stream/poll/auto 支持走 CDN/反代 + # http-mask-tls: true # 可选:仅在 http-mask-mode 为 stream/poll/auto 时生效;true 强制 https;false 强制 http(不会根据端口自动推断) + # http-mask-host: "" # 可选:覆盖 Host/SNI(支持 example.com 或 example.com:443);仅在 http-mask-mode 为 stream/poll/auto 时生效 + # http-mask-strategy: random # 可选:random(默认)、post、websocket;仅 legacy 下生效 enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与服务端端保持相同(如果此处为false,则要求aead不可为none) # anytls @@ -1596,6 +1599,8 @@ listeners: # custom-tables: ["xpxvvpvv", "vxpvxvvp"] # 可选,自定义字节布局列表(x/v/p),用于 xvp 模式轮换;非空时覆盖 custom-table handshake-timeout: 5 # optional enable-pure-downlink: false # 是否启用混淆下行,false的情况下能在保证数据安全的前提下极大提升下行速度,与客户端保持相同(如果此处为false,则要求aead不可为none) + disable-http-mask: false # 可选:禁用 http 掩码/隧道(默认为 false) + # http-mask-mode: legacy # 可选:legacy(默认)、stream、poll、auto;stream/poll/auto 支持走 CDN/反代 diff --git a/mihomo/go.mod b/mihomo/go.mod index bb55812230..e4ab66ee48 100644 --- a/mihomo/go.mod +++ b/mihomo/go.mod @@ -3,6 +3,7 @@ module github.com/metacubex/mihomo go 1.20 require ( + filippo.io/edwards25519 v1.1.0 github.com/bahlo/generic-list-go v0.2.0 github.com/coreos/go-iptables v0.8.0 github.com/dlclark/regexp2 v1.11.5 @@ -46,7 +47,6 @@ require ( github.com/mroth/weightedrand/v2 v2.1.0 github.com/openacid/low v0.1.21 github.com/oschwald/maxminddb-golang v1.12.0 // lastest version compatible with golang1.20 - github.com/saba-futai/sudoku v0.0.2-d github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a github.com/samber/lo v1.52.0 github.com/sirupsen/logrus v1.9.3 @@ -66,7 +66,6 @@ require ( ) require ( - filippo.io/edwards25519 v1.1.0 // indirect github.com/RyuaNerin/go-krypto v1.3.0 // indirect github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect github.com/ajg/form v1.5.1 // indirect diff --git a/mihomo/go.sum b/mihomo/go.sum index 4756c83bd4..ac72c1dc1d 100644 --- a/mihomo/go.sum +++ b/mihomo/go.sum @@ -170,8 +170,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= -github.com/saba-futai/sudoku v0.0.2-d h1:HW/gIyNUFcDchpMN+ZhluM86U/HGkWkkRV+9Km6WZM8= -github.com/saba-futai/sudoku v0.0.2-d/go.mod h1:Rvggsoprp7HQM7bMIZUd1M27bPj8THRsZdY1dGbIAvo= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= diff --git a/mihomo/listener/config/sudoku.go b/mihomo/listener/config/sudoku.go index 848db875d7..118e252cad 100644 --- a/mihomo/listener/config/sudoku.go +++ b/mihomo/listener/config/sudoku.go @@ -20,6 +20,8 @@ type SudokuServer struct { EnablePureDownlink *bool `json:"enable-pure-downlink,omitempty"` CustomTable string `json:"custom-table,omitempty"` CustomTables []string `json:"custom-tables,omitempty"` + DisableHTTPMask bool `json:"disable-http-mask,omitempty"` + HTTPMaskMode string `json:"http-mask-mode,omitempty"` // mihomo private extension (not the part of standard Sudoku protocol) MuxOption sing.MuxOption `json:"mux-option,omitempty"` diff --git a/mihomo/listener/inbound/mieru_test.go b/mihomo/listener/inbound/mieru_test.go index 12aa680ccc..d57f4df5ee 100644 --- a/mihomo/listener/inbound/mieru_test.go +++ b/mihomo/listener/inbound/mieru_test.go @@ -3,7 +3,9 @@ package inbound_test import ( "net" "net/netip" + "runtime" "strconv" + "strings" "testing" "github.com/metacubex/mihomo/adapter/outbound" @@ -149,6 +151,9 @@ func TestNewMieru(t *testing.T) { } func TestInboundMieru(t *testing.T) { + if runtime.GOOS == "windows" && strings.HasPrefix(runtime.Version(), "go1.26") { + t.Skip("temporarily skipped on windows due to intermittent failures; tracked in PR") + } t.Run("TCP_HANDSHAKE_STANDARD", func(t *testing.T) { testInboundMieruTCP(t, "HANDSHAKE_STANDARD") }) diff --git a/mihomo/listener/inbound/sudoku.go b/mihomo/listener/inbound/sudoku.go index 433976026d..fc37cb7912 100644 --- a/mihomo/listener/inbound/sudoku.go +++ b/mihomo/listener/inbound/sudoku.go @@ -22,6 +22,8 @@ type SudokuOption struct { EnablePureDownlink *bool `inbound:"enable-pure-downlink,omitempty"` CustomTable string `inbound:"custom-table,omitempty"` // optional custom byte layout, e.g. xpxvvpvv CustomTables []string `inbound:"custom-tables,omitempty"` + DisableHTTPMask bool `inbound:"disable-http-mask,omitempty"` + HTTPMaskMode string `inbound:"http-mask-mode,omitempty"` // "legacy" (default), "stream", "poll", "auto" // mihomo private extension (not the part of standard Sudoku protocol) MuxOption MuxOption `inbound:"mux-option,omitempty"` @@ -59,6 +61,8 @@ func NewSudoku(options *SudokuOption) (*Sudoku, error) { EnablePureDownlink: options.EnablePureDownlink, CustomTable: options.CustomTable, CustomTables: options.CustomTables, + DisableHTTPMask: options.DisableHTTPMask, + HTTPMaskMode: options.HTTPMaskMode, } serverConf.MuxOption = options.MuxOption.Build() diff --git a/mihomo/listener/inbound/sudoku_test.go b/mihomo/listener/inbound/sudoku_test.go index 6ba9e63b6b..5596bf91ad 100644 --- a/mihomo/listener/inbound/sudoku_test.go +++ b/mihomo/listener/inbound/sudoku_test.go @@ -2,6 +2,7 @@ package inbound_test import ( "net/netip" + "runtime" "testing" "github.com/metacubex/mihomo/adapter/outbound" @@ -164,3 +165,27 @@ func TestInboundSudoku_CustomTable(t *testing.T) { testInboundSudoku(t, inboundOptions, outboundOptions) }) } + +func TestInboundSudoku_HTTPMaskMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("temporarily skipped on windows due to intermittent failures; tracked in PR") + } + + key := "test_key_http_mask_mode" + + for _, mode := range []string{"legacy", "stream", "poll", "auto"} { + mode := mode + t.Run(mode, func(t *testing.T) { + inboundOptions := inbound.SudokuOption{ + Key: key, + HTTPMaskMode: mode, + } + outboundOptions := outbound.SudokuOption{ + Key: key, + HTTPMask: true, + HTTPMaskMode: mode, + } + testInboundSudoku(t, inboundOptions, outboundOptions) + }) + } +} diff --git a/mihomo/listener/sudoku/server.go b/mihomo/listener/sudoku/server.go index e90e231c1d..7652783f1a 100644 --- a/mihomo/listener/sudoku/server.go +++ b/mihomo/listener/sudoku/server.go @@ -20,6 +20,7 @@ type Listener struct { addr string closed bool protoConf sudoku.ProtocolConfig + tunnelSrv *sudoku.HTTPMaskTunnelServer handler *sing.ListenerHandler } @@ -46,9 +47,31 @@ func (l *Listener) Close() error { } func (l *Listener) handleConn(conn net.Conn, tunnel C.Tunnel, additions ...inbound.Addition) { - session, err := sudoku.ServerHandshake(conn, &l.protoConf) + handshakeConn := conn + handshakeCfg := &l.protoConf + if l.tunnelSrv != nil { + c, cfg, done, err := l.tunnelSrv.WrapConn(conn) + if err != nil { + _ = conn.Close() + return + } + if done { + return + } + if c != nil { + handshakeConn = c + } + if cfg != nil { + handshakeCfg = cfg + } + } + + session, err := sudoku.ServerHandshake(handshakeConn, handshakeCfg) if err != nil { - _ = conn.Close() + _ = handshakeConn.Close() + if handshakeConn != conn { + _ = conn.Close() + } return } @@ -184,6 +207,8 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition) PaddingMax: paddingMax, EnablePureDownlink: enablePureDownlink, HandshakeTimeoutSeconds: handshakeTimeout, + DisableHTTPMask: config.DisableHTTPMask, + HTTPMaskMode: config.HTTPMaskMode, } if len(tables) == 1 { protoConf.Table = tables[0] @@ -200,6 +225,7 @@ func New(config LC.SudokuServer, tunnel C.Tunnel, additions ...inbound.Addition) protoConf: protoConf, handler: h, } + sl.tunnelSrv = sudoku.NewHTTPMaskTunnelServer(&sl.protoConf) go func() { for { diff --git a/mihomo/transport/sudoku/config.go b/mihomo/transport/sudoku/config.go new file mode 100644 index 0000000000..4fee6b670a --- /dev/null +++ b/mihomo/transport/sudoku/config.go @@ -0,0 +1,144 @@ +package sudoku + +import ( + "fmt" + "strings" + + "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" +) + +// ProtocolConfig defines the configuration for the Sudoku protocol stack. +// It is intentionally kept close to the upstream Sudoku project to ensure wire compatibility. +type ProtocolConfig struct { + // Client-only: "host:port". + ServerAddress string + + // Pre-shared key (or ED25519 key material) used to derive crypto and tables. + Key string + + // "aes-128-gcm", "chacha20-poly1305", or "none". + AEADMethod string + + // Table is the single obfuscation table to use when table rotation is disabled. + Table *sudoku.Table + + // Tables is an optional candidate set for table rotation. + // If provided (len>0), the client will pick one table per connection and the server will + // probe the handshake to detect which one was used, keeping the handshake format unchanged. + // When Tables is set, Table may be nil. + Tables []*sudoku.Table + + // Padding insertion ratio (0-100). Must satisfy PaddingMax >= PaddingMin. + PaddingMin int + PaddingMax int + + // EnablePureDownlink toggles the bandwidth-optimized downlink mode. + EnablePureDownlink bool + + // Client-only: final target "host:port". + TargetAddress string + + // Server-side handshake timeout (seconds). + HandshakeTimeoutSeconds int + + // DisableHTTPMask disables all HTTP camouflage layers. + DisableHTTPMask bool + + // HTTPMaskMode controls how the HTTP layer behaves: + // - "legacy": write a fake HTTP/1.1 header then switch to raw stream (default, not CDN-compatible) + // - "stream": real HTTP tunnel (stream-one or split), CDN-compatible + // - "poll": plain HTTP tunnel (authorize/push/pull), strong restricted-network pass-through + // - "auto": try stream then fall back to poll + HTTPMaskMode string + + // HTTPMaskTLSEnabled enables HTTPS for HTTP tunnel modes (client-side). + // If false, the tunnel uses HTTP (no port-based inference). + HTTPMaskTLSEnabled bool + + // HTTPMaskHost optionally overrides the HTTP Host header / SNI host for HTTP tunnel modes (client-side). + HTTPMaskHost string +} + +func (c *ProtocolConfig) Validate() error { + if c.Table == nil && len(c.Tables) == 0 { + return fmt.Errorf("table cannot be nil (or provide tables)") + } + for i, t := range c.Tables { + if t == nil { + return fmt.Errorf("tables[%d] cannot be nil", i) + } + } + + if c.Key == "" { + return fmt.Errorf("key cannot be empty") + } + + switch c.AEADMethod { + case "aes-128-gcm", "chacha20-poly1305", "none": + default: + return fmt.Errorf("invalid aead-method: %s, must be one of: aes-128-gcm, chacha20-poly1305, none", c.AEADMethod) + } + + if c.PaddingMin < 0 || c.PaddingMin > 100 { + return fmt.Errorf("padding-min must be between 0 and 100, got %d", c.PaddingMin) + } + if c.PaddingMax < 0 || c.PaddingMax > 100 { + return fmt.Errorf("padding-max must be between 0 and 100, got %d", c.PaddingMax) + } + if c.PaddingMax < c.PaddingMin { + return fmt.Errorf("padding-max (%d) must be >= padding-min (%d)", c.PaddingMax, c.PaddingMin) + } + + if !c.EnablePureDownlink && c.AEADMethod == "none" { + return fmt.Errorf("bandwidth optimized downlink requires AEAD") + } + + if c.HandshakeTimeoutSeconds < 0 { + return fmt.Errorf("handshake-timeout must be >= 0, got %d", c.HandshakeTimeoutSeconds) + } + + switch strings.ToLower(strings.TrimSpace(c.HTTPMaskMode)) { + case "", "legacy", "stream", "poll", "auto": + default: + return fmt.Errorf("invalid http-mask-mode: %s, must be one of: legacy, stream, poll, auto", c.HTTPMaskMode) + } + + return nil +} + +func (c *ProtocolConfig) ValidateClient() error { + if err := c.Validate(); err != nil { + return err + } + if c.ServerAddress == "" { + return fmt.Errorf("server address cannot be empty") + } + if c.TargetAddress == "" { + return fmt.Errorf("target address cannot be empty") + } + return nil +} + +func DefaultConfig() *ProtocolConfig { + return &ProtocolConfig{ + AEADMethod: "chacha20-poly1305", + PaddingMin: 10, + PaddingMax: 30, + EnablePureDownlink: true, + HandshakeTimeoutSeconds: 5, + HTTPMaskMode: "legacy", + } +} + +func (c *ProtocolConfig) tableCandidates() []*sudoku.Table { + if c == nil { + return nil + } + if len(c.Tables) > 0 { + return c.Tables + } + if c.Table != nil { + return []*sudoku.Table{c.Table} + } + return nil +} diff --git a/mihomo/transport/sudoku/crypto/aead.go b/mihomo/transport/sudoku/crypto/aead.go new file mode 100644 index 0000000000..b5f574d9ff --- /dev/null +++ b/mihomo/transport/sudoku/crypto/aead.go @@ -0,0 +1,130 @@ +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + + "golang.org/x/crypto/chacha20poly1305" +) + +type AEADConn struct { + net.Conn + aead cipher.AEAD + readBuf bytes.Buffer + nonceSize int +} + +func NewAEADConn(c net.Conn, key string, method string) (*AEADConn, error) { + if method == "none" { + return &AEADConn{Conn: c, aead: nil}, nil + } + + h := sha256.New() + h.Write([]byte(key)) + keyBytes := h.Sum(nil) + + var aead cipher.AEAD + var err error + + switch method { + case "aes-128-gcm": + block, _ := aes.NewCipher(keyBytes[:16]) + aead, err = cipher.NewGCM(block) + case "chacha20-poly1305": + aead, err = chacha20poly1305.New(keyBytes) + default: + return nil, fmt.Errorf("unsupported cipher: %s", method) + } + if err != nil { + return nil, err + } + + return &AEADConn{ + Conn: c, + aead: aead, + nonceSize: aead.NonceSize(), + }, nil +} + +func (cc *AEADConn) Write(p []byte) (int, error) { + if cc.aead == nil { + return cc.Conn.Write(p) + } + + maxPayload := 65535 - cc.nonceSize - cc.aead.Overhead() + totalWritten := 0 + var frameBuf bytes.Buffer + header := make([]byte, 2) + nonce := make([]byte, cc.nonceSize) + + for len(p) > 0 { + chunkSize := len(p) + if chunkSize > maxPayload { + chunkSize = maxPayload + } + chunk := p[:chunkSize] + p = p[chunkSize:] + + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return totalWritten, err + } + + ciphertext := cc.aead.Seal(nil, nonce, chunk, nil) + frameLen := len(nonce) + len(ciphertext) + binary.BigEndian.PutUint16(header, uint16(frameLen)) + + frameBuf.Reset() + frameBuf.Write(header) + frameBuf.Write(nonce) + frameBuf.Write(ciphertext) + + if _, err := cc.Conn.Write(frameBuf.Bytes()); err != nil { + return totalWritten, err + } + totalWritten += chunkSize + } + return totalWritten, nil +} + +func (cc *AEADConn) Read(p []byte) (int, error) { + if cc.aead == nil { + return cc.Conn.Read(p) + } + + if cc.readBuf.Len() > 0 { + return cc.readBuf.Read(p) + } + + header := make([]byte, 2) + if _, err := io.ReadFull(cc.Conn, header); err != nil { + return 0, err + } + frameLen := int(binary.BigEndian.Uint16(header)) + + body := make([]byte, frameLen) + if _, err := io.ReadFull(cc.Conn, body); err != nil { + return 0, err + } + + if len(body) < cc.nonceSize { + return 0, errors.New("frame too short") + } + nonce := body[:cc.nonceSize] + ciphertext := body[cc.nonceSize:] + + plaintext, err := cc.aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return 0, errors.New("decryption failed") + } + + cc.readBuf.Write(plaintext) + return cc.readBuf.Read(p) +} diff --git a/mihomo/transport/sudoku/crypto/ed25519.go b/mihomo/transport/sudoku/crypto/ed25519.go new file mode 100644 index 0000000000..7a2d0a12f5 --- /dev/null +++ b/mihomo/transport/sudoku/crypto/ed25519.go @@ -0,0 +1,116 @@ +package crypto + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + + "filippo.io/edwards25519" +) + +// KeyPair holds the scalar private key and point public key +type KeyPair struct { + Private *edwards25519.Scalar + Public *edwards25519.Point +} + +// GenerateMasterKey generates a random master private key (scalar) and its public key (point) +func GenerateMasterKey() (*KeyPair, error) { + // 1. Generate random scalar x (32 bytes) + var seed [64]byte + if _, err := rand.Read(seed[:]); err != nil { + return nil, err + } + + x, err := edwards25519.NewScalar().SetUniformBytes(seed[:]) + if err != nil { + return nil, err + } + + // 2. Calculate Public Key P = x * G + P := new(edwards25519.Point).ScalarBaseMult(x) + + return &KeyPair{Private: x, Public: P}, nil +} + +// SplitPrivateKey takes a master private key x and returns a new random split key (r, k) +// such that x = r + k (mod L). +// Returns hex encoded string of r || k (64 bytes) +func SplitPrivateKey(x *edwards25519.Scalar) (string, error) { + // 1. Generate random r (32 bytes) + var seed [64]byte + if _, err := rand.Read(seed[:]); err != nil { + return "", err + } + r, err := edwards25519.NewScalar().SetUniformBytes(seed[:]) + if err != nil { + return "", err + } + + // 2. Calculate k = x - r (mod L) + k := new(edwards25519.Scalar).Subtract(x, r) + + // 3. Encode r and k + rBytes := r.Bytes() + kBytes := k.Bytes() + + full := make([]byte, 64) + copy(full[:32], rBytes) + copy(full[32:], kBytes) + + return hex.EncodeToString(full), nil +} + +// RecoverPublicKey takes a split private key (r, k) or a master private key (x) +// and returns the public key P. +// Input can be: +// - 32 bytes hex (Master Scalar x) +// - 64 bytes hex (Split Key r || k) +func RecoverPublicKey(keyHex string) (*edwards25519.Point, error) { + keyBytes, err := hex.DecodeString(keyHex) + if err != nil { + return nil, fmt.Errorf("invalid hex: %w", err) + } + + if len(keyBytes) == 32 { + // Master Key x + x, err := edwards25519.NewScalar().SetCanonicalBytes(keyBytes) + if err != nil { + return nil, fmt.Errorf("invalid scalar: %w", err) + } + return new(edwards25519.Point).ScalarBaseMult(x), nil + + } else if len(keyBytes) == 64 { + // Split Key r || k + rBytes := keyBytes[:32] + kBytes := keyBytes[32:] + + r, err := edwards25519.NewScalar().SetCanonicalBytes(rBytes) + if err != nil { + return nil, fmt.Errorf("invalid scalar r: %w", err) + } + k, err := edwards25519.NewScalar().SetCanonicalBytes(kBytes) + if err != nil { + return nil, fmt.Errorf("invalid scalar k: %w", err) + } + + // sum = r + k + sum := new(edwards25519.Scalar).Add(r, k) + + // P = sum * G + return new(edwards25519.Point).ScalarBaseMult(sum), nil + } + + return nil, errors.New("invalid key length: must be 32 bytes (Master) or 64 bytes (Split)") +} + +// EncodePoint returns the hex string of the compressed point +func EncodePoint(p *edwards25519.Point) string { + return hex.EncodeToString(p.Bytes()) +} + +// EncodeScalar returns the hex string of the scalar +func EncodeScalar(s *edwards25519.Scalar) string { + return hex.EncodeToString(s.Bytes()) +} diff --git a/mihomo/transport/sudoku/features_test.go b/mihomo/transport/sudoku/features_test.go index 8eb3aedd25..470598ce8e 100644 --- a/mihomo/transport/sudoku/features_test.go +++ b/mihomo/transport/sudoku/features_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - sudokuobfs "github.com/saba-futai/sudoku/pkg/obfs/sudoku" + sudokuobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" ) type discardConn struct{} diff --git a/mihomo/transport/sudoku/handshake.go b/mihomo/transport/sudoku/handshake.go index 989d281323..2a0437d6df 100644 --- a/mihomo/transport/sudoku/handshake.go +++ b/mihomo/transport/sudoku/handshake.go @@ -11,18 +11,13 @@ import ( "strings" "time" - "github.com/saba-futai/sudoku/apis" - "github.com/saba-futai/sudoku/pkg/crypto" - "github.com/saba-futai/sudoku/pkg/obfs/httpmask" - "github.com/saba-futai/sudoku/pkg/obfs/sudoku" + "github.com/metacubex/mihomo/transport/sudoku/crypto" + "github.com/metacubex/mihomo/transport/sudoku/obfs/httpmask" + "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" "github.com/metacubex/mihomo/log" ) -type ProtocolConfig = apis.ProtocolConfig - -func DefaultConfig() *ProtocolConfig { return apis.DefaultConfig() } - type SessionType int const ( @@ -105,14 +100,14 @@ const ( downlinkModePacked byte = 0x02 ) -func downlinkMode(cfg *apis.ProtocolConfig) byte { +func downlinkMode(cfg *ProtocolConfig) byte { if cfg.EnablePureDownlink { return downlinkModePure } return downlinkModePacked } -func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.Table) net.Conn { +func buildClientObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table) net.Conn { baseReader := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, false) baseWriter := newSudokuObfsWriter(raw, table, cfg.PaddingMin, cfg.PaddingMax) if cfg.EnablePureDownlink { @@ -130,7 +125,7 @@ func buildClientObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.T } } -func buildServerObfsConn(raw net.Conn, cfg *apis.ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) { +func buildServerObfsConn(raw net.Conn, cfg *ProtocolConfig, table *sudoku.Table, record bool) (*sudoku.Conn, net.Conn) { uplink := sudoku.NewConn(raw, table, cfg.PaddingMin, cfg.PaddingMax, record) if cfg.EnablePureDownlink { downlink := &directionalConn{ @@ -189,12 +184,12 @@ type ClientHandshakeOptions struct { } // ClientHandshake performs the client-side Sudoku handshake (without sending target address). -func ClientHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (net.Conn, error) { +func ClientHandshake(rawConn net.Conn, cfg *ProtocolConfig) (net.Conn, error) { return ClientHandshakeWithOptions(rawConn, cfg, ClientHandshakeOptions{}) } // ClientHandshakeWithOptions performs the client-side Sudoku handshake (without sending target address). -func ClientHandshakeWithOptions(rawConn net.Conn, cfg *apis.ProtocolConfig, opt ClientHandshakeOptions) (net.Conn, error) { +func ClientHandshakeWithOptions(rawConn net.Conn, cfg *ProtocolConfig, opt ClientHandshakeOptions) (net.Conn, error) { if cfg == nil { return nil, fmt.Errorf("config is required") } @@ -220,7 +215,7 @@ func ClientHandshakeWithOptions(rawConn net.Conn, cfg *apis.ProtocolConfig, opt } handshake := buildHandshakePayload(cfg.Key) - if len(tableCandidates(cfg)) > 1 { + if len(cfg.tableCandidates()) > 1 { handshake[15] = tableID } if _, err := cConn.Write(handshake[:]); err != nil { @@ -236,7 +231,7 @@ func ClientHandshakeWithOptions(rawConn net.Conn, cfg *apis.ProtocolConfig, opt } // ServerHandshake performs Sudoku server-side handshake and detects UoT preface. -func ServerHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (*ServerSession, error) { +func ServerHandshake(rawConn net.Conn, cfg *ProtocolConfig) (*ServerSession, error) { if cfg == nil { return nil, fmt.Errorf("config is required") } @@ -260,7 +255,7 @@ func ServerHandshake(rawConn net.Conn, cfg *apis.ProtocolConfig) (*ServerSession } } - selectedTable, preRead, err := selectTableByProbe(bufReader, cfg, tableCandidates(cfg)) + selectedTable, preRead, err := selectTableByProbe(bufReader, cfg, cfg.tableCandidates()) if err != nil { return nil, err } diff --git a/mihomo/transport/sudoku/handshake_test.go b/mihomo/transport/sudoku/handshake_test.go index 5d9443dfde..b2f0999e4e 100644 --- a/mihomo/transport/sudoku/handshake_test.go +++ b/mihomo/transport/sudoku/handshake_test.go @@ -9,8 +9,7 @@ import ( "testing" "time" - "github.com/saba-futai/sudoku/apis" - sudokuobfs "github.com/saba-futai/sudoku/pkg/obfs/sudoku" + sudokuobfs "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" ) func TestPackedConnRoundTrip_WithPadding(t *testing.T) { @@ -67,8 +66,8 @@ func TestPackedConnRoundTrip_WithPadding(t *testing.T) { } } -func newPackedConfig(table *sudokuobfs.Table) *apis.ProtocolConfig { - cfg := apis.DefaultConfig() +func newPackedConfig(table *sudokuobfs.Table) *ProtocolConfig { + cfg := DefaultConfig() cfg.Key = "sudoku-test-key" cfg.Table = table cfg.PaddingMin = 10 @@ -118,7 +117,7 @@ func TestPackedDownlinkSoak(t *testing.T) { } } -func runPackedTCPSession(id int, cfg *apis.ProtocolConfig, errCh chan<- error) { +func runPackedTCPSession(id int, cfg *ProtocolConfig, errCh chan<- error) { serverConn, clientConn := net.Pipe() target := fmt.Sprintf("1.1.1.%d:80", (id%200)+1) payload := []byte{0x42, byte(id)} @@ -176,7 +175,7 @@ func runPackedTCPSession(id int, cfg *apis.ProtocolConfig, errCh chan<- error) { } } -func runPackedUoTSession(id int, cfg *apis.ProtocolConfig, errCh chan<- error) { +func runPackedUoTSession(id int, cfg *ProtocolConfig, errCh chan<- error) { serverConn, clientConn := net.Pipe() target := "8.8.8.8:53" payload := []byte{0xaa, byte(id)} diff --git a/mihomo/transport/sudoku/httpmask_strategy.go b/mihomo/transport/sudoku/httpmask_strategy.go index dc90991d13..fa11b24914 100644 --- a/mihomo/transport/sudoku/httpmask_strategy.go +++ b/mihomo/transport/sudoku/httpmask_strategy.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "github.com/saba-futai/sudoku/pkg/obfs/httpmask" + "github.com/metacubex/mihomo/transport/sudoku/obfs/httpmask" ) var ( diff --git a/mihomo/transport/sudoku/httpmask_tunnel.go b/mihomo/transport/sudoku/httpmask_tunnel.go new file mode 100644 index 0000000000..aeedfe15d0 --- /dev/null +++ b/mihomo/transport/sudoku/httpmask_tunnel.go @@ -0,0 +1,88 @@ +package sudoku + +import ( + "context" + "fmt" + "net" + "strings" + + "github.com/metacubex/mihomo/transport/sudoku/obfs/httpmask" +) + +type HTTPMaskTunnelServer struct { + cfg *ProtocolConfig + ts *httpmask.TunnelServer +} + +func NewHTTPMaskTunnelServer(cfg *ProtocolConfig) *HTTPMaskTunnelServer { + if cfg == nil { + return &HTTPMaskTunnelServer{} + } + + var ts *httpmask.TunnelServer + if !cfg.DisableHTTPMask { + switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { + case "stream", "poll", "auto": + ts = httpmask.NewTunnelServer(httpmask.TunnelServerOptions{Mode: cfg.HTTPMaskMode}) + } + } + return &HTTPMaskTunnelServer{cfg: cfg, ts: ts} +} + +// WrapConn inspects an accepted TCP connection and upgrades it to an HTTP tunnel stream when needed. +// +// Returns: +// - done=true: this TCP connection has been fully handled (e.g., stream/poll control request), caller should return +// - done=false: handshakeConn+cfg are ready for ServerHandshake +func (s *HTTPMaskTunnelServer) WrapConn(rawConn net.Conn) (handshakeConn net.Conn, cfg *ProtocolConfig, done bool, err error) { + if rawConn == nil { + return nil, nil, true, fmt.Errorf("nil conn") + } + if s == nil { + return rawConn, nil, false, nil + } + if s.ts == nil { + return rawConn, s.cfg, false, nil + } + + res, c, err := s.ts.HandleConn(rawConn) + if err != nil { + return nil, nil, true, err + } + + switch res { + case httpmask.HandleDone: + return nil, nil, true, nil + case httpmask.HandlePassThrough: + return c, s.cfg, false, nil + case httpmask.HandleStartTunnel: + inner := *s.cfg + inner.DisableHTTPMask = true + return c, &inner, false, nil + default: + return nil, nil, true, nil + } +} + +type TunnelDialer func(ctx context.Context, network, addr string) (net.Conn, error) + +// DialHTTPMaskTunnel dials a CDN-capable HTTP tunnel (stream/poll/auto) and returns a stream carrying raw Sudoku bytes. +func DialHTTPMaskTunnel(ctx context.Context, serverAddress string, cfg *ProtocolConfig, dial TunnelDialer) (net.Conn, error) { + if cfg == nil { + return nil, fmt.Errorf("config is required") + } + if cfg.DisableHTTPMask { + return nil, fmt.Errorf("http mask is disabled") + } + switch strings.ToLower(strings.TrimSpace(cfg.HTTPMaskMode)) { + case "stream", "poll", "auto": + default: + return nil, fmt.Errorf("http-mask-mode=%q does not use http tunnel", cfg.HTTPMaskMode) + } + return httpmask.DialTunnel(ctx, serverAddress, httpmask.TunnelDialOptions{ + Mode: cfg.HTTPMaskMode, + TLSEnabled: cfg.HTTPMaskTLSEnabled, + HostOverride: cfg.HTTPMaskHost, + DialContext: dial, + }) +} diff --git a/mihomo/transport/sudoku/httpmask_tunnel_test.go b/mihomo/transport/sudoku/httpmask_tunnel_test.go new file mode 100644 index 0000000000..eab310f976 --- /dev/null +++ b/mihomo/transport/sudoku/httpmask_tunnel_test.go @@ -0,0 +1,445 @@ +package sudoku + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "strings" + "sync" + "testing" + "time" +) + +func startTunnelServer(t *testing.T, cfg *ProtocolConfig, handle func(*ServerSession) error) (addr string, stop func(), errCh <-chan error) { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + errC := make(chan error, 128) + done := make(chan struct{}) + + tunnelSrv := NewHTTPMaskTunnelServer(cfg) + var wg sync.WaitGroup + var stopOnce sync.Once + + wg.Add(1) + go func() { + defer wg.Done() + for { + c, err := ln.Accept() + if err != nil { + close(done) + return + } + wg.Add(1) + go func(conn net.Conn) { + defer wg.Done() + + handshakeConn, handshakeCfg, handled, err := tunnelSrv.WrapConn(conn) + if err != nil { + _ = conn.Close() + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + return + } + if err == io.EOF { + return + } + errC <- err + return + } + if handled { + return + } + if handshakeConn == nil || handshakeCfg == nil { + _ = conn.Close() + errC <- fmt.Errorf("wrap conn returned nil") + return + } + + session, err := ServerHandshake(handshakeConn, handshakeCfg) + if err != nil { + _ = handshakeConn.Close() + if handshakeConn != conn { + _ = conn.Close() + } + errC <- err + return + } + defer session.Conn.Close() + + if handleErr := handle(session); handleErr != nil { + errC <- handleErr + } + }(c) + } + }() + + stop = func() { + stopOnce.Do(func() { + _ = ln.Close() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("server did not stop") + } + + ch := make(chan struct{}) + go func() { + wg.Wait() + close(ch) + }() + select { + case <-ch: + case <-time.After(10 * time.Second): + t.Fatalf("server goroutines did not exit") + } + close(errC) + }) + } + + return ln.Addr().String(), stop, errC +} + +func newTunnelTestTable(t *testing.T, key string) *ProtocolConfig { + t.Helper() + + tables, err := NewTablesWithCustomPatterns(ClientAEADSeed(key), "prefer_ascii", "", nil) + if err != nil { + t.Fatalf("build tables: %v", err) + } + if len(tables) != 1 { + t.Fatalf("unexpected tables: %d", len(tables)) + } + + cfg := DefaultConfig() + cfg.Key = key + cfg.AEADMethod = "chacha20-poly1305" + cfg.Table = tables[0] + cfg.PaddingMin = 0 + cfg.PaddingMax = 0 + cfg.HandshakeTimeoutSeconds = 5 + cfg.EnablePureDownlink = true + cfg.DisableHTTPMask = false + return cfg +} + +func TestHTTPMaskTunnel_Stream_TCPRoundTrip(t *testing.T) { + key := "tunnel-stream-key" + target := "1.1.1.1:80" + + serverCfg := newTunnelTestTable(t, key) + serverCfg.HTTPMaskMode = "stream" + + 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 + clientCfg.HTTPMaskHost = "example.com" + + tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext) + if err != nil { + t.Fatalf("dial tunnel: %v", err) + } + defer tunnelConn.Close() + + handshakeCfg := clientCfg + handshakeCfg.DisableHTTPMask = true + cConn, err := ClientHandshake(tunnelConn, &handshakeCfg) + if err != nil { + t.Fatalf("client handshake: %v", err) + } + defer cConn.Close() + + addrBuf, err := EncodeAddress(target) + if err != nil { + t.Fatalf("encode addr: %v", err) + } + if _, err := cConn.Write(addrBuf); err != nil { + t.Fatalf("write addr: %v", err) + } + + buf := make([]byte, 2) + if _, err := io.ReadFull(cConn, 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_Poll_UoTRoundTrip(t *testing.T) { + key := "tunnel-poll-key" + target := "8.8.8.8:53" + payload := []byte{0xaa, 0xbb, 0xcc, 0xdd} + + serverCfg := newTunnelTestTable(t, key) + serverCfg.HTTPMaskMode = "poll" + + addr, stop, errCh := startTunnelServer(t, serverCfg, func(s *ServerSession) error { + if s.Type != SessionTypeUoT { + return fmt.Errorf("unexpected session type: %v", s.Type) + } + gotAddr, gotPayload, err := ReadDatagram(s.Conn) + if err != nil { + return fmt.Errorf("server read datagram: %w", err) + } + if gotAddr != target { + return fmt.Errorf("uot target mismatch: %s", gotAddr) + } + if !bytes.Equal(gotPayload, payload) { + return fmt.Errorf("uot payload mismatch: %x", gotPayload) + } + if err := WriteDatagram(s.Conn, gotAddr, gotPayload); err != nil { + return fmt.Errorf("server write datagram: %w", err) + } + return nil + }) + defer stop() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + clientCfg := *serverCfg + clientCfg.ServerAddress = addr + + tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext) + if err != nil { + t.Fatalf("dial tunnel: %v", err) + } + defer tunnelConn.Close() + + handshakeCfg := clientCfg + handshakeCfg.DisableHTTPMask = true + cConn, err := ClientHandshake(tunnelConn, &handshakeCfg) + if err != nil { + t.Fatalf("client handshake: %v", err) + } + defer cConn.Close() + + if err := WritePreface(cConn); err != nil { + t.Fatalf("write preface: %v", err) + } + if err := WriteDatagram(cConn, target, payload); err != nil { + t.Fatalf("write datagram: %v", err) + } + gotAddr, gotPayload, err := ReadDatagram(cConn) + if err != nil { + t.Fatalf("read datagram: %v", err) + } + if gotAddr != target { + t.Fatalf("uot target mismatch: %s", gotAddr) + } + if !bytes.Equal(gotPayload, payload) { + t.Fatalf("uot payload mismatch: %x", gotPayload) + } + + stop() + for err := range errCh { + t.Fatalf("server error: %v", err) + } +} + +func TestHTTPMaskTunnel_Auto_TCPRoundTrip(t *testing.T) { + key := "tunnel-auto-key" + target := "9.9.9.9:443" + + serverCfg := newTunnelTestTable(t, key) + serverCfg.HTTPMaskMode = "auto" + + 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 + + tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext) + if err != nil { + t.Fatalf("dial tunnel: %v", err) + } + defer tunnelConn.Close() + + handshakeCfg := clientCfg + handshakeCfg.DisableHTTPMask = true + cConn, err := ClientHandshake(tunnelConn, &handshakeCfg) + if err != nil { + t.Fatalf("client handshake: %v", err) + } + defer cConn.Close() + + addrBuf, err := EncodeAddress(target) + if err != nil { + t.Fatalf("encode addr: %v", err) + } + if _, err := cConn.Write(addrBuf); err != nil { + t.Fatalf("write addr: %v", err) + } + + buf := make([]byte, 2) + if _, err := io.ReadFull(cConn, 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" + cfg.Table = NewTable("seed", "prefer_ascii") + cfg.ServerAddress = "127.0.0.1:1" + + cfg.DisableHTTPMask = true + cfg.HTTPMaskMode = "stream" + if _, err := DialHTTPMaskTunnel(context.Background(), cfg.ServerAddress, cfg, (&net.Dialer{}).DialContext); err == nil { + t.Fatalf("expected error for disabled http mask") + } + + cfg.DisableHTTPMask = false + cfg.HTTPMaskMode = "legacy" + if _, err := DialHTTPMaskTunnel(context.Background(), cfg.ServerAddress, cfg, (&net.Dialer{}).DialContext); err == nil { + t.Fatalf("expected error for legacy mode") + } +} + +func TestHTTPMaskTunnel_Soak_Concurrent(t *testing.T) { + key := "tunnel-soak-key" + target := "1.0.0.1:80" + + serverCfg := newTunnelTestTable(t, key) + serverCfg.HTTPMaskMode = "stream" + serverCfg.EnablePureDownlink = false + + const ( + sessions = 8 + payloadLen = 64 * 1024 + ) + + 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) + } + buf := make([]byte, payloadLen) + if _, err := io.ReadFull(s.Conn, buf); err != nil { + return fmt.Errorf("server read payload: %w", err) + } + _, err := s.Conn.Write(buf) + return err + }) + defer stop() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var wg sync.WaitGroup + runErr := make(chan error, sessions) + + for i := 0; i < sessions; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + clientCfg := *serverCfg + clientCfg.ServerAddress = addr + clientCfg.HTTPMaskHost = strings.TrimSpace(clientCfg.HTTPMaskHost) + + tunnelConn, err := DialHTTPMaskTunnel(ctx, clientCfg.ServerAddress, &clientCfg, (&net.Dialer{}).DialContext) + if err != nil { + runErr <- fmt.Errorf("dial: %w", err) + return + } + defer tunnelConn.Close() + + handshakeCfg := clientCfg + handshakeCfg.DisableHTTPMask = true + cConn, err := ClientHandshake(tunnelConn, &handshakeCfg) + if err != nil { + runErr <- fmt.Errorf("handshake: %w", err) + return + } + defer cConn.Close() + + addrBuf, err := EncodeAddress(target) + if err != nil { + runErr <- fmt.Errorf("encode addr: %w", err) + return + } + if _, err := cConn.Write(addrBuf); err != nil { + runErr <- fmt.Errorf("write addr: %w", err) + return + } + + payload := bytes.Repeat([]byte{byte(id)}, payloadLen) + if _, err := cConn.Write(payload); err != nil { + runErr <- fmt.Errorf("write payload: %w", err) + return + } + echo := make([]byte, payloadLen) + if _, err := io.ReadFull(cConn, echo); err != nil { + runErr <- fmt.Errorf("read echo: %w", err) + return + } + if !bytes.Equal(echo, payload) { + runErr <- fmt.Errorf("echo mismatch") + return + } + runErr <- nil + }(i) + } + + wg.Wait() + close(runErr) + + for err := range runErr { + if err != nil { + t.Fatalf("soak: %v", err) + } + } + + stop() + for err := range errCh { + t.Fatalf("server error: %v", err) + } +} diff --git a/mihomo/transport/sudoku/obfs/httpmask/masker.go b/mihomo/transport/sudoku/obfs/httpmask/masker.go new file mode 100644 index 0000000000..540a8911e0 --- /dev/null +++ b/mihomo/transport/sudoku/obfs/httpmask/masker.go @@ -0,0 +1,246 @@ +package httpmask + +import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + "io" + "math/rand" + "net" + "strconv" + "strings" + "sync" + "time" +) + +var ( + userAgents = []string{ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36", + } + accepts = []string{ + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "application/json, text/plain, */*", + "application/octet-stream", + "*/*", + } + acceptLanguages = []string{ + "en-US,en;q=0.9", + "en-GB,en;q=0.9", + "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", + "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7", + "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", + } + acceptEncodings = []string{ + "gzip, deflate, br", + "gzip, deflate", + "br, gzip, deflate", + } + paths = []string{ + "/api/v1/upload", + "/data/sync", + "/uploads/raw", + "/api/report", + "/feed/update", + "/v2/events", + "/v1/telemetry", + "/session", + "/stream", + "/ws", + } + contentTypes = []string{ + "application/octet-stream", + "application/x-protobuf", + "application/json", + } +) + +var ( + rngPool = sync.Pool{ + New: func() interface{} { + return rand.New(rand.NewSource(time.Now().UnixNano())) + }, + } + headerBufPool = sync.Pool{ + New: func() interface{} { + b := make([]byte, 0, 1024) + return &b + }, + } +) + +// LooksLikeHTTPRequestStart reports whether peek4 looks like a supported HTTP/1.x request method prefix. +func LooksLikeHTTPRequestStart(peek4 []byte) bool { + if len(peek4) < 4 { + return false + } + // Common methods: "GET ", "POST", "HEAD", "PUT ", "OPTI" (OPTIONS), "PATC" (PATCH), "DELE" (DELETE) + return bytes.Equal(peek4, []byte("GET ")) || + bytes.Equal(peek4, []byte("POST")) || + bytes.Equal(peek4, []byte("HEAD")) || + bytes.Equal(peek4, []byte("PUT ")) || + bytes.Equal(peek4, []byte("OPTI")) || + bytes.Equal(peek4, []byte("PATC")) || + bytes.Equal(peek4, []byte("DELE")) +} + +func trimPortForHost(host string) string { + if host == "" { + return host + } + // Accept "example.com:443" / "1.2.3.4:443" / "[::1]:443" + h, _, err := net.SplitHostPort(host) + if err == nil && h != "" { + return h + } + // If it's not in host:port form, keep as-is. + return host +} + +func appendCommonHeaders(buf []byte, host string, r *rand.Rand) []byte { + ua := userAgents[r.Intn(len(userAgents))] + accept := accepts[r.Intn(len(accepts))] + lang := acceptLanguages[r.Intn(len(acceptLanguages))] + enc := acceptEncodings[r.Intn(len(acceptEncodings))] + + buf = append(buf, "Host: "...) + buf = append(buf, host...) + buf = append(buf, "\r\nUser-Agent: "...) + buf = append(buf, ua...) + buf = append(buf, "\r\nAccept: "...) + buf = append(buf, accept...) + buf = append(buf, "\r\nAccept-Language: "...) + buf = append(buf, lang...) + buf = append(buf, "\r\nAccept-Encoding: "...) + buf = append(buf, enc...) + buf = append(buf, "\r\nConnection: keep-alive\r\n"...) + + // A couple of common cache headers; keep them static for simplicity. + buf = append(buf, "Cache-Control: no-cache\r\nPragma: no-cache\r\n"...) + return buf +} + +// WriteRandomRequestHeader writes a plausible HTTP/1.1 request header as a mask. +func WriteRandomRequestHeader(w io.Writer, host string) error { + // Get RNG from pool + r := rngPool.Get().(*rand.Rand) + defer rngPool.Put(r) + + path := paths[r.Intn(len(paths))] + ctype := contentTypes[r.Intn(len(contentTypes))] + + // Use buffer pool + bufPtr := headerBufPool.Get().(*[]byte) + buf := *bufPtr + buf = buf[:0] + defer func() { + if cap(buf) <= 4096 { + *bufPtr = buf + headerBufPool.Put(bufPtr) + } + }() + + // Weighted template selection. Keep a conservative default (POST w/ Content-Length), + // but occasionally rotate to other realistic templates (e.g. WebSocket upgrade). + switch r.Intn(10) { + case 0, 1: // ~20% WebSocket-like upgrade + hostNoPort := trimPortForHost(host) + var keyBytes [16]byte + for i := 0; i < len(keyBytes); i++ { + keyBytes[i] = byte(r.Intn(256)) + } + wsKey := base64.StdEncoding.EncodeToString(keyBytes[:]) + + buf = append(buf, "GET "...) + buf = append(buf, path...) + buf = append(buf, " HTTP/1.1\r\n"...) + buf = appendCommonHeaders(buf, host, r) + buf = append(buf, "Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: "...) + buf = append(buf, wsKey...) + buf = append(buf, "\r\nOrigin: https://"...) + buf = append(buf, hostNoPort...) + buf = append(buf, "\r\n\r\n"...) + default: // ~80% POST upload + // Random Content-Length: 4KB–10MB. Small enough to look plausible, large enough + // to justify long-lived writes on keep-alive connections. + const minCL = int64(4 * 1024) + const maxCL = int64(10 * 1024 * 1024) + contentLength := minCL + r.Int63n(maxCL-minCL+1) + + buf = append(buf, "POST "...) + buf = append(buf, path...) + buf = append(buf, " HTTP/1.1\r\n"...) + buf = appendCommonHeaders(buf, host, r) + buf = append(buf, "Content-Type: "...) + buf = append(buf, ctype...) + buf = append(buf, "\r\nContent-Length: "...) + buf = strconv.AppendInt(buf, contentLength, 10) + // A couple of extra headers seen in real clients. + if r.Intn(2) == 0 { + buf = append(buf, "\r\nX-Requested-With: XMLHttpRequest"...) + } + if r.Intn(3) == 0 { + buf = append(buf, "\r\nReferer: https://"...) + buf = append(buf, trimPortForHost(host)...) + buf = append(buf, "/"...) + } + buf = append(buf, "\r\n\r\n"...) + } + + _, err := w.Write(buf) + return err +} + +// ConsumeHeader 读取并消耗 HTTP 头部,返回消耗的数据和剩余的 reader 数据 +// 如果不是 POST 请求或格式严重错误,返回 error +func ConsumeHeader(r *bufio.Reader) ([]byte, error) { + var consumed bytes.Buffer + + // 1. 读取请求行 + // Use ReadSlice to avoid allocation if line fits in buffer + line, err := r.ReadSlice('\n') + if err != nil { + return nil, err + } + consumed.Write(line) + + // Basic method validation: accept common HTTP/1.x methods used by our masker. + // Keep it strict enough to reject obvious garbage. + switch { + case bytes.HasPrefix(line, []byte("POST ")), + bytes.HasPrefix(line, []byte("GET ")), + bytes.HasPrefix(line, []byte("HEAD ")), + bytes.HasPrefix(line, []byte("PUT ")), + bytes.HasPrefix(line, []byte("DELETE ")), + bytes.HasPrefix(line, []byte("OPTIONS ")), + bytes.HasPrefix(line, []byte("PATCH ")): + default: + return consumed.Bytes(), fmt.Errorf("invalid method or garbage: %s", strings.TrimSpace(string(line))) + } + + // 2. 循环读取头部,直到遇到空行 + for { + line, err = r.ReadSlice('\n') + if err != nil { + return consumed.Bytes(), err + } + consumed.Write(line) + + // Check for empty line (\r\n or \n) + // ReadSlice includes the delimiter + n := len(line) + if n == 2 && line[0] == '\r' && line[1] == '\n' { + return consumed.Bytes(), nil + } + if n == 1 && line[0] == '\n' { + return consumed.Bytes(), nil + } + } +} diff --git a/mihomo/transport/sudoku/obfs/httpmask/tunnel.go b/mihomo/transport/sudoku/obfs/httpmask/tunnel.go new file mode 100644 index 0000000000..b4c880bbd2 --- /dev/null +++ b/mihomo/transport/sudoku/obfs/httpmask/tunnel.go @@ -0,0 +1,1691 @@ +package httpmask + +import ( + "bufio" + "bytes" + "context" + crand "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + mrand "math/rand" + "net" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/metacubex/mihomo/component/ca" + + "github.com/metacubex/http" + "github.com/metacubex/http/httputil" + "github.com/metacubex/tls" +) + +type TunnelMode string + +const ( + TunnelModeLegacy TunnelMode = "legacy" + TunnelModeStream TunnelMode = "stream" + TunnelModePoll TunnelMode = "poll" + TunnelModeAuto TunnelMode = "auto" +) + +func normalizeTunnelMode(mode string) TunnelMode { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", string(TunnelModeLegacy): + return TunnelModeLegacy + case string(TunnelModeStream): + return TunnelModeStream + case string(TunnelModePoll): + return TunnelModePoll + case string(TunnelModeAuto): + return TunnelModeAuto + default: + // Be conservative: unknown => legacy + return TunnelModeLegacy + } +} + +type HandleResult int + +const ( + HandlePassThrough HandleResult = iota + HandleStartTunnel + HandleDone +) + +type TunnelDialOptions struct { + Mode string + TLSEnabled bool // when true, use HTTPS; otherwise, use HTTP (no port-based inference) + HostOverride string // optional Host header / SNI host (without scheme); accepts "example.com" or "example.com:443" + // DialContext overrides how the HTTP tunnel dials raw TCP/TLS connections. + // It must not be nil; passing nil is a programming error. + DialContext func(ctx context.Context, network, addr string) (net.Conn, error) +} + +// DialTunnel establishes a bidirectional stream over HTTP: +// - stream: a single streaming POST (request body uplink, response body downlink) +// - poll: authorize + push/pull polling tunnel (base64 framed) +// - auto: try stream then fall back to poll +// +// The returned net.Conn carries the raw Sudoku stream (no HTTP headers). +func DialTunnel(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + mode := normalizeTunnelMode(opts.Mode) + if mode == TunnelModeLegacy { + return nil, fmt.Errorf("legacy mode does not use http tunnel") + } + + switch mode { + case TunnelModeStream: + return dialStreamFn(ctx, serverAddress, opts) + case TunnelModePoll: + return dialPollFn(ctx, serverAddress, opts) + case TunnelModeAuto: + // "stream" can hang on some CDNs that buffer uploads until request body completes. + // Keep it on a short leash so we can fall back to poll within the caller's deadline. + streamCtx, cancelX := context.WithTimeout(ctx, 3*time.Second) + c, errX := dialStreamFn(streamCtx, serverAddress, opts) + cancelX() + if errX == nil { + return c, nil + } + c, errP := dialPollFn(ctx, serverAddress, opts) + if errP == nil { + return c, nil + } + return nil, fmt.Errorf("auto tunnel failed: stream: %v; poll: %w", errX, errP) + default: + return dialStreamFn(ctx, serverAddress, opts) + } +} + +var ( + dialStreamFn = dialStream + dialPollFn = dialPoll +) + +func canonicalHeaderHost(urlHost, scheme string) string { + host, port, err := net.SplitHostPort(urlHost) + if err != nil { + return urlHost + } + + defaultPort := "" + switch scheme { + case "https": + defaultPort = "443" + case "http": + defaultPort = "80" + } + if defaultPort == "" || port != defaultPort { + return urlHost + } + + // If we strip the port from an IPv6 literal, re-add brackets to keep the Host header valid. + if strings.Contains(host, ":") { + return "[" + host + "]" + } + return host +} + +func parseTunnelToken(body []byte) (string, error) { + s := strings.TrimSpace(string(body)) + idx := strings.Index(s, "token=") + if idx < 0 { + return "", errors.New("missing token") + } + 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 +} + +type httpStreamConn struct { + reader io.ReadCloser + writer *io.PipeWriter + cancel context.CancelFunc + + localAddr net.Addr + remoteAddr net.Addr +} + +func (c *httpStreamConn) Read(p []byte) (int, error) { return c.reader.Read(p) } +func (c *httpStreamConn) Write(p []byte) (int, error) { return c.writer.Write(p) } + +func (c *httpStreamConn) Close() error { + if c.cancel != nil { + c.cancel() + } + _ = c.writer.CloseWithError(io.ErrClosedPipe) + return c.reader.Close() +} + +func (c *httpStreamConn) LocalAddr() net.Addr { return c.localAddr } +func (c *httpStreamConn) RemoteAddr() net.Addr { return c.remoteAddr } + +func (c *httpStreamConn) SetDeadline(time.Time) error { return nil } +func (c *httpStreamConn) SetReadDeadline(time.Time) error { return nil } +func (c *httpStreamConn) SetWriteDeadline(time.Time) error { return nil } + +type httpClientTarget struct { + scheme string + urlHost string + headerHost string +} + +func newHTTPClient(serverAddress string, opts TunnelDialOptions, maxIdleConns int) (*http.Client, httpClientTarget, error) { + if opts.DialContext == nil { + panic("httpmask: DialContext is nil") + } + + scheme, urlHost, dialAddr, serverName, err := normalizeHTTPDialTarget(serverAddress, opts.TLSEnabled, opts.HostOverride) + if err != nil { + return nil, httpClientTarget{}, err + } + + transport := &http.Transport{ + ForceAttemptHTTP2: true, + DisableCompression: true, + MaxIdleConns: maxIdleConns, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + DialContext: func(dialCtx context.Context, network, _ string) (net.Conn, error) { + return opts.DialContext(dialCtx, network, dialAddr) + }, + } + if scheme == "https" { + transport.TLSClientConfig, err = ca.GetTLSConfig(ca.Option{TLSConfig: &tls.Config{ + ServerName: serverName, + MinVersion: tls.VersionTLS12, + }}) + if err != nil { + return nil, httpClientTarget{}, err + } + } + + return &http.Client{Transport: transport}, httpClientTarget{ + scheme: scheme, + urlHost: urlHost, + headerHost: canonicalHeaderHost(urlHost, scheme), + }, nil +} + +func dialStream(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + // Prefer split session (Cloudflare-friendly). Fall back to stream-one for older servers / environments. + c, errSplit := dialStreamSplit(ctx, serverAddress, opts) + if errSplit == nil { + return c, nil + } + c2, errOne := dialStreamOne(ctx, serverAddress, opts) + if errOne == nil { + return c2, nil + } + return nil, fmt.Errorf("dial stream failed: split: %v; stream-one: %w", errSplit, errOne) +} + +func dialStreamOne(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + client, target, err := newHTTPClient(serverAddress, opts, 16) + if err != nil { + return nil, err + } + + r := rngPool.Get().(*mrand.Rand) + path := paths[r.Intn(len(paths))] + ctype := contentTypes[r.Intn(len(contentTypes))] + rngPool.Put(r) + + u := url.URL{ + Scheme: target.scheme, + Host: target.urlHost, + Path: path, + } + + reqBodyR, reqBodyW := io.Pipe() + + ctx, cancel := context.WithCancel(ctx) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), reqBodyR) + if err != nil { + cancel() + _ = reqBodyW.Close() + return nil, err + } + req.Host = target.headerHost + + applyTunnelHeaders(req.Header, target.headerHost, TunnelModeStream) + req.Header.Set("Content-Type", ctype) + + resp, err := client.Do(req) + if err != nil { + cancel() + _ = reqBodyW.Close() + return nil, err + } + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024)) + cancel() + _ = reqBodyW.Close() + return nil, fmt.Errorf("stream bad status: %s (%s)", resp.Status, strings.TrimSpace(string(body))) + } + + return &httpStreamConn{ + reader: resp.Body, + writer: reqBodyW, + cancel: cancel, + localAddr: &net.TCPAddr{}, + remoteAddr: &net.TCPAddr{}, + }, nil +} + +type streamSplitConn struct { + ctx context.Context + cancel context.CancelFunc + + client *http.Client + pushURL string + pullURL string + closeURL string + headerHost string + + rxc chan []byte + closed chan struct{} + + writeCh chan []byte + + mu sync.Mutex + readBuf []byte + localAddr net.Addr + remoteAddr net.Addr +} + +func (c *streamSplitConn) Read(b []byte) (n int, err error) { + if len(c.readBuf) == 0 { + select { + case c.readBuf = <-c.rxc: + case <-c.closed: + return 0, io.ErrClosedPipe + } + } + n = copy(b, c.readBuf) + c.readBuf = c.readBuf[n:] + return n, nil +} + +func (c *streamSplitConn) Write(b []byte) (n int, err error) { + if len(b) == 0 { + return 0, nil + } + c.mu.Lock() + select { + case <-c.closed: + c.mu.Unlock() + return 0, io.ErrClosedPipe + default: + } + c.mu.Unlock() + + payload := make([]byte, len(b)) + copy(payload, b) + select { + case c.writeCh <- payload: + return len(b), nil + case <-c.closed: + return 0, io.ErrClosedPipe + } +} + +func (c *streamSplitConn) Close() error { + c.mu.Lock() + select { + case <-c.closed: + c.mu.Unlock() + return nil + default: + close(c.closed) + } + c.mu.Unlock() + + if c.cancel != nil { + c.cancel() + } + + // Best-effort session close signal (avoid leaking server-side sessions). + closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(closeCtx, http.MethodPost, c.closeURL, nil) + if err == nil { + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModeStream) + if resp, doErr := c.client.Do(req); doErr == nil && resp != nil { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() + } + } + + return nil +} + +func (c *streamSplitConn) LocalAddr() net.Addr { return c.localAddr } +func (c *streamSplitConn) RemoteAddr() net.Addr { return c.remoteAddr } + +func (c *streamSplitConn) SetDeadline(time.Time) error { return nil } +func (c *streamSplitConn) SetReadDeadline(time.Time) error { return nil } +func (c *streamSplitConn) SetWriteDeadline(time.Time) error { return nil } + +func dialStreamSplit(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + client, target, err := newHTTPClient(serverAddress, opts, 32) + if err != nil { + return nil, err + } + + authorizeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/session"}).String() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil) + if err != nil { + return nil, err + } + req.Host = target.headerHost + applyTunnelHeaders(req.Header, target.headerHost, TunnelModeStream) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("stream authorize bad status: %s (%s)", resp.Status, strings.TrimSpace(string(bodyBytes))) + } + + token, err := parseTunnelToken(bodyBytes) + if err != nil { + return nil, fmt.Errorf("stream authorize failed: %q", strings.TrimSpace(string(bodyBytes))) + } + if token == "" { + return nil, fmt.Errorf("stream authorize empty token") + } + + pushURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/api/v1/upload", RawQuery: "token=" + url.QueryEscape(token)}).String() + pullURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/stream", RawQuery: "token=" + url.QueryEscape(token)}).String() + closeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/api/v1/upload", RawQuery: "token=" + url.QueryEscape(token) + "&close=1"}).String() + + connCtx, cancel := context.WithCancel(context.Background()) + c := &streamSplitConn{ + ctx: connCtx, + cancel: cancel, + client: client, + pushURL: pushURL, + pullURL: pullURL, + closeURL: closeURL, + headerHost: target.headerHost, + rxc: make(chan []byte, 256), + closed: make(chan struct{}), + writeCh: make(chan []byte, 256), + localAddr: &net.TCPAddr{}, + remoteAddr: &net.TCPAddr{}, + } + + go c.pullLoop() + go c.pushLoop() + return c, nil +} + +func (c *streamSplitConn) pullLoop() { + const ( + requestTimeout = 30 * time.Second + readChunkSize = 32 * 1024 + idleBackoff = 25 * time.Millisecond + ) + + buf := make([]byte, readChunkSize) + for { + select { + case <-c.closed: + return + default: + } + + reqCtx, cancel := context.WithTimeout(c.ctx, requestTimeout) + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, c.pullURL, nil) + if err != nil { + cancel() + _ = c.Close() + return + } + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModeStream) + + resp, err := c.client.Do(req) + if err != nil { + cancel() + _ = c.Close() + return + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + cancel() + _ = c.Close() + return + } + + readAny := false + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + readAny = true + payload := make([]byte, n) + copy(payload, buf[:n]) + select { + case c.rxc <- payload: + case <-c.closed: + _ = resp.Body.Close() + cancel() + return + } + } + if rerr != nil { + _ = resp.Body.Close() + cancel() + if errors.Is(rerr, io.EOF) { + // Long-poll ended; retry. + break + } + _ = c.Close() + return + } + } + cancel() + if !readAny { + // Avoid tight loop if the server replied quickly with an empty body. + select { + case <-time.After(idleBackoff): + case <-c.closed: + return + } + } + } +} + +func (c *streamSplitConn) pushLoop() { + const ( + maxBatchBytes = 256 * 1024 + flushInterval = 5 * time.Millisecond + requestTimeout = 20 * time.Second + ) + + var ( + buf bytes.Buffer + timer = time.NewTimer(flushInterval) + ) + defer timer.Stop() + + flush := func() bool { + if buf.Len() == 0 { + return true + } + + reqCtx, cancel := context.WithTimeout(c.ctx, requestTimeout) + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.pushURL, bytes.NewReader(buf.Bytes())) + if err != nil { + cancel() + return false + } + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModeStream) + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := c.client.Do(req) + if err != nil { + cancel() + return false + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() + cancel() + if resp.StatusCode != http.StatusOK { + return false + } + + buf.Reset() + return true + } + + resetTimer := func() { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(flushInterval) + } + + resetTimer() + + for { + select { + case b, ok := <-c.writeCh: + if !ok { + _ = flush() + return + } + if len(b) == 0 { + continue + } + if buf.Len()+len(b) > maxBatchBytes { + if !flush() { + _ = c.Close() + return + } + resetTimer() + } + _, _ = buf.Write(b) + if buf.Len() >= maxBatchBytes { + if !flush() { + _ = c.Close() + return + } + resetTimer() + } + case <-timer.C: + if !flush() { + _ = c.Close() + return + } + resetTimer() + case <-c.closed: + _ = flush() + return + } + } +} + +type pollConn struct { + client *http.Client + pushURL string + pullURL string + closeURL string + headerHost string + + rxc chan []byte + closed chan struct{} + + writeCh chan []byte + + mu sync.Mutex + readBuf []byte + localAddr net.Addr + remoteAddr net.Addr +} + +func (c *pollConn) Read(b []byte) (n int, err error) { + if len(c.readBuf) == 0 { + select { + case c.readBuf = <-c.rxc: + case <-c.closed: + return 0, io.ErrClosedPipe + } + } + n = copy(b, c.readBuf) + c.readBuf = c.readBuf[n:] + return n, nil +} + +func (c *pollConn) Write(b []byte) (n int, err error) { + if len(b) == 0 { + return 0, nil + } + c.mu.Lock() + select { + case <-c.closed: + c.mu.Unlock() + return 0, io.ErrClosedPipe + default: + } + c.mu.Unlock() + + payload := make([]byte, len(b)) + copy(payload, b) + select { + case c.writeCh <- payload: + return len(b), nil + case <-c.closed: + return 0, io.ErrClosedPipe + } +} + +func (c *pollConn) Close() error { + c.mu.Lock() + select { + case <-c.closed: + c.mu.Unlock() + return nil + default: + close(c.closed) + } + c.mu.Unlock() + + close(c.writeCh) + + // Best-effort session close signal (avoid leaking server-side sessions). + req, err := http.NewRequest(http.MethodPost, c.closeURL, nil) + if err == nil { + req.Host = c.headerHost + req.Header.Set("X-Sudoku-Tunnel", string(TunnelModePoll)) + req.Header.Set("X-Sudoku-Version", "1") + _, _ = c.client.Do(req) + } + + return nil +} + +func (c *pollConn) LocalAddr() net.Addr { return c.localAddr } +func (c *pollConn) RemoteAddr() net.Addr { return c.remoteAddr } + +func (c *pollConn) SetDeadline(time.Time) error { return nil } +func (c *pollConn) SetReadDeadline(time.Time) error { return nil } +func (c *pollConn) SetWriteDeadline(time.Time) error { return nil } + +func dialPoll(ctx context.Context, serverAddress string, opts TunnelDialOptions) (net.Conn, error) { + client, target, err := newHTTPClient(serverAddress, opts, 32) + if err != nil { + return nil, err + } + + authorizeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/session"}).String() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil) + if err != nil { + return nil, err + } + req.Host = target.headerHost + applyTunnelHeaders(req.Header, target.headerHost, TunnelModePoll) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("poll authorize bad status: %s (%s)", resp.Status, strings.TrimSpace(string(bodyBytes))) + } + + token, err := parseTunnelToken(bodyBytes) + if err != nil { + return nil, fmt.Errorf("poll authorize failed: %q", strings.TrimSpace(string(bodyBytes))) + } + if token == "" { + return nil, fmt.Errorf("poll authorize empty token") + } + + pushURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/api/v1/upload", RawQuery: "token=" + url.QueryEscape(token)}).String() + pullURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/stream", RawQuery: "token=" + url.QueryEscape(token)}).String() + closeURL := (&url.URL{Scheme: target.scheme, Host: target.urlHost, Path: "/api/v1/upload", RawQuery: "token=" + url.QueryEscape(token) + "&close=1"}).String() + + c := &pollConn{ + client: client, + pushURL: pushURL, + pullURL: pullURL, + closeURL: closeURL, + headerHost: target.headerHost, + rxc: make(chan []byte, 128), + closed: make(chan struct{}), + writeCh: make(chan []byte, 256), + localAddr: &net.TCPAddr{}, + remoteAddr: &net.TCPAddr{}, + } + + go c.pullLoop() + go c.pushLoop() + return c, nil +} + +func (c *pollConn) pullLoop() { + for { + select { + case <-c.closed: + return + default: + } + + req, err := http.NewRequest(http.MethodGet, c.pullURL, nil) + if err != nil { + _ = c.Close() + return + } + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModePoll) + + resp, err := c.client.Do(req) + if err != nil { + _ = c.Close() + return + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + _ = c.Close() + return + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + payload, err := base64.StdEncoding.DecodeString(line) + if err != nil { + _ = resp.Body.Close() + _ = c.Close() + return + } + select { + case c.rxc <- payload: + case <-c.closed: + _ = resp.Body.Close() + return + } + } + _ = resp.Body.Close() + if err := scanner.Err(); err != nil { + _ = c.Close() + return + } + } +} + +func (c *pollConn) pushLoop() { + const ( + maxBatchBytes = 64 * 1024 + flushInterval = 5 * time.Millisecond + maxLineRawBytes = 16 * 1024 + ) + + var ( + buf bytes.Buffer + pendingRaw int + timer = time.NewTimer(flushInterval) + ) + defer timer.Stop() + + flush := func() bool { + if buf.Len() == 0 { + return true + } + + req, err := http.NewRequest(http.MethodPost, c.pushURL, bytes.NewReader(buf.Bytes())) + if err != nil { + return false + } + req.Host = c.headerHost + applyTunnelHeaders(req.Header, c.headerHost, TunnelModePoll) + req.Header.Set("Content-Type", "text/plain") + + resp, err := c.client.Do(req) + if err != nil { + return false + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false + } + + buf.Reset() + pendingRaw = 0 + return true + } + + resetTimer := func() { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(flushInterval) + } + + resetTimer() + + for { + select { + case b, ok := <-c.writeCh: + if !ok { + _ = flush() + return + } + if len(b) == 0 { + continue + } + + // Split large writes into multiple base64 lines to cap per-line size. + for len(b) > 0 { + chunk := b + if len(chunk) > maxLineRawBytes { + chunk = b[:maxLineRawBytes] + } + b = b[len(chunk):] + + encLen := base64.StdEncoding.EncodedLen(len(chunk)) + if pendingRaw+len(chunk) > maxBatchBytes || buf.Len()+encLen+1 > maxBatchBytes*2 { + if !flush() { + _ = c.Close() + return + } + } + + tmp := make([]byte, base64.StdEncoding.EncodedLen(len(chunk))) + base64.StdEncoding.Encode(tmp, chunk) + buf.Write(tmp) + buf.WriteByte('\n') + pendingRaw += len(chunk) + } + + if pendingRaw >= maxBatchBytes { + if !flush() { + _ = c.Close() + return + } + resetTimer() + } + case <-timer.C: + if !flush() { + _ = c.Close() + return + } + resetTimer() + case <-c.closed: + _ = flush() + return + } + } +} + +func normalizeHTTPDialTarget(serverAddress string, tlsEnabled bool, hostOverride string) (scheme, urlHost, dialAddr, serverName string, err error) { + host, port, err := net.SplitHostPort(serverAddress) + if err != nil { + return "", "", "", "", fmt.Errorf("invalid server address %q: %w", serverAddress, err) + } + + if hostOverride != "" { + // Allow "example.com" or "example.com:443" + if h, p, splitErr := net.SplitHostPort(hostOverride); splitErr == nil { + if h != "" { + hostOverride = h + } + if p != "" { + port = p + } + } + serverName = hostOverride + urlHost = net.JoinHostPort(hostOverride, port) + } else { + serverName = host + urlHost = net.JoinHostPort(host, port) + } + + if tlsEnabled { + scheme = "https" + } else { + scheme = "http" + } + + dialAddr = net.JoinHostPort(host, port) + return scheme, urlHost, dialAddr, trimPortForHost(serverName), nil +} + +func applyTunnelHeaders(h http.Header, host string, mode TunnelMode) { + r := rngPool.Get().(*mrand.Rand) + ua := userAgents[r.Intn(len(userAgents))] + accept := accepts[r.Intn(len(accepts))] + lang := acceptLanguages[r.Intn(len(acceptLanguages))] + enc := acceptEncodings[r.Intn(len(acceptEncodings))] + rngPool.Put(r) + + h.Set("User-Agent", ua) + h.Set("Accept", accept) + h.Set("Accept-Language", lang) + h.Set("Accept-Encoding", enc) + h.Set("Cache-Control", "no-cache") + h.Set("Pragma", "no-cache") + h.Set("Connection", "keep-alive") + h.Set("Host", host) + h.Set("X-Sudoku-Tunnel", string(mode)) + h.Set("X-Sudoku-Version", "1") +} + +type TunnelServerOptions struct { + Mode string + // PullReadTimeout controls how long the server long-poll waits for tunnel downlink data before replying with a keepalive newline. + PullReadTimeout time.Duration + // SessionTTL is a best-effort TTL to prevent leaked sessions. 0 uses a conservative default. + SessionTTL time.Duration +} + +type TunnelServer struct { + mode TunnelMode + + pullReadTimeout time.Duration + sessionTTL time.Duration + + mu sync.Mutex + sessions map[string]*tunnelSession +} + +type tunnelSession struct { + conn net.Conn + lastActive time.Time +} + +func NewTunnelServer(opts TunnelServerOptions) *TunnelServer { + mode := normalizeTunnelMode(opts.Mode) + if mode == TunnelModeLegacy { + // Server-side "legacy" means: don't accept stream/poll tunnels; only passthrough. + } + timeout := opts.PullReadTimeout + if timeout <= 0 { + timeout = 10 * time.Second + } + ttl := opts.SessionTTL + if ttl <= 0 { + ttl = 2 * time.Minute + } + return &TunnelServer{ + mode: mode, + pullReadTimeout: timeout, + sessionTTL: ttl, + sessions: make(map[string]*tunnelSession), + } +} + +// HandleConn inspects rawConn. If it is an HTTP tunnel request (X-Sudoku-Tunnel header), it is handled here and: +// - returns HandleStartTunnel + a net.Conn that carries the raw Sudoku stream (stream mode or poll session pipe) +// - or returns HandleDone if the HTTP request is a poll control request (push/pull) and no Sudoku handshake should run on this TCP conn +// +// If it is not an HTTP tunnel request (or server mode is legacy), it returns HandlePassThrough with a conn that replays any pre-read bytes. +func (s *TunnelServer) HandleConn(rawConn net.Conn) (HandleResult, net.Conn, error) { + if rawConn == nil { + return HandleDone, nil, errors.New("nil conn") + } + + // Small header read deadline to avoid stalling Accept loops. The actual Sudoku handshake has its own deadlines. + _ = rawConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + var first [4]byte + n, err := io.ReadFull(rawConn, first[:]) + if err != nil { + _ = rawConn.SetReadDeadline(time.Time{}) + // Even if short-read, preserve bytes for downstream handlers. + if n > 0 { + return HandlePassThrough, newPreBufferedConn(rawConn, first[:n]), nil + } + return HandleDone, nil, err + } + pc := newPreBufferedConn(rawConn, first[:]) + br := bufio.NewReader(pc) + + if !LooksLikeHTTPRequestStart(first[:]) { + _ = rawConn.SetReadDeadline(time.Time{}) + return HandlePassThrough, pc, nil + } + + req, headerBytes, buffered, err := readHTTPHeader(br) + _ = rawConn.SetReadDeadline(time.Time{}) + if err != nil { + // Not a valid HTTP request; hand it back to the legacy path with replay. + prefix := make([]byte, 0, len(first)+len(headerBytes)+len(buffered)) + if len(headerBytes) == 0 || !bytes.HasPrefix(headerBytes, first[:]) { + prefix = append(prefix, first[:]...) + } + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil + } + + tunnelHeader := strings.ToLower(strings.TrimSpace(req.headers["x-sudoku-tunnel"])) + if tunnelHeader == "" { + // Not our tunnel; replay full bytes to legacy handler. + prefix := make([]byte, 0, len(headerBytes)+len(buffered)) + prefix = append(prefix, headerBytes...) + prefix = append(prefix, buffered...) + return HandlePassThrough, newPreBufferedConn(rawConn, prefix), nil + } + if s.mode == TunnelModeLegacy { + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + switch TunnelMode(tunnelHeader) { + case TunnelModeStream: + if s.mode != TunnelModeStream && s.mode != TunnelModeAuto { + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + return s.handleStream(rawConn, req, buffered) + case TunnelModePoll: + if s.mode != TunnelModePoll && s.mode != TunnelModeAuto { + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + return s.handlePoll(rawConn, req, buffered) + default: + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } +} + +type httpRequestHeader struct { + method string + target string // path + query + proto string + headers map[string]string // lower-case keys +} + +func readHTTPHeader(r *bufio.Reader) (*httpRequestHeader, []byte, []byte, error) { + const maxHeaderBytes = 32 * 1024 + + var consumed bytes.Buffer + readLine := func() ([]byte, error) { + line, err := r.ReadSlice('\n') + if len(line) > 0 { + if consumed.Len()+len(line) > maxHeaderBytes { + return line, fmt.Errorf("http header too large") + } + consumed.Write(line) + } + return line, err + } + + // Request line + line, err := readLine() + if err != nil { + return nil, consumed.Bytes(), readAllBuffered(r), err + } + lineStr := strings.TrimRight(string(line), "\r\n") + parts := strings.SplitN(lineStr, " ", 3) + if len(parts) != 3 { + return nil, consumed.Bytes(), readAllBuffered(r), fmt.Errorf("invalid request line") + } + req := &httpRequestHeader{ + method: parts[0], + target: parts[1], + proto: parts[2], + headers: make(map[string]string), + } + + // Headers + for { + line, err = readLine() + if err != nil { + return nil, consumed.Bytes(), readAllBuffered(r), err + } + trimmed := strings.TrimRight(string(line), "\r\n") + if trimmed == "" { + break + } + k, v, ok := strings.Cut(trimmed, ":") + if !ok { + continue + } + k = strings.ToLower(strings.TrimSpace(k)) + v = strings.TrimSpace(v) + if k == "" { + continue + } + // Keep the first value; we only care about a small set. + if _, exists := req.headers[k]; !exists { + req.headers[k] = v + } + } + + return req, consumed.Bytes(), readAllBuffered(r), nil +} + +func readAllBuffered(r *bufio.Reader) []byte { + n := r.Buffered() + if n <= 0 { + return nil + } + b, err := r.Peek(n) + if err != nil { + return nil + } + out := make([]byte, n) + copy(out, b) + return out +} + +type preBufferedConn struct { + net.Conn + buf []byte +} + +func newPreBufferedConn(conn net.Conn, pre []byte) net.Conn { + cpy := make([]byte, len(pre)) + copy(cpy, pre) + return &preBufferedConn{Conn: conn, buf: cpy} +} + +func (p *preBufferedConn) Read(b []byte) (int, error) { + if len(p.buf) > 0 { + n := copy(b, p.buf) + p.buf = p.buf[n:] + return n, nil + } + return p.Conn.Read(b) +} + +type bodyConn struct { + net.Conn + reader io.Reader + writer io.WriteCloser + tail io.Writer + flush func() error +} + +func (c *bodyConn) Read(p []byte) (int, error) { return c.reader.Read(p) } +func (c *bodyConn) Write(p []byte) (int, error) { + n, err := c.writer.Write(p) + if c.flush != nil { + _ = c.flush() + } + return n, err +} + +func (c *bodyConn) Close() error { + var firstErr error + if c.writer != nil { + if err := c.writer.Close(); err != nil && firstErr == nil { + firstErr = err + } + // NewChunkedWriter does not write the final CRLF. Ensure a clean terminator. + if c.tail != nil { + _, _ = c.tail.Write([]byte("\r\n")) + } else { + _, _ = c.Conn.Write([]byte("\r\n")) + } + if c.flush != nil { + _ = c.flush() + } + } + if err := c.Conn.Close(); err != nil && firstErr == nil { + firstErr = err + } + return firstErr +} + +func (s *TunnelServer) handleStream(rawConn net.Conn, req *httpRequestHeader, buffered []byte) (HandleResult, net.Conn, error) { + u, err := url.ParseRequestURI(req.target) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + // Only accept plausible paths to reduce accidental exposure. + if !isAllowedPath(req.target) { + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + token := u.Query().Get("token") + closeFlag := u.Query().Get("close") == "1" + + 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 == "" && u.Path == "/session" { + return s.authorizeSession(rawConn) + } + // Stream split-session: GET /stream?token=... => downlink poll. + if token != "" && u.Path == "/stream" { + return s.streamPull(rawConn, token) + } + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + + case http.MethodPost: + // Stream split-session: POST /api/v1/upload?token=... => uplink push. + if token != "" && u.Path == "/api/v1/upload" { + if closeFlag { + s.closeSession(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil + } + bodyReader, err := newRequestBodyReader(newPreBufferedConn(rawConn, buffered), req.headers) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + return s.streamPush(rawConn, token, bodyReader) + } + + // Stream-one: single full-duplex POST. + if err := writeTunnelResponseHeader(rawConn); err != nil { + _ = rawConn.Close() + return HandleDone, nil, err + } + + bodyReader, err := newRequestBodyReader(newPreBufferedConn(rawConn, buffered), req.headers) + if err != nil { + _ = rawConn.Close() + return HandleDone, nil, err + } + + bw := bufio.NewWriterSize(rawConn, 32*1024) + chunked := httputil.NewChunkedWriter(bw) + stream := &bodyConn{ + Conn: rawConn, + reader: bodyReader, + writer: chunked, + tail: bw, + flush: bw.Flush, + } + return HandleStartTunnel, stream, nil + + default: + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } +} + +func isAllowedPath(target string) bool { + u, err := url.ParseRequestURI(target) + if err != nil { + return false + } + for _, p := range paths { + if u.Path == p { + return true + } + } + return false +} + +func newRequestBodyReader(conn net.Conn, headers map[string]string) (io.Reader, error) { + br := bufio.NewReaderSize(conn, 32*1024) + + te := strings.ToLower(headers["transfer-encoding"]) + if strings.Contains(te, "chunked") { + return httputil.NewChunkedReader(br), nil + } + if clStr := headers["content-length"]; clStr != "" { + n, err := strconv.ParseInt(strings.TrimSpace(clStr), 10, 64) + if err != nil || n < 0 { + return nil, fmt.Errorf("invalid content-length") + } + return io.LimitReader(br, n), nil + } + return br, nil +} + +func writeTunnelResponseHeader(w io.Writer) error { + _, err := io.WriteString(w, + "HTTP/1.1 200 OK\r\n"+ + "Content-Type: application/octet-stream\r\n"+ + "Transfer-Encoding: chunked\r\n"+ + "Cache-Control: no-store\r\n"+ + "Pragma: no-cache\r\n"+ + "Connection: keep-alive\r\n"+ + "X-Accel-Buffering: no\r\n"+ + "\r\n") + return err +} + +func writeSimpleHTTPResponse(w io.Writer, code int, body string) error { + if body == "" { + body = http.StatusText(code) + } + body = strings.TrimRight(body, "\r\n") + _, err := io.WriteString(w, + fmt.Sprintf("HTTP/1.1 %d %s\r\nContent-Type: text/plain\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", + code, http.StatusText(code), len(body), body)) + return err +} + +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. + _, 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)) + return err +} + +func (s *TunnelServer) handlePoll(rawConn net.Conn, req *httpRequestHeader, buffered []byte) (HandleResult, net.Conn, error) { + u, err := url.ParseRequestURI(req.target) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + if !isAllowedPath(req.target) { + _ = writeSimpleHTTPResponse(rawConn, http.StatusNotFound, "not found") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + token := u.Query().Get("token") + closeFlag := u.Query().Get("close") == "1" + switch strings.ToUpper(req.method) { + case http.MethodGet: + if token == "" { + return s.authorizeSession(rawConn) + } + return s.pollPull(rawConn, token) + case http.MethodPost: + if token == "" { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "missing token") + _ = rawConn.Close() + return HandleDone, nil, nil + } + if closeFlag { + s.closeSession(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil + } + bodyReader, err := newRequestBodyReader(newPreBufferedConn(rawConn, buffered), req.headers) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + return s.pollPush(rawConn, token, bodyReader) + default: + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } +} + +func (s *TunnelServer) authorizeSession(rawConn net.Conn) (HandleResult, net.Conn, error) { + token, err := newSessionToken() + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusInternalServerError, "internal error") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + c1, c2 := net.Pipe() + + s.mu.Lock() + s.sessions[token] = &tunnelSession{conn: c2, lastActive: time.Now()} + s.mu.Unlock() + + go s.reapSessionLater(token) + + _ = writeTokenHTTPResponse(rawConn, token) + _ = rawConn.Close() + return HandleStartTunnel, c1, nil +} + +func newSessionToken() (string, error) { + var b [16]byte + if _, err := crand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +func (s *TunnelServer) reapSessionLater(token string) { + ttl := s.sessionTTL + if ttl <= 0 { + return + } + timer := time.NewTimer(ttl) + defer timer.Stop() + <-timer.C + + s.mu.Lock() + sess, ok := s.sessions[token] + if !ok { + s.mu.Unlock() + return + } + if time.Since(sess.lastActive) < ttl { + s.mu.Unlock() + return + } + delete(s.sessions, token) + s.mu.Unlock() + _ = sess.conn.Close() +} + +func (s *TunnelServer) getSession(token string) (*tunnelSession, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.sessions[token] + if !ok { + return nil, false + } + sess.lastActive = time.Now() + return sess, true +} + +func (s *TunnelServer) closeSession(token string) { + s.mu.Lock() + sess, ok := s.sessions[token] + if ok { + delete(s.sessions, token) + } + s.mu.Unlock() + if ok { + _ = sess.conn.Close() + } +} + +func (s *TunnelServer) pollPush(rawConn net.Conn, token string, body io.Reader) (HandleResult, net.Conn, error) { + sess, ok := s.getSession(token) + if !ok { + _ = writeSimpleHTTPResponse(rawConn, http.StatusForbidden, "forbidden") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + payload, err := io.ReadAll(io.LimitReader(body, 1<<20)) // 1MiB per request cap + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + lines := bytes.Split(payload, []byte{'\n'}) + for _, line := range lines { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + decoded := make([]byte, base64.StdEncoding.DecodedLen(len(line))) + n, decErr := base64.StdEncoding.Decode(decoded, line) + if decErr != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + if n == 0 { + continue + } + _ = sess.conn.SetWriteDeadline(time.Now().Add(30 * time.Second)) + _, werr := sess.conn.Write(decoded[:n]) + _ = sess.conn.SetWriteDeadline(time.Time{}) + if werr != nil { + s.closeSession(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusGone, "gone") + _ = rawConn.Close() + return HandleDone, nil, nil + } + } + + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil +} + +func (s *TunnelServer) streamPush(rawConn net.Conn, token string, body io.Reader) (HandleResult, net.Conn, error) { + sess, ok := s.getSession(token) + if !ok { + _ = writeSimpleHTTPResponse(rawConn, http.StatusForbidden, "forbidden") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + const maxUploadBytes = 1 << 20 + payload, err := io.ReadAll(io.LimitReader(body, maxUploadBytes+1)) + if err != nil { + _ = writeSimpleHTTPResponse(rawConn, http.StatusBadRequest, "bad request") + _ = rawConn.Close() + return HandleDone, nil, nil + } + if len(payload) > maxUploadBytes { + _ = writeSimpleHTTPResponse(rawConn, http.StatusRequestEntityTooLarge, "too large") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + if len(payload) > 0 { + _ = sess.conn.SetWriteDeadline(time.Now().Add(30 * time.Second)) + _, werr := sess.conn.Write(payload) + _ = sess.conn.SetWriteDeadline(time.Time{}) + if werr != nil { + s.closeSession(token) + _ = writeSimpleHTTPResponse(rawConn, http.StatusGone, "gone") + _ = rawConn.Close() + return HandleDone, nil, nil + } + } + + _ = writeSimpleHTTPResponse(rawConn, http.StatusOK, "") + _ = rawConn.Close() + return HandleDone, nil, nil +} + +func (s *TunnelServer) streamPull(rawConn net.Conn, token string) (HandleResult, net.Conn, error) { + sess, ok := s.getSession(token) + if !ok { + _ = writeSimpleHTTPResponse(rawConn, http.StatusForbidden, "forbidden") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + // Streaming response (chunked) with raw bytes (no base64 framing). + if err := writeTunnelResponseHeader(rawConn); err != nil { + _ = rawConn.Close() + return HandleDone, nil, err + } + + bw := bufio.NewWriterSize(rawConn, 32*1024) + cw := httputil.NewChunkedWriter(bw) + defer func() { + _ = cw.Close() + _, _ = bw.WriteString("\r\n") + _ = bw.Flush() + _ = rawConn.Close() + }() + + buf := make([]byte, 32*1024) + for { + _ = sess.conn.SetReadDeadline(time.Now().Add(s.pullReadTimeout)) + n, err := sess.conn.Read(buf) + if n > 0 { + _, _ = cw.Write(buf[:n]) + _ = bw.Flush() + } + if err != nil { + if errors.Is(err, os.ErrDeadlineExceeded) { + // End this long-poll response; client will re-issue. + return HandleDone, nil, nil + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed) { + return HandleDone, nil, nil + } + s.closeSession(token) + return HandleDone, nil, nil + } + } +} + +func (s *TunnelServer) pollPull(rawConn net.Conn, token string) (HandleResult, net.Conn, error) { + sess, ok := s.getSession(token) + if !ok { + _ = writeSimpleHTTPResponse(rawConn, http.StatusForbidden, "forbidden") + _ = rawConn.Close() + return HandleDone, nil, nil + } + + // Streaming response (chunked) with base64 lines. + if err := writeTunnelResponseHeader(rawConn); err != nil { + _ = rawConn.Close() + return HandleDone, nil, err + } + + bw := bufio.NewWriterSize(rawConn, 32*1024) + cw := httputil.NewChunkedWriter(bw) + defer func() { + _ = cw.Close() + _, _ = bw.WriteString("\r\n") + _ = bw.Flush() + _ = rawConn.Close() + }() + + buf := make([]byte, 32*1024) + for { + _ = sess.conn.SetReadDeadline(time.Now().Add(s.pullReadTimeout)) + n, err := sess.conn.Read(buf) + if n > 0 { + line := make([]byte, base64.StdEncoding.EncodedLen(n)) + base64.StdEncoding.Encode(line, buf[:n]) + _, _ = cw.Write(append(line, '\n')) + _ = bw.Flush() + } + if err != nil { + if errors.Is(err, os.ErrDeadlineExceeded) { + // Keepalive: send an empty line then end this long-poll response. + _, _ = cw.Write([]byte("\n")) + _ = bw.Flush() + return HandleDone, nil, nil + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed) { + return HandleDone, nil, nil + } + s.closeSession(token) + return HandleDone, nil, nil + } + } +} diff --git a/mihomo/transport/sudoku/obfs/sudoku/conn.go b/mihomo/transport/sudoku/obfs/sudoku/conn.go new file mode 100644 index 0000000000..d09c8a68f2 --- /dev/null +++ b/mihomo/transport/sudoku/obfs/sudoku/conn.go @@ -0,0 +1,212 @@ +package sudoku + +import ( + "bufio" + "bytes" + crypto_rand "crypto/rand" + "encoding/binary" + "errors" + "math/rand" + "net" + "sync" +) + +const IOBufferSize = 32 * 1024 + +var perm4 = [24][4]byte{ + {0, 1, 2, 3}, + {0, 1, 3, 2}, + {0, 2, 1, 3}, + {0, 2, 3, 1}, + {0, 3, 1, 2}, + {0, 3, 2, 1}, + {1, 0, 2, 3}, + {1, 0, 3, 2}, + {1, 2, 0, 3}, + {1, 2, 3, 0}, + {1, 3, 0, 2}, + {1, 3, 2, 0}, + {2, 0, 1, 3}, + {2, 0, 3, 1}, + {2, 1, 0, 3}, + {2, 1, 3, 0}, + {2, 3, 0, 1}, + {2, 3, 1, 0}, + {3, 0, 1, 2}, + {3, 0, 2, 1}, + {3, 1, 0, 2}, + {3, 1, 2, 0}, + {3, 2, 0, 1}, + {3, 2, 1, 0}, +} + +type Conn struct { + net.Conn + table *Table + reader *bufio.Reader + recorder *bytes.Buffer + recording bool + recordLock sync.Mutex + + rawBuf []byte + pendingData []byte + hintBuf []byte + + rng *rand.Rand + paddingRate float32 +} + +func NewConn(c net.Conn, table *Table, pMin, pMax int, record bool) *Conn { + var seedBytes [8]byte + if _, err := crypto_rand.Read(seedBytes[:]); err != nil { + binary.BigEndian.PutUint64(seedBytes[:], uint64(rand.Int63())) + } + seed := int64(binary.BigEndian.Uint64(seedBytes[:])) + localRng := rand.New(rand.NewSource(seed)) + + min := float32(pMin) / 100.0 + rng := float32(pMax-pMin) / 100.0 + rate := min + localRng.Float32()*rng + + sc := &Conn{ + Conn: c, + table: table, + reader: bufio.NewReaderSize(c, IOBufferSize), + rawBuf: make([]byte, IOBufferSize), + pendingData: make([]byte, 0, 4096), + hintBuf: make([]byte, 0, 4), + rng: localRng, + paddingRate: rate, + } + if record { + sc.recorder = new(bytes.Buffer) + sc.recording = true + } + return sc +} + +func (sc *Conn) StopRecording() { + sc.recordLock.Lock() + sc.recording = false + sc.recorder = nil + sc.recordLock.Unlock() +} + +func (sc *Conn) GetBufferedAndRecorded() []byte { + if sc == nil { + return nil + } + + sc.recordLock.Lock() + defer sc.recordLock.Unlock() + + var recorded []byte + if sc.recorder != nil { + recorded = sc.recorder.Bytes() + } + + buffered := sc.reader.Buffered() + if buffered > 0 { + peeked, _ := sc.reader.Peek(buffered) + full := make([]byte, len(recorded)+len(peeked)) + copy(full, recorded) + copy(full[len(recorded):], peeked) + return full + } + return recorded +} + +func (sc *Conn) Write(p []byte) (n int, err error) { + if len(p) == 0 { + return 0, nil + } + + outCapacity := len(p) * 6 + out := make([]byte, 0, outCapacity) + pads := sc.table.PaddingPool + padLen := len(pads) + + for _, b := range p { + if sc.rng.Float32() < sc.paddingRate { + out = append(out, pads[sc.rng.Intn(padLen)]) + } + + puzzles := sc.table.EncodeTable[b] + puzzle := puzzles[sc.rng.Intn(len(puzzles))] + + perm := perm4[sc.rng.Intn(len(perm4))] + for _, idx := range perm { + if sc.rng.Float32() < sc.paddingRate { + out = append(out, pads[sc.rng.Intn(padLen)]) + } + out = append(out, puzzle[idx]) + } + } + + if sc.rng.Float32() < sc.paddingRate { + out = append(out, pads[sc.rng.Intn(padLen)]) + } + + _, err = sc.Conn.Write(out) + return len(p), err +} + +func (sc *Conn) Read(p []byte) (n int, err error) { + if len(sc.pendingData) > 0 { + n = copy(p, sc.pendingData) + if n == len(sc.pendingData) { + sc.pendingData = sc.pendingData[:0] + } else { + sc.pendingData = sc.pendingData[n:] + } + return n, nil + } + + for { + if len(sc.pendingData) > 0 { + break + } + + nr, rErr := sc.reader.Read(sc.rawBuf) + if nr > 0 { + chunk := sc.rawBuf[:nr] + sc.recordLock.Lock() + if sc.recording { + sc.recorder.Write(chunk) + } + sc.recordLock.Unlock() + + for _, b := range chunk { + if !sc.table.layout.isHint(b) { + continue + } + + sc.hintBuf = append(sc.hintBuf, b) + if len(sc.hintBuf) == 4 { + key := packHintsToKey([4]byte{sc.hintBuf[0], sc.hintBuf[1], sc.hintBuf[2], sc.hintBuf[3]}) + val, ok := sc.table.DecodeMap[key] + if !ok { + return 0, errors.New("INVALID_SUDOKU_MAP_MISS") + } + sc.pendingData = append(sc.pendingData, val) + sc.hintBuf = sc.hintBuf[:0] + } + } + } + + if rErr != nil { + return 0, rErr + } + if len(sc.pendingData) > 0 { + break + } + } + + n = copy(p, sc.pendingData) + if n == len(sc.pendingData) { + sc.pendingData = sc.pendingData[:0] + } else { + sc.pendingData = sc.pendingData[n:] + } + return n, nil +} diff --git a/mihomo/transport/sudoku/obfs/sudoku/grid.go b/mihomo/transport/sudoku/obfs/sudoku/grid.go new file mode 100644 index 0000000000..3e802989d3 --- /dev/null +++ b/mihomo/transport/sudoku/obfs/sudoku/grid.go @@ -0,0 +1,46 @@ +package sudoku + +// Grid represents a 4x4 sudoku grid +type Grid [16]uint8 + +// GenerateAllGrids generates all valid 4x4 Sudoku grids +func GenerateAllGrids() []Grid { + var grids []Grid + var g Grid + var backtrack func(int) + + backtrack = func(idx int) { + if idx == 16 { + grids = append(grids, g) + return + } + row, col := idx/4, idx%4 + br, bc := (row/2)*2, (col/2)*2 + for num := uint8(1); num <= 4; num++ { + valid := true + for i := 0; i < 4; i++ { + if g[row*4+i] == num || g[i*4+col] == num { + valid = false + break + } + } + if valid { + for r := 0; r < 2; r++ { + for c := 0; c < 2; c++ { + if g[(br+r)*4+(bc+c)] == num { + valid = false + break + } + } + } + } + if valid { + g[idx] = num + backtrack(idx + 1) + g[idx] = 0 + } + } + } + backtrack(0) + return grids +} diff --git a/mihomo/transport/sudoku/obfs/sudoku/layout.go b/mihomo/transport/sudoku/obfs/sudoku/layout.go new file mode 100644 index 0000000000..72c569f5cd --- /dev/null +++ b/mihomo/transport/sudoku/obfs/sudoku/layout.go @@ -0,0 +1,204 @@ +package sudoku + +import ( + "fmt" + "math/bits" + "sort" + "strings" +) + +type byteLayout struct { + name string + hintMask byte + hintValue byte + padMarker byte + paddingPool []byte + + encodeHint func(val, pos byte) byte + encodeGroup func(group byte) byte + decodeGroup func(b byte) (byte, bool) +} + +func (l *byteLayout) isHint(b byte) bool { + return (b & l.hintMask) == l.hintValue +} + +// resolveLayout picks the byte layout based on ASCII preference and optional custom pattern. +// ASCII always wins if requested. Custom patterns are ignored when ASCII is preferred. +func resolveLayout(mode string, customPattern string) (*byteLayout, error) { + switch strings.ToLower(mode) { + case "ascii", "prefer_ascii": + return newASCIILayout(), nil + case "entropy", "prefer_entropy", "": + // fallback to entropy unless a custom pattern is provided + default: + return nil, fmt.Errorf("invalid ascii mode: %s", mode) + } + + if strings.TrimSpace(customPattern) != "" { + return newCustomLayout(customPattern) + } + return newEntropyLayout(), nil +} + +func newASCIILayout() *byteLayout { + padding := make([]byte, 0, 32) + for i := 0; i < 32; i++ { + padding = append(padding, byte(0x20+i)) + } + return &byteLayout{ + name: "ascii", + hintMask: 0x40, + hintValue: 0x40, + padMarker: 0x3F, + paddingPool: padding, + encodeHint: func(val, pos byte) byte { + return 0x40 | ((val & 0x03) << 4) | (pos & 0x0F) + }, + encodeGroup: func(group byte) byte { + return 0x40 | (group & 0x3F) + }, + decodeGroup: func(b byte) (byte, bool) { + if (b & 0x40) == 0 { + return 0, false + } + return b & 0x3F, true + }, + } +} + +func newEntropyLayout() *byteLayout { + padding := make([]byte, 0, 16) + for i := 0; i < 8; i++ { + padding = append(padding, byte(0x80+i)) + padding = append(padding, byte(0x10+i)) + } + return &byteLayout{ + name: "entropy", + hintMask: 0x90, + hintValue: 0x00, + padMarker: 0x80, + paddingPool: padding, + encodeHint: func(val, pos byte) byte { + return ((val & 0x03) << 5) | (pos & 0x0F) + }, + encodeGroup: func(group byte) byte { + v := group & 0x3F + return ((v & 0x30) << 1) | (v & 0x0F) + }, + decodeGroup: func(b byte) (byte, bool) { + if (b & 0x90) != 0 { + return 0, false + } + return ((b >> 1) & 0x30) | (b & 0x0F), true + }, + } +} + +func newCustomLayout(pattern string) (*byteLayout, error) { + cleaned := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(pattern), " ", "")) + if len(cleaned) != 8 { + return nil, fmt.Errorf("custom table must have 8 symbols, got %d", len(cleaned)) + } + + var xBits, pBits, vBits []uint8 + for i, c := range cleaned { + bit := uint8(7 - i) + switch c { + case 'x': + xBits = append(xBits, bit) + case 'p': + pBits = append(pBits, bit) + case 'v': + vBits = append(vBits, bit) + default: + return nil, fmt.Errorf("invalid char %q in custom table", c) + } + } + + if len(xBits) != 2 || len(pBits) != 2 || len(vBits) != 4 { + return nil, fmt.Errorf("custom table must contain exactly 2 x, 2 p, 4 v") + } + + xMask := byte(0) + for _, b := range xBits { + xMask |= 1 << b + } + + encodeBits := func(val, pos byte, dropX int) byte { + var out byte + out |= xMask + if dropX >= 0 { + out &^= 1 << xBits[dropX] + } + if (val & 0x02) != 0 { + out |= 1 << pBits[0] + } + if (val & 0x01) != 0 { + out |= 1 << pBits[1] + } + for i, bit := range vBits { + if (pos>>(3-uint8(i)))&0x01 == 1 { + out |= 1 << bit + } + } + return out + } + + decodeGroup := func(b byte) (byte, bool) { + if (b & xMask) != xMask { + return 0, false + } + var val, pos byte + if b&(1<= 5 { + if _, ok := paddingSet[b]; !ok { + paddingSet[b] = struct{}{} + padding = append(padding, b) + } + } + } + } + } + sort.Slice(padding, func(i, j int) bool { return padding[i] < padding[j] }) + if len(padding) == 0 { + return nil, fmt.Errorf("custom table produced empty padding pool") + } + + return &byteLayout{ + name: fmt.Sprintf("custom(%s)", cleaned), + hintMask: xMask, + hintValue: xMask, + padMarker: padding[0], + paddingPool: padding, + encodeHint: func(val, pos byte) byte { + return encodeBits(val, pos, -1) + }, + encodeGroup: func(group byte) byte { + val := (group >> 4) & 0x03 + pos := group & 0x0F + return encodeBits(val, pos, -1) + }, + decodeGroup: decodeGroup, + }, nil +} diff --git a/mihomo/transport/sudoku/obfs/sudoku/packed.go b/mihomo/transport/sudoku/obfs/sudoku/packed.go new file mode 100644 index 0000000000..567afe73bc --- /dev/null +++ b/mihomo/transport/sudoku/obfs/sudoku/packed.go @@ -0,0 +1,332 @@ +package sudoku + +import ( + "bufio" + crypto_rand "crypto/rand" + "encoding/binary" + "io" + "math/rand" + "net" + "sync" +) + +const ( + // 每次从 RNG 获取批量随机数的缓存大小,减少 RNG 函数调用开销 + RngBatchSize = 128 +) + +// 1. 使用 12字节->16组 的块处理优化 Write (减少循环开销) +// 2. 使用浮点随机概率判断 Padding,与纯 Sudoku 保持流量特征一致 +// 3. Read 使用 copy 移动避免底层数组泄漏 +type PackedConn struct { + net.Conn + table *Table + reader *bufio.Reader + + // 读缓冲 + rawBuf []byte + pendingData []byte // 解码后尚未被 Read 取走的字节 + + // 写缓冲与状态 + writeMu sync.Mutex + writeBuf []byte + bitBuf uint64 // 暂存的位数据 + bitCount int // 暂存的位数 + + // 读状态 + readBitBuf uint64 + readBits int + + // 随机数与填充控制 - 使用浮点随机,与 Conn 一致 + rng *rand.Rand + paddingRate float32 // 与 Conn 保持一致的随机概率模型 + padMarker byte + padPool []byte +} + +func NewPackedConn(c net.Conn, table *Table, pMin, pMax int) *PackedConn { + var seedBytes [8]byte + if _, err := crypto_rand.Read(seedBytes[:]); err != nil { + binary.BigEndian.PutUint64(seedBytes[:], uint64(rand.Int63())) + } + seed := int64(binary.BigEndian.Uint64(seedBytes[:])) + localRng := rand.New(rand.NewSource(seed)) + + // 与 Conn 保持一致的 padding 概率计算 + min := float32(pMin) / 100.0 + rng := float32(pMax-pMin) / 100.0 + rate := min + localRng.Float32()*rng + + pc := &PackedConn{ + Conn: c, + table: table, + reader: bufio.NewReaderSize(c, IOBufferSize), + rawBuf: make([]byte, IOBufferSize), + pendingData: make([]byte, 0, 4096), + writeBuf: make([]byte, 0, 4096), + rng: localRng, + paddingRate: rate, + } + + pc.padMarker = table.layout.padMarker + for _, b := range table.PaddingPool { + if b != pc.padMarker { + pc.padPool = append(pc.padPool, b) + } + } + if len(pc.padPool) == 0 { + pc.padPool = append(pc.padPool, pc.padMarker) + } + return pc +} + +// maybeAddPadding 内联辅助:根据浮点概率插入 padding +func (pc *PackedConn) maybeAddPadding(out []byte) []byte { + if pc.rng.Float32() < pc.paddingRate { + out = append(out, pc.getPaddingByte()) + } + return out +} + +// Write 极致优化版 - 批量处理 12 字节 +func (pc *PackedConn) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + 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 + 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) + 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.maybeAddPadding(out) + out = append(out, pc.encodeGroup(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 + + g1 := (b1 >> 2) & 0x3F + g2 := ((b1 & 0x03) << 4) | ((b2 >> 4) & 0x0F) + 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)) + } + } + + // 4. 处理剩余的 3 字节块 + for i+2 < n { + b1, b2, b3 := p[i], p[i+1], p[i+2] + i += 3 + + g1 := (b1 >> 2) & 0x3F + g2 := ((b1 & 0x03) << 4) | ((b2 >> 4) & 0x0F) + 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)) + } + + // 5. 尾部处理 (Tail Path) - 处理剩余的 1 或 2 个字节 + for ; i < n; i++ { + b := p[i] + pc.bitBuf = (pc.bitBuf << 8) | uint64(b) + 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.maybeAddPadding(out) + out = append(out, pc.encodeGroup(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 = 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 + } + pc.writeBuf = out[:0] + return len(p), nil +} + +// Flush 处理最后不足 6 bit 的情况 +func (pc *PackedConn) Flush() error { + pc.writeMu.Lock() + defer pc.writeMu.Unlock() + + out := pc.writeBuf[:0] + if pc.bitCount > 0 { + group := byte(pc.bitBuf << (6 - pc.bitCount)) + pc.bitBuf = 0 + pc.bitCount = 0 + + out = append(out, pc.encodeGroup(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 err + } + 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] + } + return n, nil + } + + // 2. 循环读取直到解出数据或出错 + for { + nr, rErr := pc.reader.Read(pc.rawBuf) + if nr > 0 { + // 缓存频繁访问的变量 + rBuf := pc.readBitBuf + rBits := pc.readBits + padMarker := pc.padMarker + layout := pc.table.layout + + for _, b := range pc.rawBuf[:nr] { + if !layout.isHint(b) { + if b == padMarker { + rBuf = 0 + rBits = 0 + } + continue + } + + group, ok := layout.decodeGroup(b) + if !ok { + return 0, ErrInvalidSudokuMapMiss + } + + rBuf = (rBuf << 6) | uint64(group) + rBits += 6 + + if rBits >= 8 { + rBits -= 8 + val := byte(rBuf >> rBits) + pc.pendingData = append(pc.pendingData, val) + } + } + + pc.readBitBuf = rBuf + pc.readBits = rBits + } + + if rErr != nil { + if rErr == io.EOF { + pc.readBitBuf = 0 + pc.readBits = 0 + } + if len(pc.pendingData) > 0 { + break + } + return 0, rErr + } + + if len(pc.pendingData) > 0 { + break + } + } + + // 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 +} + +// 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) +} diff --git a/mihomo/transport/sudoku/obfs/sudoku/table.go b/mihomo/transport/sudoku/obfs/sudoku/table.go new file mode 100644 index 0000000000..d86e642fb8 --- /dev/null +++ b/mihomo/transport/sudoku/obfs/sudoku/table.go @@ -0,0 +1,153 @@ +package sudoku + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + "log" + "math/rand" + "time" +) + +var ( + ErrInvalidSudokuMapMiss = errors.New("INVALID_SUDOKU_MAP_MISS") +) + +type Table struct { + EncodeTable [256][][4]byte + DecodeMap map[uint32]byte + PaddingPool []byte + IsASCII bool // 标记当前模式 + layout *byteLayout +} + +// NewTable initializes the obfuscation tables with built-in layouts. +// Equivalent to calling NewTableWithCustom(key, mode, ""). +func NewTable(key string, mode string) *Table { + t, err := NewTableWithCustom(key, mode, "") + if err != nil { + log.Panicf("failed to build table: %v", err) + } + return t +} + +// NewTableWithCustom initializes obfuscation tables using either predefined or custom layouts. +// mode: "prefer_ascii" or "prefer_entropy". If a custom pattern is provided, ASCII mode still takes precedence. +// The customPattern must contain 8 characters with exactly 2 x, 2 p, and 4 v (case-insensitive). +func NewTableWithCustom(key string, mode string, customPattern string) (*Table, error) { + start := time.Now() + + layout, err := resolveLayout(mode, customPattern) + if err != nil { + return nil, err + } + + t := &Table{ + DecodeMap: make(map[uint32]byte), + IsASCII: layout.name == "ascii", + layout: layout, + } + t.PaddingPool = append(t.PaddingPool, layout.paddingPool...) + + // 生成数独网格 (逻辑不变) + allGrids := GenerateAllGrids() + h := sha256.New() + h.Write([]byte(key)) + seed := int64(binary.BigEndian.Uint64(h.Sum(nil)[:8])) + rng := rand.New(rand.NewSource(seed)) + + shuffledGrids := make([]Grid, 288) + copy(shuffledGrids, allGrids) + rng.Shuffle(len(shuffledGrids), func(i, j int) { + shuffledGrids[i], shuffledGrids[j] = shuffledGrids[j], shuffledGrids[i] + }) + + // 预计算组合 + var combinations [][]int + var combine func(int, int, []int) + combine = func(s, k int, c []int) { + if k == 0 { + tmp := make([]int, len(c)) + copy(tmp, c) + combinations = append(combinations, tmp) + return + } + for i := s; i <= 16-k; i++ { + c = append(c, i) + combine(i+1, k-1, c) + c = c[:len(c)-1] + } + } + combine(0, 4, []int{}) + + // 构建映射表 + for byteVal := 0; byteVal < 256; byteVal++ { + targetGrid := shuffledGrids[byteVal] + for _, positions := range combinations { + var currentHints [4]byte + + // 1. 计算抽象提示 (Abstract Hints) + // 我们先计算出 val 和 pos,后面再根据模式编码成 byte + var rawParts [4]struct{ val, pos byte } + + for i, pos := range positions { + val := targetGrid[pos] // 1..4 + rawParts[i] = struct{ val, pos byte }{val, uint8(pos)} + } + + // 检查唯一性 (数独逻辑) + matchCount := 0 + for _, g := range allGrids { + match := true + for _, p := range rawParts { + if g[p.pos] != p.val { + match = false + break + } + } + if match { + matchCount++ + if matchCount > 1 { + break + } + } + } + + if matchCount == 1 { + // 唯一确定,生成最终编码字节 + for i, p := range rawParts { + currentHints[i] = t.layout.encodeHint(p.val-1, p.pos) + } + + t.EncodeTable[byteVal] = append(t.EncodeTable[byteVal], currentHints) + // 生成解码键 (需要对 Hints 进行排序以忽略传输顺序) + key := packHintsToKey(currentHints) + t.DecodeMap[key] = byte(byteVal) + } + } + } + log.Printf("[Init] Sudoku Tables initialized (%s) in %v", layout.name, time.Since(start)) + return t, nil +} + +func packHintsToKey(hints [4]byte) uint32 { + // Sorting network for 4 elements (Bubble sort unrolled) + // Swap if a > b + if hints[0] > hints[1] { + hints[0], hints[1] = hints[1], hints[0] + } + if hints[2] > hints[3] { + hints[2], hints[3] = hints[3], hints[2] + } + if hints[0] > hints[2] { + hints[0], hints[2] = hints[2], hints[0] + } + if hints[1] > hints[3] { + hints[1], hints[3] = hints[3], hints[1] + } + if hints[1] > hints[2] { + hints[1], hints[2] = hints[2], hints[1] + } + + return uint32(hints[0])<<24 | uint32(hints[1])<<16 | uint32(hints[2])<<8 | uint32(hints[3]) +} diff --git a/mihomo/transport/sudoku/obfs/sudoku/table_set.go b/mihomo/transport/sudoku/obfs/sudoku/table_set.go new file mode 100644 index 0000000000..59d3c98f1c --- /dev/null +++ b/mihomo/transport/sudoku/obfs/sudoku/table_set.go @@ -0,0 +1,38 @@ +package sudoku + +import "fmt" + +// TableSet is a small helper for managing multiple Sudoku tables (e.g. for per-connection rotation). +// It is intentionally decoupled from the tunnel/app layers. +type TableSet struct { + Tables []*Table +} + +// NewTableSet builds one or more tables from key/mode and a list of custom X/P/V patterns. +// If patterns is empty, it builds a single default table (customPattern=""). +func NewTableSet(key string, mode string, patterns []string) (*TableSet, error) { + if len(patterns) == 0 { + t, err := NewTableWithCustom(key, mode, "") + if err != nil { + return nil, err + } + return &TableSet{Tables: []*Table{t}}, nil + } + + tables := make([]*Table, 0, len(patterns)) + for i, pattern := range patterns { + t, err := NewTableWithCustom(key, mode, pattern) + if err != nil { + return nil, fmt.Errorf("build table[%d] (%q): %w", i, pattern, err) + } + tables = append(tables, t) + } + return &TableSet{Tables: tables}, nil +} + +func (ts *TableSet) Candidates() []*Table { + if ts == nil { + return nil + } + return ts.Tables +} diff --git a/mihomo/transport/sudoku/obfs_writer.go b/mihomo/transport/sudoku/obfs_writer.go index f980359119..3dc94b4eba 100644 --- a/mihomo/transport/sudoku/obfs_writer.go +++ b/mihomo/transport/sudoku/obfs_writer.go @@ -6,7 +6,7 @@ import ( "math/rand" "net" - "github.com/saba-futai/sudoku/pkg/obfs/sudoku" + "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" ) // perm4 matches github.com/saba-futai/sudoku/pkg/obfs/sudoku perm4. diff --git a/mihomo/transport/sudoku/table_probe.go b/mihomo/transport/sudoku/table_probe.go index f12c172226..8def6fd488 100644 --- a/mihomo/transport/sudoku/table_probe.go +++ b/mihomo/transport/sudoku/table_probe.go @@ -10,26 +10,12 @@ import ( "net" "time" - "github.com/saba-futai/sudoku/apis" - "github.com/saba-futai/sudoku/pkg/crypto" - "github.com/saba-futai/sudoku/pkg/obfs/sudoku" + "github.com/metacubex/mihomo/transport/sudoku/crypto" + "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" ) -func tableCandidates(cfg *apis.ProtocolConfig) []*sudoku.Table { - if cfg == nil { - return nil - } - if len(cfg.Tables) > 0 { - return cfg.Tables - } - if cfg.Table != nil { - return []*sudoku.Table{cfg.Table} - } - return nil -} - -func pickClientTable(cfg *apis.ProtocolConfig) (*sudoku.Table, byte, error) { - candidates := tableCandidates(cfg) +func pickClientTable(cfg *ProtocolConfig) (*sudoku.Table, byte, error) { + candidates := cfg.tableCandidates() if len(candidates) == 0 { return nil, 0, fmt.Errorf("no table configured") } @@ -62,7 +48,7 @@ func drainBuffered(r *bufio.Reader) ([]byte, error) { return out, err } -func probeHandshakeBytes(probe []byte, cfg *apis.ProtocolConfig, table *sudoku.Table) error { +func probeHandshakeBytes(probe []byte, cfg *ProtocolConfig, table *sudoku.Table) error { rc := &readOnlyConn{Reader: bytes.NewReader(probe)} _, obfsConn := buildServerObfsConn(rc, cfg, table, false) cConn, err := crypto.NewAEADConn(obfsConn, cfg.Key, cfg.AEADMethod) @@ -90,7 +76,7 @@ func probeHandshakeBytes(probe []byte, cfg *apis.ProtocolConfig, table *sudoku.T return nil } -func selectTableByProbe(r *bufio.Reader, cfg *apis.ProtocolConfig, tables []*sudoku.Table) (*sudoku.Table, []byte, error) { +func selectTableByProbe(r *bufio.Reader, cfg *ProtocolConfig, tables []*sudoku.Table) (*sudoku.Table, []byte, error) { const ( maxProbeBytes = 64 * 1024 readChunk = 4 * 1024 diff --git a/mihomo/transport/sudoku/tables.go b/mihomo/transport/sudoku/tables.go index 429a4ab327..2630ea5256 100644 --- a/mihomo/transport/sudoku/tables.go +++ b/mihomo/transport/sudoku/tables.go @@ -3,7 +3,7 @@ package sudoku import ( "strings" - "github.com/saba-futai/sudoku/pkg/obfs/sudoku" + "github.com/metacubex/mihomo/transport/sudoku/obfs/sudoku" ) // NewTablesWithCustomPatterns builds one or more obfuscation tables from x/v/p custom patterns. diff --git a/openwrt-packages/luci-app-ddnsto/luasrc/controller/ddnsto.lua b/openwrt-packages/luci-app-ddnsto/luasrc/controller/ddnsto.lua index e4f7af139f..50a4ca8b45 100644 --- a/openwrt-packages/luci-app-ddnsto/luasrc/controller/ddnsto.lua +++ b/openwrt-packages/luci-app-ddnsto/luasrc/controller/ddnsto.lua @@ -582,55 +582,67 @@ function api_status() local version = get_command("/usr/sbin/ddnstod -v") - -- Check connectivity to the tunnel server (BusyBox nc may not support -z/-w flags) + -- Check connectivity to the tunnel server via ping local function resolve_host(host) - local out = sys.exec(string.format("nslookup %s 2>/dev/null", host)) or "" + -- Prefer public DNS to avoid local resolver issues; fallback to default resolver + local out = sys.exec(string.format("nslookup %s 223.5.5.5 2>/dev/null", host)) or "" + if out == "" then + out = sys.exec(string.format("nslookup %s 8.8.8.8 2>/dev/null", host)) or "" + end + if out == "" then + out = sys.exec(string.format("nslookup %s 2>/dev/null", host)) or "" + end local ip = out:match("Address 1:%s*([%d%.]+)") or out:match("Address:%s*([%d%.]+)") return ip or "" end - local tunnel_ip = resolve_host("tunnel.kooldns.cn") - local tunnel_target = tunnel_ip ~= "" and tunnel_ip or "tunnel.kooldns.cn" - local tunnel_err = nil - local tunnel_ret = -1 + local tunnel_targets = {} + local resolved_ip = resolve_host("tunnel.kooldns.cn") + if resolved_ip ~= "" then table.insert(tunnel_targets, resolved_ip) end + table.insert(tunnel_targets, "tunnel.kooldns.cn") + -- Fallback known IP to avoid DNS issues + table.insert(tunnel_targets, "125.39.21.43") - if tunnel_ip == "" then + -- Deduplicate targets + do + local seen = {} + local uniq = {} + for _, t in ipairs(tunnel_targets) do + if not seen[t] then + seen[t] = true + table.insert(uniq, t) + end + end + tunnel_targets = uniq + end + + local function connect_target(target) + -- BusyBox ping: -c 1 (one packet), -W 2 (2s timeout) + local ret = sys.call(string.format("ping -c 1 -W 2 %s >/dev/null 2>&1", target)) + if ret == 0 then + return 0, nil + end + return ret, string.format("ping exit %d to %s", ret, target) + end + + local tunnel_ok = false + local tunnel_err = nil + + if #tunnel_targets == 0 then tunnel_err = "resolve tunnel.kooldns.cn failed" else - local has_timeout = (sys.call("command -v timeout >/dev/null 2>&1") == 0) - if has_timeout then - -- Prefer timeout if available - tunnel_ret = sys.call(string.format("timeout 3 nc %s 4445 /dev/null 2>&1", tunnel_target)) - else - -- Fallback: background nc and kill after ~3s if still running - local tmpl = table.concat({ - "sh -c '", - "nc %s 4445 /dev/null 2>&1 & pid=$!;", - "for i in 1 2 3; do", - " sleep 1;", - " if ! kill -0 $pid 2>/dev/null; then", - " wait $pid;", - " exit $?;", - " fi;", - "done;", - "kill -9 $pid >/dev/null 2>&1;", - "wait $pid >/dev/null 2>&1;", - "exit 1", - "'" - }, " ") - tunnel_ret = sys.call(string.format(tmpl, tunnel_target)) - end - if tunnel_ret ~= 0 then - if has_timeout then - tunnel_err = string.format("nc exit %d", tunnel_ret) + for _, target in ipairs(tunnel_targets) do + local ret, err = connect_target(target) + if ret == 0 then + tunnel_ok = true + tunnel_err = nil + break else - tunnel_err = string.format("nc exit %d (no timeout available, BusyBox nc limited)", tunnel_ret) + tunnel_err = err end end end - local tunnel_ok = (tunnel_ret == 0) - local did = "" do local idx = index diff --git a/openwrt-packages/luci-app-ddnsto/luasrc/view/ddnsto/main.htm b/openwrt-packages/luci-app-ddnsto/luasrc/view/ddnsto/main.htm index 1248897306..e34af8f724 100644 --- a/openwrt-packages/luci-app-ddnsto/luasrc/view/ddnsto/main.htm +++ b/openwrt-packages/luci-app-ddnsto/luasrc/view/ddnsto/main.htm @@ -1,15 +1,24 @@ +<%+header%> + - - - - - - DDNSTO Plugin Settings Page - - +
+
+
- -
- - - \ No newline at end of file + +<%+footer%> diff --git a/openwrt-packages/luci-app-ddnsto/root/www/luci-static/ddnsto/index.js b/openwrt-packages/luci-app-ddnsto/root/www/luci-static/ddnsto/index.js index d52f86b9d8..c145a7904a 100644 --- a/openwrt-packages/luci-app-ddnsto/root/www/luci-static/ddnsto/index.js +++ b/openwrt-packages/luci-app-ddnsto/root/www/luci-static/ddnsto/index.js @@ -1,4 +1,4 @@ -(function(){const C=document.createElement("link").relList;if(C&&C.supports&&C.supports("modulepreload"))return;for(const R of document.querySelectorAll('link[rel="modulepreload"]'))U(R);new MutationObserver(R=>{for(const I of R)if(I.type==="childList")for(const Q of I.addedNodes)Q.tagName==="LINK"&&Q.rel==="modulepreload"&&U(Q)}).observe(document,{childList:!0,subtree:!0});function d(R){const I={};return R.integrity&&(I.integrity=R.integrity),R.referrerPolicy&&(I.referrerPolicy=R.referrerPolicy),R.crossOrigin==="use-credentials"?I.credentials="include":R.crossOrigin==="anonymous"?I.credentials="omit":I.credentials="same-origin",I}function U(R){if(R.ep)return;R.ep=!0;const I=d(R);fetch(R.href,I)}})();function Vd(S){return S&&S.__esModule&&Object.prototype.hasOwnProperty.call(S,"default")?S.default:S}var Ri={exports:{}},Cr={},Li={exports:{}},Z={};/** +(function(){const j=document.createElement("link").relList;if(j&&j.supports&&j.supports("modulepreload"))return;for(const R of document.querySelectorAll('link[rel="modulepreload"]'))$(R);new MutationObserver(R=>{for(const I of R)if(I.type==="childList")for(const Q of I.addedNodes)Q.tagName==="LINK"&&Q.rel==="modulepreload"&&$(Q)}).observe(document,{childList:!0,subtree:!0});function d(R){const I={};return R.integrity&&(I.integrity=R.integrity),R.referrerPolicy&&(I.referrerPolicy=R.referrerPolicy),R.crossOrigin==="use-credentials"?I.credentials="include":R.crossOrigin==="anonymous"?I.credentials="omit":I.credentials="same-origin",I}function $(R){if(R.ep)return;R.ep=!0;const I=d(R);fetch(R.href,I)}})();function Vd(S){return S&&S.__esModule&&Object.prototype.hasOwnProperty.call(S,"default")?S.default:S}var Li={exports:{}},Nr={},Ii={exports:{}},J={};/** * @license React * react.production.min.js * @@ -6,7 +6,7 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var Ru;function Hd(){if(Ru)return Z;Ru=1;var S=Symbol.for("react.element"),C=Symbol.for("react.portal"),d=Symbol.for("react.fragment"),U=Symbol.for("react.strict_mode"),R=Symbol.for("react.profiler"),I=Symbol.for("react.provider"),Q=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),A=Symbol.for("react.suspense"),b=Symbol.for("react.memo"),X=Symbol.for("react.lazy"),K=Symbol.iterator;function $(c){return c===null||typeof c!="object"?null:(c=K&&c[K]||c["@@iterator"],typeof c=="function"?c:null)}var q={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},ie=Object.assign,B={};function Y(c,v,F){this.props=c,this.context=v,this.refs=B,this.updater=F||q}Y.prototype.isReactComponent={},Y.prototype.setState=function(c,v){if(typeof c!="object"&&typeof c!="function"&&c!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,c,v,"setState")},Y.prototype.forceUpdate=function(c){this.updater.enqueueForceUpdate(this,c,"forceUpdate")};function Ee(){}Ee.prototype=Y.prototype;function we(c,v,F){this.props=c,this.context=v,this.refs=B,this.updater=F||q}var xe=we.prototype=new Ee;xe.constructor=we,ie(xe,Y.prototype),xe.isPureReactComponent=!0;var fe=Array.isArray,ee=Object.prototype.hasOwnProperty,Ce={current:null},Re={key:!0,ref:!0,__self:!0,__source:!0};function pe(c,v,F){var H,M={},J=null,re=null;if(v!=null)for(H in v.ref!==void 0&&(re=v.ref),v.key!==void 0&&(J=""+v.key),v)ee.call(v,H)&&!Re.hasOwnProperty(H)&&(M[H]=v[H]);var te=arguments.length-2;if(te===1)M.children=F;else if(1>>1,v=E[c];if(0>>1;cR(M,y))JR(re,M)?(E[c]=re,E[J]=y,c=J):(E[c]=M,E[H]=y,c=H);else if(JR(re,y))E[c]=re,E[J]=y,c=J;else break e}}return g}function R(E,g){var y=E.sortIndex-g.sortIndex;return y!==0?y:E.id-g.id}if(typeof performance=="object"&&typeof performance.now=="function"){var I=performance;S.unstable_now=function(){return I.now()}}else{var Q=Date,G=Q.now();S.unstable_now=function(){return Q.now()-G}}var A=[],b=[],X=1,K=null,$=3,q=!1,ie=!1,B=!1,Y=typeof setTimeout=="function"?setTimeout:null,Ee=typeof clearTimeout=="function"?clearTimeout:null,we=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function xe(E){for(var g=d(b);g!==null;){if(g.callback===null)U(b);else if(g.startTime<=E)U(b),g.sortIndex=g.expirationTime,C(A,g);else break;g=d(b)}}function fe(E){if(B=!1,xe(E),!ie)if(d(A)!==null)ie=!0,ze(ee);else{var g=d(b);g!==null&&W(fe,g.startTime-E)}}function ee(E,g){ie=!1,B&&(B=!1,Ee(pe),pe=-1),q=!0;var y=$;try{for(xe(g),K=d(A);K!==null&&(!(K.expirationTime>g)||E&&!Je());){var c=K.callback;if(typeof c=="function"){K.callback=null,$=K.priorityLevel;var v=c(K.expirationTime<=g);g=S.unstable_now(),typeof v=="function"?K.callback=v:K===d(A)&&U(A),xe(g)}else U(A);K=d(A)}if(K!==null)var F=!0;else{var H=d(b);H!==null&&W(fe,H.startTime-g),F=!1}return F}finally{K=null,$=y,q=!1}}var Ce=!1,Re=null,pe=-1,Ne=5,ke=-1;function Je(){return!(S.unstable_now()-keE||125c?(E.sortIndex=y,C(b,E),d(A)===null&&E===d(b)&&(B?(Ee(pe),pe=-1):B=!0,W(fe,y-c))):(E.sortIndex=v,C(A,E),ie||q||(ie=!0,ze(ee))),E},S.unstable_shouldYield=Je,S.unstable_wrapCallback=function(E){var g=$;return function(){var y=$;$=g;try{return E.apply(this,arguments)}finally{$=y}}}})(Oi)),Oi}var Du;function bd(){return Du||(Du=1,Mi.exports=Kd()),Mi.exports}/** + */var Ou;function Kd(){return Ou||(Ou=1,(function(S){function j(E,x){var g=E.length;E.push(x);e:for(;0>>1,v=E[u];if(0>>1;uR(G,g))OR(le,G)?(E[u]=le,E[O]=g,u=O):(E[u]=G,E[A]=g,u=A);else if(OR(le,g))E[u]=le,E[O]=g,u=O;else break e}}return x}function R(E,x){var g=E.sortIndex-x.sortIndex;return g!==0?g:E.id-x.id}if(typeof performance=="object"&&typeof performance.now=="function"){var I=performance;S.unstable_now=function(){return I.now()}}else{var Q=Date,Z=Q.now();S.unstable_now=function(){return Q.now()-Z}}var U=[],b=[],X=1,K=null,V=3,ee=!1,se=!1,B=!1,Y=typeof setTimeout=="function"?setTimeout:null,Ee=typeof clearTimeout=="function"?clearTimeout:null,xe=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function ke(E){for(var x=d(b);x!==null;){if(x.callback===null)$(b);else if(x.startTime<=E)$(b),x.sortIndex=x.expirationTime,j(U,x);else break;x=d(b)}}function pe(E){if(B=!1,ke(E),!se)if(d(U)!==null)se=!0,ze(te);else{var x=d(b);x!==null&&W(pe,x.startTime-E)}}function te(E,x){se=!1,B&&(B=!1,Ee(he),he=-1),ee=!0;var g=V;try{for(ke(x),K=d(U);K!==null&&(!(K.expirationTime>x)||E&&!Ke());){var u=K.callback;if(typeof u=="function"){K.callback=null,V=K.priorityLevel;var v=u(K.expirationTime<=x);x=S.unstable_now(),typeof v=="function"?K.callback=v:K===d(U)&&$(U),ke(x)}else $(U);K=d(U)}if(K!==null)var M=!0;else{var A=d(b);A!==null&&W(pe,A.startTime-x),M=!1}return M}finally{K=null,V=g,ee=!1}}var Ce=!1,Re=null,he=-1,Ne=5,ue=-1;function Ke(){return!(S.unstable_now()-ueE||125u?(E.sortIndex=g,j(b,E),d(U)===null&&E===d(b)&&(B?(Ee(he),he=-1):B=!0,W(pe,g-u))):(E.sortIndex=v,j(U,E),se||ee||(se=!0,ze(te))),E},S.unstable_shouldYield=Ke,S.unstable_wrapCallback=function(E){var x=V;return function(){var g=V;V=x;try{return E.apply(this,arguments)}finally{V=g}}}})(Di)),Di}var Du;function bd(){return Du||(Du=1,Oi.exports=Kd()),Oi.exports}/** * @license React * react-dom.production.min.js * @@ -30,19 +30,19 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var Fu;function Yd(){if(Fu)return Ze;Fu=1;var S=Fi(),C=bd();function d(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),A=Object.prototype.hasOwnProperty,b=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,X={},K={};function $(e){return A.call(K,e)?!0:A.call(X,e)?!1:b.test(e)?K[e]=!0:(X[e]=!0,!1)}function q(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function ie(e,t,n,r){if(t===null||typeof t>"u"||q(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function B(e,t,n,r,l,o,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=o,this.removeEmptyString=i}var Y={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){Y[e]=new B(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];Y[t]=new B(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){Y[e]=new B(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){Y[e]=new B(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){Y[e]=new B(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){Y[e]=new B(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){Y[e]=new B(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){Y[e]=new B(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){Y[e]=new B(e,5,!1,e.toLowerCase(),null,!1,!1)});var Ee=/[\-:]([a-z])/g;function we(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Ee,we);Y[t]=new B(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Ee,we);Y[t]=new B(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Ee,we);Y[t]=new B(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){Y[e]=new B(e,1,!1,e.toLowerCase(),null,!1,!1)}),Y.xlinkHref=new B("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){Y[e]=new B(e,1,!1,e.toLowerCase(),null,!0,!0)});function xe(e,t,n,r){var l=Y.hasOwnProperty(t)?Y[t]:null;(l!==null?l.type!==0:r||!(2"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),U=Object.prototype.hasOwnProperty,b=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,X={},K={};function V(e){return U.call(K,e)?!0:U.call(X,e)?!1:b.test(e)?K[e]=!0:(X[e]=!0,!1)}function ee(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function se(e,t,n,r){if(t===null||typeof t>"u"||ee(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function B(e,t,n,r,l,o,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=o,this.removeEmptyString=i}var Y={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){Y[e]=new B(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];Y[t]=new B(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){Y[e]=new B(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){Y[e]=new B(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){Y[e]=new B(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){Y[e]=new B(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){Y[e]=new B(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){Y[e]=new B(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){Y[e]=new B(e,5,!1,e.toLowerCase(),null,!1,!1)});var Ee=/[\-:]([a-z])/g;function xe(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Ee,xe);Y[t]=new B(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Ee,xe);Y[t]=new B(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Ee,xe);Y[t]=new B(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){Y[e]=new B(e,1,!1,e.toLowerCase(),null,!1,!1)}),Y.xlinkHref=new B("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){Y[e]=new B(e,1,!1,e.toLowerCase(),null,!0,!0)});function ke(e,t,n,r){var l=Y.hasOwnProperty(t)?Y[t]:null;(l!==null?l.type!==0:r||!(2s||l[i]!==o[s]){var a=` -`+l[i].replace(" at new "," at ");return e.displayName&&a.includes("")&&(a=a.replace("",e.displayName)),a}while(1<=i&&0<=s);break}}}finally{F=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?v(e):""}function M(e){switch(e.tag){case 5:return v(e.type);case 16:return v("Lazy");case 13:return v("Suspense");case 19:return v("SuspenseList");case 0:case 2:case 15:return e=H(e.type,!1),e;case 11:return e=H(e.type.render,!1),e;case 1:return e=H(e.type,!0),e;default:return""}}function J(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Re:return"Fragment";case Ce:return"Portal";case Ne:return"Profiler";case pe:return"StrictMode";case he:return"Suspense";case ge:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Je:return(e.displayName||"Context")+".Consumer";case ke:return(e._context.displayName||"Context")+".Provider";case Oe:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Ue:return t=e.displayName||null,t!==null?t:J(e.type)||"Memo";case ze:t=e._payload,e=e._init;try{return J(e(t))}catch{}}return null}function re(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return J(t);case 8:return t===pe?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function te(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function se(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function We(e){var t=se(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,o=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(i){r=""+i,o.call(this,i)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(i){r=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Gt(e){e._valueTracker||(e._valueTracker=We(e))}function fn(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=se(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function pn(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Pt(e,t){var n=t.checked;return y({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Ui(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=te(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Ai(e,t){t=t.checked,t!=null&&xe(e,"checked",t,!1)}function Ul(e,t){Ai(e,t);var n=te(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Al(e,t.type,n):t.hasOwnProperty("defaultValue")&&Al(e,t.type,te(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function $i(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Al(e,t,n){(t!=="number"||pn(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var $n=Array.isArray;function hn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=zr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Vn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Hn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Qu=["Webkit","ms","Moz","O"];Object.keys(Hn).forEach(function(e){Qu.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Hn[t]=Hn[e]})});function Ki(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Hn.hasOwnProperty(e)&&Hn[e]?(""+t).trim():t+"px"}function bi(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Ki(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Ku=y({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Hl(e,t){if(t){if(Ku[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(d(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(d(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(d(61))}if(t.style!=null&&typeof t.style!="object")throw Error(d(62))}}function Wl(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Bl=null;function Ql(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Kl=null,mn=null,vn=null;function Yi(e){if(e=cr(e)){if(typeof Kl!="function")throw Error(d(280));var t=e.stateNode;t&&(t=Zr(t),Kl(e.stateNode,e.type,t))}}function Xi(e){mn?vn?vn.push(e):vn=[e]:mn=e}function Gi(){if(mn){var e=mn,t=vn;if(vn=mn=null,Yi(e),t)for(e=0;e>>=0,e===0?32:31-(rc(e)/lc|0)|0}var Ir=64,Mr=4194304;function Kn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Or(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,o=e.pingedLanes,i=n&268435455;if(i!==0){var s=i&~l;s!==0?r=Kn(s):(o&=i,o!==0&&(r=Kn(o)))}else i=n&~l,i!==0?r=Kn(i):o!==0&&(r=Kn(o));if(r===0)return 0;if(t!==0&&t!==r&&(t&l)===0&&(l=r&-r,o=t&-t,l>=o||l===16&&(o&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function bn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-ut(t),e[t]=n}function ac(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=tr),Es=" ",Cs=!1;function Ns(e,t){switch(e){case"keyup":return Dc.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function js(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var wn=!1;function Uc(e,t){switch(e){case"compositionend":return js(t);case"keypress":return t.which!==32?null:(Cs=!0,Es);case"textInput":return e=t.data,e===Es&&Cs?null:e;default:return null}}function Ac(e,t){if(wn)return e==="compositionend"||!co&&Ns(e,t)?(e=ys(),$r=lo=Mt=null,wn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Ms(n)}}function Ds(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ds(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Fs(){for(var e=window,t=pn();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=pn(e.document)}return t}function ho(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Yc(e){var t=Fs(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ds(n.ownerDocument.documentElement,n)){if(r!==null&&ho(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,o=Math.min(r.start,l);r=r.end===void 0?o:Math.min(r.end,l),!e.extend&&o>r&&(l=r,r=o,o=l),l=Os(n,o);var i=Os(n,r);l&&i&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==i.node||e.focusOffset!==i.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),o>r?(e.addRange(t),e.extend(i.node,i.offset)):(t.setEnd(i.node,i.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,xn=null,mo=null,or=null,vo=!1;function Us(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;vo||xn==null||xn!==pn(r)||(r=xn,"selectionStart"in r&&ho(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),or&&lr(or,r)||(or=r,r=Yr(mo,"onSelect"),0Cn||(e.current=zo[Cn],zo[Cn]=null,Cn--)}function ae(e,t){Cn++,zo[Cn]=e.current,e.current=t}var Ut={},Ae=Ft(Ut),Ke=Ft(!1),qt=Ut;function Nn(e,t){var n=e.type.contextTypes;if(!n)return Ut;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},o;for(o in n)l[o]=t[o];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function be(e){return e=e.childContextTypes,e!=null}function Jr(){ce(Ke),ce(Ae)}function qs(e,t,n){if(Ae.current!==Ut)throw Error(d(168));ae(Ae,t),ae(Ke,n)}function ea(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(d(108,re(e)||"Unknown",l));return y({},n,r)}function qr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Ut,qt=Ae.current,ae(Ae,e),ae(Ke,Ke.current),!0}function ta(e,t,n){var r=e.stateNode;if(!r)throw Error(d(169));n?(e=ea(e,t,qt),r.__reactInternalMemoizedMergedChildContext=e,ce(Ke),ce(Ae),ae(Ae,e)):ce(Ke),ae(Ke,n)}var St=null,el=!1,Po=!1;function na(e){St===null?St=[e]:St.push(e)}function id(e){el=!0,na(e)}function At(){if(!Po&&St!==null){Po=!0;var e=0,t=oe;try{var n=St;for(oe=1;e>=i,l-=i,_t=1<<32-ut(t)+l|n<V?(Me=D,D=null):Me=D.sibling;var le=w(f,D,p[V],_);if(le===null){D===null&&(D=Me);break}e&&D&&le.alternate===null&&t(f,D),u=o(le,u,V),O===null?L=le:O.sibling=le,O=le,D=Me}if(V===p.length)return n(f,D),de&&tn(f,V),L;if(D===null){for(;VV?(Me=D,D=null):Me=D.sibling;var Yt=w(f,D,le.value,_);if(Yt===null){D===null&&(D=Me);break}e&&D&&Yt.alternate===null&&t(f,D),u=o(Yt,u,V),O===null?L=Yt:O.sibling=Yt,O=Yt,D=Me}if(le.done)return n(f,D),de&&tn(f,V),L;if(D===null){for(;!le.done;V++,le=p.next())le=k(f,le.value,_),le!==null&&(u=o(le,u,V),O===null?L=le:O.sibling=le,O=le);return de&&tn(f,V),L}for(D=r(f,D);!le.done;V++,le=p.next())le=j(D,f,V,le.value,_),le!==null&&(e&&le.alternate!==null&&D.delete(le.key===null?V:le.key),u=o(le,u,V),O===null?L=le:O.sibling=le,O=le);return e&&D.forEach(function($d){return t(f,$d)}),de&&tn(f,V),L}function _e(f,u,p,_){if(typeof p=="object"&&p!==null&&p.type===Re&&p.key===null&&(p=p.props.children),typeof p=="object"&&p!==null){switch(p.$$typeof){case ee:e:{for(var L=p.key,O=u;O!==null;){if(O.key===L){if(L=p.type,L===Re){if(O.tag===7){n(f,O.sibling),u=l(O,p.props.children),u.return=f,f=u;break e}}else if(O.elementType===L||typeof L=="object"&&L!==null&&L.$$typeof===ze&&aa(L)===O.type){n(f,O.sibling),u=l(O,p.props),u.ref=dr(f,O,p),u.return=f,f=u;break e}n(f,O);break}else t(f,O);O=O.sibling}p.type===Re?(u=cn(p.props.children,f.mode,_,p.key),u.return=f,f=u):(_=zl(p.type,p.key,p.props,null,f.mode,_),_.ref=dr(f,u,p),_.return=f,f=_)}return i(f);case Ce:e:{for(O=p.key;u!==null;){if(u.key===O)if(u.tag===4&&u.stateNode.containerInfo===p.containerInfo&&u.stateNode.implementation===p.implementation){n(f,u.sibling),u=l(u,p.children||[]),u.return=f,f=u;break e}else{n(f,u);break}else t(f,u);u=u.sibling}u=Ni(p,f.mode,_),u.return=f,f=u}return i(f);case ze:return O=p._init,_e(f,u,O(p._payload),_)}if($n(p))return P(f,u,p,_);if(g(p))return T(f,u,p,_);ll(f,p)}return typeof p=="string"&&p!==""||typeof p=="number"?(p=""+p,u!==null&&u.tag===6?(n(f,u.sibling),u=l(u,p),u.return=f,f=u):(n(f,u),u=Ci(p,f.mode,_),u.return=f,f=u),i(f)):n(f,u)}return _e}var Tn=ua(!0),ca=ua(!1),ol=Ft(null),il=null,Rn=null,Oo=null;function Do(){Oo=Rn=il=null}function Fo(e){var t=ol.current;ce(ol),e._currentValue=t}function Uo(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Ln(e,t){il=e,Oo=Rn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ye=!0),e.firstContext=null)}function ot(e){var t=e._currentValue;if(Oo!==e)if(e={context:e,memoizedValue:t,next:null},Rn===null){if(il===null)throw Error(d(308));Rn=e,il.dependencies={lanes:0,firstContext:e}}else Rn=Rn.next=e;return t}var nn=null;function Ao(e){nn===null?nn=[e]:nn.push(e)}function da(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Ao(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ct(e,r)}function Ct(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var $t=!1;function $o(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function fa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Nt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Vt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(ne&2)!==0){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ct(e,n)}return l=r.interleaved,l===null?(t.next=t,Ao(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ct(e,n)}function sl(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ql(e,n)}}function pa(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,o=null;if(n=n.firstBaseUpdate,n!==null){do{var i={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};o===null?l=o=i:o=o.next=i,n=n.next}while(n!==null);o===null?l=o=t:o=o.next=t}else l=o=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:o,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function al(e,t,n,r){var l=e.updateQueue;$t=!1;var o=l.firstBaseUpdate,i=l.lastBaseUpdate,s=l.shared.pending;if(s!==null){l.shared.pending=null;var a=s,h=a.next;a.next=null,i===null?o=h:i.next=h,i=a;var x=e.alternate;x!==null&&(x=x.updateQueue,s=x.lastBaseUpdate,s!==i&&(s===null?x.firstBaseUpdate=h:s.next=h,x.lastBaseUpdate=a))}if(o!==null){var k=l.baseState;i=0,x=h=a=null,s=o;do{var w=s.lane,j=s.eventTime;if((r&w)===w){x!==null&&(x=x.next={eventTime:j,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});e:{var P=e,T=s;switch(w=t,j=n,T.tag){case 1:if(P=T.payload,typeof P=="function"){k=P.call(j,k,w);break e}k=P;break e;case 3:P.flags=P.flags&-65537|128;case 0:if(P=T.payload,w=typeof P=="function"?P.call(j,k,w):P,w==null)break e;k=y({},k,w);break e;case 2:$t=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,w=l.effects,w===null?l.effects=[s]:w.push(s))}else j={eventTime:j,lane:w,tag:s.tag,payload:s.payload,callback:s.callback,next:null},x===null?(h=x=j,a=k):x=x.next=j,i|=w;if(s=s.next,s===null){if(s=l.shared.pending,s===null)break;w=s,s=w.next,w.next=null,l.lastBaseUpdate=w,l.shared.pending=null}}while(!0);if(x===null&&(a=k),l.baseState=a,l.firstBaseUpdate=h,l.lastBaseUpdate=x,t=l.shared.interleaved,t!==null){l=t;do i|=l.lane,l=l.next;while(l!==t)}else o===null&&(l.shared.lanes=0);on|=i,e.lanes=i,e.memoizedState=k}}function ha(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Qo.transition;Qo.transition={};try{e(!1),t()}finally{oe=n,Qo.transition=r}}function Ia(){return it().memoizedState}function cd(e,t,n){var r=Qt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Ma(e))Oa(t,n);else if(n=da(e,t,n,r),n!==null){var l=Qe();mt(n,e,r,l),Da(n,t,r)}}function dd(e,t,n){var r=Qt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Ma(e))Oa(t,l);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=t.lastRenderedReducer,o!==null))try{var i=t.lastRenderedState,s=o(i,n);if(l.hasEagerState=!0,l.eagerState=s,ct(s,i)){var a=t.interleaved;a===null?(l.next=l,Ao(t)):(l.next=a.next,a.next=l),t.interleaved=l;return}}catch{}finally{}n=da(e,t,l,r),n!==null&&(l=Qe(),mt(n,e,r,l),Da(n,t,r))}}function Ma(e){var t=e.alternate;return e===ve||t!==null&&t===ve}function Oa(e,t){mr=dl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Da(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ql(e,n)}}var hl={readContext:ot,useCallback:$e,useContext:$e,useEffect:$e,useImperativeHandle:$e,useInsertionEffect:$e,useLayoutEffect:$e,useMemo:$e,useReducer:$e,useRef:$e,useState:$e,useDebugValue:$e,useDeferredValue:$e,useTransition:$e,useMutableSource:$e,useSyncExternalStore:$e,useId:$e,unstable_isNewReconciler:!1},fd={readContext:ot,useCallback:function(e,t){return wt().memoizedState=[e,t===void 0?null:t],e},useContext:ot,useEffect:Ca,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,fl(4194308,4,za.bind(null,t,e),n)},useLayoutEffect:function(e,t){return fl(4194308,4,e,t)},useInsertionEffect:function(e,t){return fl(4,2,e,t)},useMemo:function(e,t){var n=wt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=wt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=cd.bind(null,ve,e),[r.memoizedState,e]},useRef:function(e){var t=wt();return e={current:e},t.memoizedState=e},useState:_a,useDebugValue:Jo,useDeferredValue:function(e){return wt().memoizedState=e},useTransition:function(){var e=_a(!1),t=e[0];return e=ud.bind(null,e[1]),wt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=ve,l=wt();if(de){if(n===void 0)throw Error(d(407));n=n()}else{if(n=t(),Ie===null)throw Error(d(349));(ln&30)!==0||ya(r,t,n)}l.memoizedState=n;var o={value:n,getSnapshot:t};return l.queue=o,Ca(xa.bind(null,r,o,e),[e]),r.flags|=2048,yr(9,wa.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=wt(),t=Ie.identifierPrefix;if(de){var n=Et,r=_t;n=(r&~(1<<32-ut(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=vr++,0")&&(a=a.replace("",e.displayName)),a}while(1<=i&&0<=s);break}}}finally{M=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?v(e):""}function G(e){switch(e.tag){case 5:return v(e.type);case 16:return v("Lazy");case 13:return v("Suspense");case 19:return v("SuspenseList");case 0:case 2:case 15:return e=A(e.type,!1),e;case 11:return e=A(e.type.render,!1),e;case 1:return e=A(e.type,!0),e;default:return""}}function O(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Re:return"Fragment";case Ce:return"Portal";case Ne:return"Profiler";case he:return"StrictMode";case me:return"Suspense";case ye:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Ke:return(e.displayName||"Context")+".Consumer";case ue:return(e._context.displayName||"Context")+".Provider";case Le:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Ue:return t=e.displayName||null,t!==null?t:O(e.type)||"Memo";case ze:t=e._payload,e=e._init;try{return O(e(t))}catch{}}return null}function le(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return O(t);case 8:return t===he?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function q(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ie(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Ae(e){var t=ie(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,o=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(i){r=""+i,o.call(this,i)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(i){r=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function fn(e){e._valueTracker||(e._valueTracker=Ae(e))}function An(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=ie(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Pt(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function $n(e,t){var n=t.checked;return g({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Gt(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=q(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Ai(e,t){t=t.checked,t!=null&&ke(e,"checked",t,!1)}function Al(e,t){Ai(e,t);var n=q(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?$l(e,t.type,n):t.hasOwnProperty("defaultValue")&&$l(e,t.type,q(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function $i(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function $l(e,t,n){(t!=="number"||Pt(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Vn=Array.isArray;function pn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=Pr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Hn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Wn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Qu=["Webkit","ms","Moz","O"];Object.keys(Wn).forEach(function(e){Qu.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Wn[t]=Wn[e]})});function Ki(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Wn.hasOwnProperty(e)&&Wn[e]?(""+t).trim():t+"px"}function bi(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Ki(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Ku=g({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Wl(e,t){if(t){if(Ku[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(d(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(d(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(d(61))}if(t.style!=null&&typeof t.style!="object")throw Error(d(62))}}function Bl(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Ql=null;function Kl(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var bl=null,hn=null,mn=null;function Yi(e){if(e=dr(e)){if(typeof bl!="function")throw Error(d(280));var t=e.stateNode;t&&(t=Jr(t),bl(e.stateNode,e.type,t))}}function Xi(e){hn?mn?mn.push(e):mn=[e]:hn=e}function Gi(){if(hn){var e=hn,t=mn;if(mn=hn=null,Yi(e),t)for(e=0;e>>=0,e===0?32:31-(rc(e)/lc|0)|0}var Mr=64,Or=4194304;function bn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Dr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,o=e.pingedLanes,i=n&268435455;if(i!==0){var s=i&~l;s!==0?r=bn(s):(o&=i,o!==0&&(r=bn(o)))}else i=n&~l,i!==0?r=bn(i):o!==0&&(r=bn(o));if(r===0)return 0;if(t!==0&&t!==r&&(t&l)===0&&(l=r&-r,o=t&-t,l>=o||l===16&&(o&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Yn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-ut(t),e[t]=n}function ac(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=nr),Es=" ",Cs=!1;function Ns(e,t){switch(e){case"keyup":return Dc.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function js(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var yn=!1;function Uc(e,t){switch(e){case"compositionend":return js(t);case"keypress":return t.which!==32?null:(Cs=!0,Es);case"textInput":return e=t.data,e===Es&&Cs?null:e;default:return null}}function Ac(e,t){if(yn)return e==="compositionend"||!fo&&Ns(e,t)?(e=ys(),Vr=oo=Mt=null,yn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Ms(n)}}function Ds(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ds(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Fs(){for(var e=window,t=Pt();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Pt(e.document)}return t}function mo(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Yc(e){var t=Fs(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ds(n.ownerDocument.documentElement,n)){if(r!==null&&mo(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,o=Math.min(r.start,l);r=r.end===void 0?o:Math.min(r.end,l),!e.extend&&o>r&&(l=r,r=o,o=l),l=Os(n,o);var i=Os(n,r);l&&i&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==i.node||e.focusOffset!==i.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),o>r?(e.addRange(t),e.extend(i.node,i.offset)):(t.setEnd(i.node,i.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,wn=null,vo=null,ir=null,go=!1;function Us(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;go||wn==null||wn!==Pt(r)||(r=wn,"selectionStart"in r&&mo(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),ir&&or(ir,r)||(ir=r,r=Xr(vo,"onSelect"),0En||(e.current=Po[En],Po[En]=null,En--)}function ae(e,t){En++,Po[En]=e.current,e.current=t}var Ut={},$e=Ft(Ut),be=Ft(!1),qt=Ut;function Cn(e,t){var n=e.type.contextTypes;if(!n)return Ut;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},o;for(o in n)l[o]=t[o];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function Ye(e){return e=e.childContextTypes,e!=null}function qr(){de(be),de($e)}function qs(e,t,n){if($e.current!==Ut)throw Error(d(168));ae($e,t),ae(be,n)}function ea(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(d(108,le(e)||"Unknown",l));return g({},n,r)}function el(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Ut,qt=$e.current,ae($e,e),ae(be,be.current),!0}function ta(e,t,n){var r=e.stateNode;if(!r)throw Error(d(169));n?(e=ea(e,t,qt),r.__reactInternalMemoizedMergedChildContext=e,de(be),de($e),ae($e,e)):de(be),ae(be,n)}var St=null,tl=!1,To=!1;function na(e){St===null?St=[e]:St.push(e)}function id(e){tl=!0,na(e)}function At(){if(!To&&St!==null){To=!0;var e=0,t=oe;try{var n=St;for(oe=1;e>=i,l-=i,_t=1<<32-ut(t)+l|n<H?(Oe=F,F=null):Oe=F.sibling;var re=y(f,F,p[H],_);if(re===null){F===null&&(F=Oe);break}e&&F&&re.alternate===null&&t(f,F),c=o(re,c,H),D===null?L=re:D.sibling=re,D=re,F=Oe}if(H===p.length)return n(f,F),fe&&tn(f,H),L;if(F===null){for(;HH?(Oe=F,F=null):Oe=F.sibling;var Yt=y(f,F,re.value,_);if(Yt===null){F===null&&(F=Oe);break}e&&F&&Yt.alternate===null&&t(f,F),c=o(Yt,c,H),D===null?L=Yt:D.sibling=Yt,D=Yt,F=Oe}if(re.done)return n(f,F),fe&&tn(f,H),L;if(F===null){for(;!re.done;H++,re=p.next())re=k(f,re.value,_),re!==null&&(c=o(re,c,H),D===null?L=re:D.sibling=re,D=re);return fe&&tn(f,H),L}for(F=r(f,F);!re.done;H++,re=p.next())re=N(F,f,H,re.value,_),re!==null&&(e&&re.alternate!==null&&F.delete(re.key===null?H:re.key),c=o(re,c,H),D===null?L=re:D.sibling=re,D=re);return e&&F.forEach(function($d){return t(f,$d)}),fe&&tn(f,H),L}function _e(f,c,p,_){if(typeof p=="object"&&p!==null&&p.type===Re&&p.key===null&&(p=p.props.children),typeof p=="object"&&p!==null){switch(p.$$typeof){case te:e:{for(var L=p.key,D=c;D!==null;){if(D.key===L){if(L=p.type,L===Re){if(D.tag===7){n(f,D.sibling),c=l(D,p.props.children),c.return=f,f=c;break e}}else if(D.elementType===L||typeof L=="object"&&L!==null&&L.$$typeof===ze&&aa(L)===D.type){n(f,D.sibling),c=l(D,p.props),c.ref=fr(f,D,p),c.return=f,f=c;break e}n(f,D);break}else t(f,D);D=D.sibling}p.type===Re?(c=cn(p.props.children,f.mode,_,p.key),c.return=f,f=c):(_=Pl(p.type,p.key,p.props,null,f.mode,_),_.ref=fr(f,c,p),_.return=f,f=_)}return i(f);case Ce:e:{for(D=p.key;c!==null;){if(c.key===D)if(c.tag===4&&c.stateNode.containerInfo===p.containerInfo&&c.stateNode.implementation===p.implementation){n(f,c.sibling),c=l(c,p.children||[]),c.return=f,f=c;break e}else{n(f,c);break}else t(f,c);c=c.sibling}c=ji(p,f.mode,_),c.return=f,f=c}return i(f);case ze:return D=p._init,_e(f,c,D(p._payload),_)}if(Vn(p))return P(f,c,p,_);if(x(p))return T(f,c,p,_);ol(f,p)}return typeof p=="string"&&p!==""||typeof p=="number"?(p=""+p,c!==null&&c.tag===6?(n(f,c.sibling),c=l(c,p),c.return=f,f=c):(n(f,c),c=Ni(p,f.mode,_),c.return=f,f=c),i(f)):n(f,c)}return _e}var Pn=ua(!0),ca=ua(!1),il=Ft(null),sl=null,Tn=null,Do=null;function Fo(){Do=Tn=sl=null}function Uo(e){var t=il.current;de(il),e._currentValue=t}function Ao(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Rn(e,t){sl=e,Do=Tn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Xe=!0),e.firstContext=null)}function ot(e){var t=e._currentValue;if(Do!==e)if(e={context:e,memoizedValue:t,next:null},Tn===null){if(sl===null)throw Error(d(308));Tn=e,sl.dependencies={lanes:0,firstContext:e}}else Tn=Tn.next=e;return t}var nn=null;function $o(e){nn===null?nn=[e]:nn.push(e)}function da(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,$o(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ct(e,r)}function Ct(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var $t=!1;function Vo(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function fa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Nt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Vt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(ne&2)!==0){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ct(e,n)}return l=r.interleaved,l===null?(t.next=t,$o(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ct(e,n)}function al(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,eo(e,n)}}function pa(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,o=null;if(n=n.firstBaseUpdate,n!==null){do{var i={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};o===null?l=o=i:o=o.next=i,n=n.next}while(n!==null);o===null?l=o=t:o=o.next=t}else l=o=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:o,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function ul(e,t,n,r){var l=e.updateQueue;$t=!1;var o=l.firstBaseUpdate,i=l.lastBaseUpdate,s=l.shared.pending;if(s!==null){l.shared.pending=null;var a=s,h=a.next;a.next=null,i===null?o=h:i.next=h,i=a;var w=e.alternate;w!==null&&(w=w.updateQueue,s=w.lastBaseUpdate,s!==i&&(s===null?w.firstBaseUpdate=h:s.next=h,w.lastBaseUpdate=a))}if(o!==null){var k=l.baseState;i=0,w=h=a=null,s=o;do{var y=s.lane,N=s.eventTime;if((r&y)===y){w!==null&&(w=w.next={eventTime:N,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});e:{var P=e,T=s;switch(y=t,N=n,T.tag){case 1:if(P=T.payload,typeof P=="function"){k=P.call(N,k,y);break e}k=P;break e;case 3:P.flags=P.flags&-65537|128;case 0:if(P=T.payload,y=typeof P=="function"?P.call(N,k,y):P,y==null)break e;k=g({},k,y);break e;case 2:$t=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,y=l.effects,y===null?l.effects=[s]:y.push(s))}else N={eventTime:N,lane:y,tag:s.tag,payload:s.payload,callback:s.callback,next:null},w===null?(h=w=N,a=k):w=w.next=N,i|=y;if(s=s.next,s===null){if(s=l.shared.pending,s===null)break;y=s,s=y.next,y.next=null,l.lastBaseUpdate=y,l.shared.pending=null}}while(!0);if(w===null&&(a=k),l.baseState=a,l.firstBaseUpdate=h,l.lastBaseUpdate=w,t=l.shared.interleaved,t!==null){l=t;do i|=l.lane,l=l.next;while(l!==t)}else o===null&&(l.shared.lanes=0);on|=i,e.lanes=i,e.memoizedState=k}}function ha(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Ko.transition;Ko.transition={};try{e(!1),t()}finally{oe=n,Ko.transition=r}}function Ia(){return it().memoizedState}function cd(e,t,n){var r=Qt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Ma(e))Oa(t,n);else if(n=da(e,t,n,r),n!==null){var l=Qe();mt(n,e,r,l),Da(n,t,r)}}function dd(e,t,n){var r=Qt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Ma(e))Oa(t,l);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=t.lastRenderedReducer,o!==null))try{var i=t.lastRenderedState,s=o(i,n);if(l.hasEagerState=!0,l.eagerState=s,ct(s,i)){var a=t.interleaved;a===null?(l.next=l,$o(t)):(l.next=a.next,a.next=l),t.interleaved=l;return}}catch{}finally{}n=da(e,t,l,r),n!==null&&(l=Qe(),mt(n,e,r,l),Da(n,t,r))}}function Ma(e){var t=e.alternate;return e===ge||t!==null&&t===ge}function Oa(e,t){vr=fl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Da(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,eo(e,n)}}var ml={readContext:ot,useCallback:Ve,useContext:Ve,useEffect:Ve,useImperativeHandle:Ve,useInsertionEffect:Ve,useLayoutEffect:Ve,useMemo:Ve,useReducer:Ve,useRef:Ve,useState:Ve,useDebugValue:Ve,useDeferredValue:Ve,useTransition:Ve,useMutableSource:Ve,useSyncExternalStore:Ve,useId:Ve,unstable_isNewReconciler:!1},fd={readContext:ot,useCallback:function(e,t){return wt().memoizedState=[e,t===void 0?null:t],e},useContext:ot,useEffect:Ca,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,pl(4194308,4,za.bind(null,t,e),n)},useLayoutEffect:function(e,t){return pl(4194308,4,e,t)},useInsertionEffect:function(e,t){return pl(4,2,e,t)},useMemo:function(e,t){var n=wt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=wt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=cd.bind(null,ge,e),[r.memoizedState,e]},useRef:function(e){var t=wt();return e={current:e},t.memoizedState=e},useState:_a,useDebugValue:qo,useDeferredValue:function(e){return wt().memoizedState=e},useTransition:function(){var e=_a(!1),t=e[0];return e=ud.bind(null,e[1]),wt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=ge,l=wt();if(fe){if(n===void 0)throw Error(d(407));n=n()}else{if(n=t(),Me===null)throw Error(d(349));(ln&30)!==0||ya(r,t,n)}l.memoizedState=n;var o={value:n,getSnapshot:t};return l.queue=o,Ca(xa.bind(null,r,o,e),[e]),r.flags|=2048,wr(9,wa.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=wt(),t=Me.identifierPrefix;if(fe){var n=Et,r=_t;n=(r&~(1<<32-ut(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=gr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=i.createElement(n,{is:r.is}):(e=i.createElement(n),n==="select"&&(i=e,r.multiple?i.multiple=!0:r.size&&(i.size=r.size))):e=i.createElementNS(e,n),e[gt]=t,e[ur]=r,nu(e,t,!1,!1),t.stateNode=e;e:{switch(i=Wl(n,r),n){case"dialog":ue("cancel",e),ue("close",e),l=r;break;case"iframe":case"object":case"embed":ue("load",e),l=r;break;case"video":case"audio":for(l=0;lFn&&(t.flags|=128,r=!0,wr(o,!1),t.lanes=4194304)}else{if(!r)if(e=ul(i),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),wr(o,!0),o.tail===null&&o.tailMode==="hidden"&&!i.alternate&&!de)return Ve(t),null}else 2*Se()-o.renderingStartTime>Fn&&n!==1073741824&&(t.flags|=128,r=!0,wr(o,!1),t.lanes=4194304);o.isBackwards?(i.sibling=t.child,t.child=i):(n=o.last,n!==null?n.sibling=i:t.child=i,o.last=i)}return o.tail!==null?(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=Se(),t.sibling=null,n=me.current,ae(me,r?n&1|2:n&1),t):(Ve(t),null);case 22:case 23:return Si(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(nt&1073741824)!==0&&(Ve(t),t.subtreeFlags&6&&(t.flags|=8192)):Ve(t),null;case 24:return null;case 25:return null}throw Error(d(156,t.tag))}function xd(e,t){switch(Ro(t),t.tag){case 1:return be(t.type)&&Jr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return In(),ce(Ke),ce(Ae),Bo(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return Ho(t),null;case 13:if(ce(me),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(d(340));Pn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return ce(me),null;case 4:return In(),null;case 10:return Fo(t.type._context),null;case 22:case 23:return Si(),null;case 24:return null;default:return null}}var yl=!1,He=!1,kd=typeof WeakSet=="function"?WeakSet:Set,z=null;function On(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){ye(e,t,r)}else n.current=null}function ci(e,t,n){try{n()}catch(r){ye(e,t,r)}}var ou=!1;function Sd(e,t){if(So=Ur,e=Fs(),ho(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break e}var i=0,s=-1,a=-1,h=0,x=0,k=e,w=null;t:for(;;){for(var j;k!==n||l!==0&&k.nodeType!==3||(s=i+l),k!==o||r!==0&&k.nodeType!==3||(a=i+r),k.nodeType===3&&(i+=k.nodeValue.length),(j=k.firstChild)!==null;)w=k,k=j;for(;;){if(k===e)break t;if(w===n&&++h===l&&(s=i),w===o&&++x===r&&(a=i),(j=k.nextSibling)!==null)break;k=w,w=k.parentNode}k=j}n=s===-1||a===-1?null:{start:s,end:a}}else n=null}n=n||{start:0,end:0}}else n=null;for(_o={focusedElem:e,selectionRange:n},Ur=!1,z=t;z!==null;)if(t=z,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,z=e;else for(;z!==null;){t=z;try{var P=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(P!==null){var T=P.memoizedProps,_e=P.memoizedState,f=t.stateNode,u=f.getSnapshotBeforeUpdate(t.elementType===t.type?T:ft(t.type,T),_e);f.__reactInternalSnapshotBeforeUpdate=u}break;case 3:var p=t.stateNode.containerInfo;p.nodeType===1?p.textContent="":p.nodeType===9&&p.documentElement&&p.removeChild(p.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(d(163))}}catch(_){ye(t,t.return,_)}if(e=t.sibling,e!==null){e.return=t.return,z=e;break}z=t.return}return P=ou,ou=!1,P}function xr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var o=l.destroy;l.destroy=void 0,o!==void 0&&ci(t,n,o)}l=l.next}while(l!==r)}}function wl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function di(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function iu(e){var t=e.alternate;t!==null&&(e.alternate=null,iu(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[gt],delete t[ur],delete t[jo],delete t[ld],delete t[od])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function su(e){return e.tag===5||e.tag===3||e.tag===4}function au(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||su(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function fi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Gr));else if(r!==4&&(e=e.child,e!==null))for(fi(e,t,n),e=e.sibling;e!==null;)fi(e,t,n),e=e.sibling}function pi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(pi(e,t,n),e=e.sibling;e!==null;)pi(e,t,n),e=e.sibling}var De=null,pt=!1;function Ht(e,t,n){for(n=n.child;n!==null;)uu(e,t,n),n=n.sibling}function uu(e,t,n){if(vt&&typeof vt.onCommitFiberUnmount=="function")try{vt.onCommitFiberUnmount(Lr,n)}catch{}switch(n.tag){case 5:He||On(n,t);case 6:var r=De,l=pt;De=null,Ht(e,t,n),De=r,pt=l,De!==null&&(pt?(e=De,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):De.removeChild(n.stateNode));break;case 18:De!==null&&(pt?(e=De,n=n.stateNode,e.nodeType===8?No(e.parentNode,n):e.nodeType===1&&No(e,n),Jn(e)):No(De,n.stateNode));break;case 4:r=De,l=pt,De=n.stateNode.containerInfo,pt=!0,Ht(e,t,n),De=r,pt=l;break;case 0:case 11:case 14:case 15:if(!He&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var o=l,i=o.destroy;o=o.tag,i!==void 0&&((o&2)!==0||(o&4)!==0)&&ci(n,t,i),l=l.next}while(l!==r)}Ht(e,t,n);break;case 1:if(!He&&(On(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(s){ye(n,t,s)}Ht(e,t,n);break;case 21:Ht(e,t,n);break;case 22:n.mode&1?(He=(r=He)||n.memoizedState!==null,Ht(e,t,n),He=r):Ht(e,t,n);break;default:Ht(e,t,n)}}function cu(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new kd),t.forEach(function(r){var l=Rd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function ht(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=i),r&=~o}if(r=l,r=Se()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Ed(r/1960))-r,10e?16:e,Bt===null)var r=!1;else{if(e=Bt,Bt=null,El=0,(ne&6)!==0)throw Error(d(331));var l=ne;for(ne|=4,z=e.current;z!==null;){var o=z,i=o.child;if((z.flags&16)!==0){var s=o.deletions;if(s!==null){for(var a=0;aSe()-vi?an(e,0):mi|=n),Ge(e,t)}function _u(e,t){t===0&&((e.mode&1)===0?t=1:(t=Mr,Mr<<=1,(Mr&130023424)===0&&(Mr=4194304)));var n=Qe();e=Ct(e,t),e!==null&&(bn(e,t,n),Ge(e,n))}function Td(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),_u(e,n)}function Rd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(d(314))}r!==null&&r.delete(t),_u(e,n)}var Eu;Eu=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Ke.current)Ye=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return Ye=!1,yd(e,t,n);Ye=(e.flags&131072)!==0}else Ye=!1,de&&(t.flags&1048576)!==0&&ra(t,nl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;gl(e,t),e=t.pendingProps;var l=Nn(t,Ae.current);Ln(t,n),l=bo(null,t,r,e,l,n);var o=Yo();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,be(r)?(o=!0,qr(t)):o=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,$o(t),l.updater=ml,t.stateNode=l,l._reactInternals=t,ei(t,r,e,n),t=li(null,t,r,!0,o,n)):(t.tag=0,de&&o&&To(t),Be(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(gl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Id(r),e=ft(r,e),l){case 0:t=ri(null,t,r,e,n);break e;case 1:t=Ga(null,t,r,e,n);break e;case 11:t=Qa(null,t,r,e,n);break e;case 14:t=Ka(null,t,r,ft(r.type,e),n);break e}throw Error(d(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),ri(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),Ga(e,t,r,l,n);case 3:e:{if(Za(t),e===null)throw Error(d(387));r=t.pendingProps,o=t.memoizedState,l=o.element,fa(e,t),al(t,r,null,n);var i=t.memoizedState;if(r=i.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:i.cache,pendingSuspenseBoundaries:i.pendingSuspenseBoundaries,transitions:i.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){l=Mn(Error(d(423)),t),t=Ja(e,t,r,n,l);break e}else if(r!==l){l=Mn(Error(d(424)),t),t=Ja(e,t,r,n,l);break e}else for(tt=Dt(t.stateNode.containerInfo.firstChild),et=t,de=!0,dt=null,n=ca(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Pn(),r===l){t=jt(e,t,n);break e}Be(e,t,r,n)}t=t.child}return t;case 5:return ma(t),e===null&&Io(t),r=t.type,l=t.pendingProps,o=e!==null?e.memoizedProps:null,i=l.children,Eo(r,l)?i=null:o!==null&&Eo(r,o)&&(t.flags|=32),Xa(e,t),Be(e,t,i,n),t.child;case 6:return e===null&&Io(t),null;case 13:return qa(e,t,n);case 4:return Vo(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=Tn(t,null,r,n):Be(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),Qa(e,t,r,l,n);case 7:return Be(e,t,t.pendingProps,n),t.child;case 8:return Be(e,t,t.pendingProps.children,n),t.child;case 12:return Be(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,o=t.memoizedProps,i=l.value,ae(ol,r._currentValue),r._currentValue=i,o!==null)if(ct(o.value,i)){if(o.children===l.children&&!Ke.current){t=jt(e,t,n);break e}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var s=o.dependencies;if(s!==null){i=o.child;for(var a=s.firstContext;a!==null;){if(a.context===r){if(o.tag===1){a=Nt(-1,n&-n),a.tag=2;var h=o.updateQueue;if(h!==null){h=h.shared;var x=h.pending;x===null?a.next=a:(a.next=x.next,x.next=a),h.pending=a}}o.lanes|=n,a=o.alternate,a!==null&&(a.lanes|=n),Uo(o.return,n,t),s.lanes|=n;break}a=a.next}}else if(o.tag===10)i=o.type===t.type?null:o.child;else if(o.tag===18){if(i=o.return,i===null)throw Error(d(341));i.lanes|=n,s=i.alternate,s!==null&&(s.lanes|=n),Uo(i,n,t),i=o.sibling}else i=o.child;if(i!==null)i.return=o;else for(i=o;i!==null;){if(i===t){i=null;break}if(o=i.sibling,o!==null){o.return=i.return,i=o;break}i=i.return}o=i}Be(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Ln(t,n),l=ot(l),r=r(l),t.flags|=1,Be(e,t,r,n),t.child;case 14:return r=t.type,l=ft(r,t.pendingProps),l=ft(r.type,l),Ka(e,t,r,l,n);case 15:return ba(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),gl(e,t),t.tag=1,be(r)?(e=!0,qr(t)):e=!1,Ln(t,n),Ua(t,r,l),ei(t,r,l,n),li(null,t,r,!0,e,n);case 19:return tu(e,t,n);case 22:return Ya(e,t,n)}throw Error(d(156,t.tag))};function Cu(e,t){return ls(e,t)}function Ld(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function at(e,t,n,r){return new Ld(e,t,n,r)}function Ei(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Id(e){if(typeof e=="function")return Ei(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Oe)return 11;if(e===Ue)return 14}return 2}function bt(e,t){var n=e.alternate;return n===null?(n=at(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function zl(e,t,n,r,l,o){var i=2;if(r=e,typeof e=="function")Ei(e)&&(i=1);else if(typeof e=="string")i=5;else e:switch(e){case Re:return cn(n.children,l,o,t);case pe:i=8,l|=8;break;case Ne:return e=at(12,n,t,l|2),e.elementType=Ne,e.lanes=o,e;case he:return e=at(13,n,t,l),e.elementType=he,e.lanes=o,e;case ge:return e=at(19,n,t,l),e.elementType=ge,e.lanes=o,e;case W:return Pl(n,l,o,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case ke:i=10;break e;case Je:i=9;break e;case Oe:i=11;break e;case Ue:i=14;break e;case ze:i=16,r=null;break e}throw Error(d(130,e==null?e:typeof e,""))}return t=at(i,n,t,l),t.elementType=e,t.type=r,t.lanes=o,t}function cn(e,t,n,r){return e=at(7,e,r,t),e.lanes=n,e}function Pl(e,t,n,r){return e=at(22,e,r,t),e.elementType=W,e.lanes=n,e.stateNode={isHidden:!1},e}function Ci(e,t,n){return e=at(6,e,null,t),e.lanes=n,e}function Ni(e,t,n){return t=at(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Md(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Jl(0),this.expirationTimes=Jl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Jl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function ji(e,t,n,r,l,o,i,s,a){return e=new Md(e,t,n,s,a),t===1?(t=1,o===!0&&(t|=8)):t=0,o=at(3,null,null,t),e.current=o,o.stateNode=e,o.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},$o(o),e}function Od(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(S)}catch(C){console.error(C)}}return S(),Ii.exports=Yd(),Ii.exports}var Au;function Gd(){if(Au)return Dl;Au=1;var S=Xd();return Dl.createRoot=S.createRoot,Dl.hydrateRoot=S.hydrateRoot,Dl}var Zd=Gd();/** +`+o.stack}return{value:e,source:t,stack:l,digest:null}}function ni(e,t,n){return{value:e,source:null,stack:n??null,digest:t??null}}function ri(e,t){try{console.error(t.value)}catch(n){setTimeout(function(){throw n})}}var md=typeof WeakMap=="function"?WeakMap:Map;function $a(e,t,n){n=Nt(-1,n),n.tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){_l||(_l=!0,yi=r),ri(e,t)},n}function Va(e,t,n){n=Nt(-1,n),n.tag=3;var r=e.type.getDerivedStateFromError;if(typeof r=="function"){var l=t.value;n.payload=function(){return r(l)},n.callback=function(){ri(e,t)}}var o=e.stateNode;return o!==null&&typeof o.componentDidCatch=="function"&&(n.callback=function(){ri(e,t),typeof r!="function"&&(Wt===null?Wt=new Set([this]):Wt.add(this));var i=t.stack;this.componentDidCatch(t.value,{componentStack:i!==null?i:""})}),n}function Ha(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new md;var l=new Set;r.set(t,l)}else l=r.get(t),l===void 0&&(l=new Set,r.set(t,l));l.has(n)||(l.add(n),e=Pd.bind(null,e,t,n),t.then(e,e))}function Wa(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function Ba(e,t,n,r,l){return(e.mode&1)===0?(e===t?e.flags|=65536:(e.flags|=128,n.flags|=131072,n.flags&=-52805,n.tag===1&&(n.alternate===null?n.tag=17:(t=Nt(-1,1),t.tag=2,Vt(n,t,1))),n.lanes|=1),e):(e.flags|=65536,e.lanes=l,e)}var vd=pe.ReactCurrentOwner,Xe=!1;function Be(e,t,n,r){t.child=e===null?ca(t,null,n,r):Pn(t,e.child,n,r)}function Qa(e,t,n,r,l){n=n.render;var o=t.ref;return Rn(t,l),r=Yo(e,t,n,r,o,l),n=Xo(),e!==null&&!Xe?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,jt(e,t,l)):(fe&&n&&Ro(t),t.flags|=1,Be(e,t,r,l),t.child)}function Ka(e,t,n,r,l){if(e===null){var o=n.type;return typeof o=="function"&&!Ci(o)&&o.defaultProps===void 0&&n.compare===null&&n.defaultProps===void 0?(t.tag=15,t.type=o,ba(e,t,o,r,l)):(e=Pl(n.type,null,r,t,t.mode,l),e.ref=t.ref,e.return=t,t.child=e)}if(o=e.child,(e.lanes&l)===0){var i=o.memoizedProps;if(n=n.compare,n=n!==null?n:or,n(i,r)&&e.ref===t.ref)return jt(e,t,l)}return t.flags|=1,e=bt(o,r),e.ref=t.ref,e.return=t,t.child=e}function ba(e,t,n,r,l){if(e!==null){var o=e.memoizedProps;if(or(o,r)&&e.ref===t.ref)if(Xe=!1,t.pendingProps=r=o,(e.lanes&l)!==0)(e.flags&131072)!==0&&(Xe=!0);else return t.lanes=e.lanes,jt(e,t,l)}return li(e,t,n,r,l)}function Ya(e,t,n){var r=t.pendingProps,l=r.children,o=e!==null?e.memoizedState:null;if(r.mode==="hidden")if((t.mode&1)===0)t.memoizedState={baseLanes:0,cachePool:null,transitions:null},ae(On,nt),nt|=n;else{if((n&1073741824)===0)return e=o!==null?o.baseLanes|n:n,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,ae(On,nt),nt|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},r=o!==null?o.baseLanes:n,ae(On,nt),nt|=r}else o!==null?(r=o.baseLanes|n,t.memoizedState=null):r=n,ae(On,nt),nt|=r;return Be(e,t,l,n),t.child}function Xa(e,t){var n=t.ref;(e===null&&n!==null||e!==null&&e.ref!==n)&&(t.flags|=512,t.flags|=2097152)}function li(e,t,n,r,l){var o=Ye(n)?qt:$e.current;return o=Cn(t,o),Rn(t,l),n=Yo(e,t,n,r,o,l),r=Xo(),e!==null&&!Xe?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,jt(e,t,l)):(fe&&r&&Ro(t),t.flags|=1,Be(e,t,n,l),t.child)}function Ga(e,t,n,r,l){if(Ye(n)){var o=!0;el(t)}else o=!1;if(Rn(t,l),t.stateNode===null)yl(e,t),Ua(t,n,r),ti(t,n,r,l),r=!0;else if(e===null){var i=t.stateNode,s=t.memoizedProps;i.props=s;var a=i.context,h=n.contextType;typeof h=="object"&&h!==null?h=ot(h):(h=Ye(n)?qt:$e.current,h=Cn(t,h));var w=n.getDerivedStateFromProps,k=typeof w=="function"||typeof i.getSnapshotBeforeUpdate=="function";k||typeof i.UNSAFE_componentWillReceiveProps!="function"&&typeof i.componentWillReceiveProps!="function"||(s!==r||a!==h)&&Aa(t,i,r,h),$t=!1;var y=t.memoizedState;i.state=y,ul(t,r,i,l),a=t.memoizedState,s!==r||y!==a||be.current||$t?(typeof w=="function"&&(ei(t,n,w,r),a=t.memoizedState),(s=$t||Fa(t,n,s,r,y,a,h))?(k||typeof i.UNSAFE_componentWillMount!="function"&&typeof i.componentWillMount!="function"||(typeof i.componentWillMount=="function"&&i.componentWillMount(),typeof i.UNSAFE_componentWillMount=="function"&&i.UNSAFE_componentWillMount()),typeof i.componentDidMount=="function"&&(t.flags|=4194308)):(typeof i.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=a),i.props=r,i.state=a,i.context=h,r=s):(typeof i.componentDidMount=="function"&&(t.flags|=4194308),r=!1)}else{i=t.stateNode,fa(e,t),s=t.memoizedProps,h=t.type===t.elementType?s:ft(t.type,s),i.props=h,k=t.pendingProps,y=i.context,a=n.contextType,typeof a=="object"&&a!==null?a=ot(a):(a=Ye(n)?qt:$e.current,a=Cn(t,a));var N=n.getDerivedStateFromProps;(w=typeof N=="function"||typeof i.getSnapshotBeforeUpdate=="function")||typeof i.UNSAFE_componentWillReceiveProps!="function"&&typeof i.componentWillReceiveProps!="function"||(s!==k||y!==a)&&Aa(t,i,r,a),$t=!1,y=t.memoizedState,i.state=y,ul(t,r,i,l);var P=t.memoizedState;s!==k||y!==P||be.current||$t?(typeof N=="function"&&(ei(t,n,N,r),P=t.memoizedState),(h=$t||Fa(t,n,h,r,y,P,a)||!1)?(w||typeof i.UNSAFE_componentWillUpdate!="function"&&typeof i.componentWillUpdate!="function"||(typeof i.componentWillUpdate=="function"&&i.componentWillUpdate(r,P,a),typeof i.UNSAFE_componentWillUpdate=="function"&&i.UNSAFE_componentWillUpdate(r,P,a)),typeof i.componentDidUpdate=="function"&&(t.flags|=4),typeof i.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof i.componentDidUpdate!="function"||s===e.memoizedProps&&y===e.memoizedState||(t.flags|=4),typeof i.getSnapshotBeforeUpdate!="function"||s===e.memoizedProps&&y===e.memoizedState||(t.flags|=1024),t.memoizedProps=r,t.memoizedState=P),i.props=r,i.state=P,i.context=a,r=h):(typeof i.componentDidUpdate!="function"||s===e.memoizedProps&&y===e.memoizedState||(t.flags|=4),typeof i.getSnapshotBeforeUpdate!="function"||s===e.memoizedProps&&y===e.memoizedState||(t.flags|=1024),r=!1)}return oi(e,t,n,r,o,l)}function oi(e,t,n,r,l,o){Xa(e,t);var i=(t.flags&128)!==0;if(!r&&!i)return l&&ta(t,n,!1),jt(e,t,o);r=t.stateNode,vd.current=t;var s=i&&typeof n.getDerivedStateFromError!="function"?null:r.render();return t.flags|=1,e!==null&&i?(t.child=Pn(t,e.child,null,o),t.child=Pn(t,null,s,o)):Be(e,t,s,o),t.memoizedState=r.state,l&&ta(t,n,!0),t.child}function Za(e){var t=e.stateNode;t.pendingContext?qs(e,t.pendingContext,t.pendingContext!==t.context):t.context&&qs(e,t.context,!1),Ho(e,t.containerInfo)}function Ja(e,t,n,r,l){return zn(),Oo(l),t.flags|=256,Be(e,t,n,r),t.child}var ii={dehydrated:null,treeContext:null,retryLane:0};function si(e){return{baseLanes:e,cachePool:null,transitions:null}}function qa(e,t,n){var r=t.pendingProps,l=ve.current,o=!1,i=(t.flags&128)!==0,s;if((s=i)||(s=e!==null&&e.memoizedState===null?!1:(l&2)!==0),s?(o=!0,t.flags&=-129):(e===null||e.memoizedState!==null)&&(l|=1),ae(ve,l&1),e===null)return Mo(t),e=t.memoizedState,e!==null&&(e=e.dehydrated,e!==null)?((t.mode&1)===0?t.lanes=1:e.data==="$!"?t.lanes=8:t.lanes=1073741824,null):(i=r.children,e=r.fallback,o?(r=t.mode,o=t.child,i={mode:"hidden",children:i},(r&1)===0&&o!==null?(o.childLanes=0,o.pendingProps=i):o=Tl(i,r,0,null),e=cn(e,r,n,null),o.return=t,e.return=t,o.sibling=e,t.child=o,t.child.memoizedState=si(n),t.memoizedState=ii,e):ai(t,i));if(l=e.memoizedState,l!==null&&(s=l.dehydrated,s!==null))return gd(e,t,i,r,s,l,n);if(o){o=r.fallback,i=t.mode,l=e.child,s=l.sibling;var a={mode:"hidden",children:r.children};return(i&1)===0&&t.child!==l?(r=t.child,r.childLanes=0,r.pendingProps=a,t.deletions=null):(r=bt(l,a),r.subtreeFlags=l.subtreeFlags&14680064),s!==null?o=bt(s,o):(o=cn(o,i,n,null),o.flags|=2),o.return=t,r.return=t,r.sibling=o,t.child=r,r=o,o=t.child,i=e.child.memoizedState,i=i===null?si(n):{baseLanes:i.baseLanes|n,cachePool:null,transitions:i.transitions},o.memoizedState=i,o.childLanes=e.childLanes&~n,t.memoizedState=ii,r}return o=e.child,e=o.sibling,r=bt(o,{mode:"visible",children:r.children}),(t.mode&1)===0&&(r.lanes=n),r.return=t,r.sibling=null,e!==null&&(n=t.deletions,n===null?(t.deletions=[e],t.flags|=16):n.push(e)),t.child=r,t.memoizedState=null,r}function ai(e,t){return t=Tl({mode:"visible",children:t},e.mode,0,null),t.return=e,e.child=t}function gl(e,t,n,r){return r!==null&&Oo(r),Pn(t,e.child,null,n),e=ai(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function gd(e,t,n,r,l,o,i){if(n)return t.flags&256?(t.flags&=-257,r=ni(Error(d(422))),gl(e,t,i,r)):t.memoizedState!==null?(t.child=e.child,t.flags|=128,null):(o=r.fallback,l=t.mode,r=Tl({mode:"visible",children:r.children},l,0,null),o=cn(o,l,i,null),o.flags|=2,r.return=t,o.return=t,r.sibling=o,t.child=r,(t.mode&1)!==0&&Pn(t,e.child,null,i),t.child.memoizedState=si(i),t.memoizedState=ii,o);if((t.mode&1)===0)return gl(e,t,i,null);if(l.data==="$!"){if(r=l.nextSibling&&l.nextSibling.dataset,r)var s=r.dgst;return r=s,o=Error(d(419)),r=ni(o,r,void 0),gl(e,t,i,r)}if(s=(i&e.childLanes)!==0,Xe||s){if(r=Me,r!==null){switch(i&-i){case 4:l=2;break;case 16:l=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:l=32;break;case 536870912:l=268435456;break;default:l=0}l=(l&(r.suspendedLanes|i))!==0?0:l,l!==0&&l!==o.retryLane&&(o.retryLane=l,Ct(e,l),mt(r,e,l,-1))}return Ei(),r=ni(Error(d(421))),gl(e,t,i,r)}return l.data==="$?"?(t.flags|=128,t.child=e.child,t=Td.bind(null,e),l._reactRetry=t,null):(e=o.treeContext,tt=Dt(l.nextSibling),et=t,fe=!0,dt=null,e!==null&&(rt[lt++]=_t,rt[lt++]=Et,rt[lt++]=en,_t=e.id,Et=e.overflow,en=t),t=ai(t,r.children),t.flags|=4096,t)}function eu(e,t,n){e.lanes|=t;var r=e.alternate;r!==null&&(r.lanes|=t),Ao(e.return,t,n)}function ui(e,t,n,r,l){var o=e.memoizedState;o===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:r,tail:n,tailMode:l}:(o.isBackwards=t,o.rendering=null,o.renderingStartTime=0,o.last=r,o.tail=n,o.tailMode=l)}function tu(e,t,n){var r=t.pendingProps,l=r.revealOrder,o=r.tail;if(Be(e,t,r.children,n),r=ve.current,(r&2)!==0)r=r&1|2,t.flags|=128;else{if(e!==null&&(e.flags&128)!==0)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&eu(e,n,t);else if(e.tag===19)eu(e,n,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}r&=1}if(ae(ve,r),(t.mode&1)===0)t.memoizedState=null;else switch(l){case"forwards":for(n=t.child,l=null;n!==null;)e=n.alternate,e!==null&&cl(e)===null&&(l=n),n=n.sibling;n=l,n===null?(l=t.child,t.child=null):(l=n.sibling,n.sibling=null),ui(t,!1,l,n,o);break;case"backwards":for(n=null,l=t.child,t.child=null;l!==null;){if(e=l.alternate,e!==null&&cl(e)===null){t.child=l;break}e=l.sibling,l.sibling=n,n=l,l=e}ui(t,!0,n,null,o);break;case"together":ui(t,!1,null,null,void 0);break;default:t.memoizedState=null}return t.child}function yl(e,t){(t.mode&1)===0&&e!==null&&(e.alternate=null,t.alternate=null,t.flags|=2)}function jt(e,t,n){if(e!==null&&(t.dependencies=e.dependencies),on|=t.lanes,(n&t.childLanes)===0)return null;if(e!==null&&t.child!==e.child)throw Error(d(153));if(t.child!==null){for(e=t.child,n=bt(e,e.pendingProps),t.child=n,n.return=t;e.sibling!==null;)e=e.sibling,n=n.sibling=bt(e,e.pendingProps),n.return=t;n.sibling=null}return t.child}function yd(e,t,n){switch(t.tag){case 3:Za(t),zn();break;case 5:ma(t);break;case 1:Ye(t.type)&&el(t);break;case 4:Ho(t,t.stateNode.containerInfo);break;case 10:var r=t.type._context,l=t.memoizedProps.value;ae(il,r._currentValue),r._currentValue=l;break;case 13:if(r=t.memoizedState,r!==null)return r.dehydrated!==null?(ae(ve,ve.current&1),t.flags|=128,null):(n&t.child.childLanes)!==0?qa(e,t,n):(ae(ve,ve.current&1),e=jt(e,t,n),e!==null?e.sibling:null);ae(ve,ve.current&1);break;case 19:if(r=(n&t.childLanes)!==0,(e.flags&128)!==0){if(r)return tu(e,t,n);t.flags|=128}if(l=t.memoizedState,l!==null&&(l.rendering=null,l.tail=null,l.lastEffect=null),ae(ve,ve.current),r)break;return null;case 22:case 23:return t.lanes=0,Ya(e,t,n)}return jt(e,t,n)}var nu,ci,ru,lu;nu=function(e,t){for(var n=t.child;n!==null;){if(n.tag===5||n.tag===6)e.appendChild(n.stateNode);else if(n.tag!==4&&n.child!==null){n.child.return=n,n=n.child;continue}if(n===t)break;for(;n.sibling===null;){if(n.return===null||n.return===t)return;n=n.return}n.sibling.return=n.return,n=n.sibling}},ci=function(){},ru=function(e,t,n,r){var l=e.memoizedProps;if(l!==r){e=t.stateNode,rn(yt.current);var o=null;switch(n){case"input":l=$n(e,l),r=$n(e,r),o=[];break;case"select":l=g({},l,{value:void 0}),r=g({},r,{value:void 0}),o=[];break;case"textarea":l=Vl(e,l),r=Vl(e,r),o=[];break;default:typeof l.onClick!="function"&&typeof r.onClick=="function"&&(e.onclick=Zr)}Wl(n,r);var i;n=null;for(h in l)if(!r.hasOwnProperty(h)&&l.hasOwnProperty(h)&&l[h]!=null)if(h==="style"){var s=l[h];for(i in s)s.hasOwnProperty(i)&&(n||(n={}),n[i]="")}else h!=="dangerouslySetInnerHTML"&&h!=="children"&&h!=="suppressContentEditableWarning"&&h!=="suppressHydrationWarning"&&h!=="autoFocus"&&(R.hasOwnProperty(h)?o||(o=[]):(o=o||[]).push(h,null));for(h in r){var a=r[h];if(s=l?.[h],r.hasOwnProperty(h)&&a!==s&&(a!=null||s!=null))if(h==="style")if(s){for(i in s)!s.hasOwnProperty(i)||a&&a.hasOwnProperty(i)||(n||(n={}),n[i]="");for(i in a)a.hasOwnProperty(i)&&s[i]!==a[i]&&(n||(n={}),n[i]=a[i])}else n||(o||(o=[]),o.push(h,n)),n=a;else h==="dangerouslySetInnerHTML"?(a=a?a.__html:void 0,s=s?s.__html:void 0,a!=null&&s!==a&&(o=o||[]).push(h,a)):h==="children"?typeof a!="string"&&typeof a!="number"||(o=o||[]).push(h,""+a):h!=="suppressContentEditableWarning"&&h!=="suppressHydrationWarning"&&(R.hasOwnProperty(h)?(a!=null&&h==="onScroll"&&ce("scroll",e),o||s===a||(o=[])):(o=o||[]).push(h,a))}n&&(o=o||[]).push("style",n);var h=o;(t.updateQueue=h)&&(t.flags|=4)}},lu=function(e,t,n,r){n!==r&&(t.flags|=4)};function xr(e,t){if(!fe)switch(e.tailMode){case"hidden":t=e.tail;for(var n=null;t!==null;)t.alternate!==null&&(n=t),t=t.sibling;n===null?e.tail=null:n.sibling=null;break;case"collapsed":n=e.tail;for(var r=null;n!==null;)n.alternate!==null&&(r=n),n=n.sibling;r===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:r.sibling=null}}function He(e){var t=e.alternate!==null&&e.alternate.child===e.child,n=0,r=0;if(t)for(var l=e.child;l!==null;)n|=l.lanes|l.childLanes,r|=l.subtreeFlags&14680064,r|=l.flags&14680064,l.return=e,l=l.sibling;else for(l=e.child;l!==null;)n|=l.lanes|l.childLanes,r|=l.subtreeFlags,r|=l.flags,l.return=e,l=l.sibling;return e.subtreeFlags|=r,e.childLanes=n,t}function wd(e,t,n){var r=t.pendingProps;switch(Lo(t),t.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return He(t),null;case 1:return Ye(t.type)&&qr(),He(t),null;case 3:return r=t.stateNode,Ln(),de(be),de($e),Qo(),r.pendingContext&&(r.context=r.pendingContext,r.pendingContext=null),(e===null||e.child===null)&&(ll(t)?t.flags|=4:e===null||e.memoizedState.isDehydrated&&(t.flags&256)===0||(t.flags|=1024,dt!==null&&(ki(dt),dt=null))),ci(e,t),He(t),null;case 5:Wo(t);var l=rn(mr.current);if(n=t.type,e!==null&&t.stateNode!=null)ru(e,t,n,r,l),e.ref!==t.ref&&(t.flags|=512,t.flags|=2097152);else{if(!r){if(t.stateNode===null)throw Error(d(166));return He(t),null}if(e=rn(yt.current),ll(t)){r=t.stateNode,n=t.type;var o=t.memoizedProps;switch(r[gt]=t,r[cr]=o,e=(t.mode&1)!==0,n){case"dialog":ce("cancel",r),ce("close",r);break;case"iframe":case"object":case"embed":ce("load",r);break;case"video":case"audio":for(l=0;l<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=i.createElement(n,{is:r.is}):(e=i.createElement(n),n==="select"&&(i=e,r.multiple?i.multiple=!0:r.size&&(i.size=r.size))):e=i.createElementNS(e,n),e[gt]=t,e[cr]=r,nu(e,t,!1,!1),t.stateNode=e;e:{switch(i=Bl(n,r),n){case"dialog":ce("cancel",e),ce("close",e),l=r;break;case"iframe":case"object":case"embed":ce("load",e),l=r;break;case"video":case"audio":for(l=0;lDn&&(t.flags|=128,r=!0,xr(o,!1),t.lanes=4194304)}else{if(!r)if(e=cl(i),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),xr(o,!0),o.tail===null&&o.tailMode==="hidden"&&!i.alternate&&!fe)return He(t),null}else 2*Se()-o.renderingStartTime>Dn&&n!==1073741824&&(t.flags|=128,r=!0,xr(o,!1),t.lanes=4194304);o.isBackwards?(i.sibling=t.child,t.child=i):(n=o.last,n!==null?n.sibling=i:t.child=i,o.last=i)}return o.tail!==null?(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=Se(),t.sibling=null,n=ve.current,ae(ve,r?n&1|2:n&1),t):(He(t),null);case 22:case 23:return _i(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(nt&1073741824)!==0&&(He(t),t.subtreeFlags&6&&(t.flags|=8192)):He(t),null;case 24:return null;case 25:return null}throw Error(d(156,t.tag))}function xd(e,t){switch(Lo(t),t.tag){case 1:return Ye(t.type)&&qr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Ln(),de(be),de($e),Qo(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return Wo(t),null;case 13:if(de(ve),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(d(340));zn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return de(ve),null;case 4:return Ln(),null;case 10:return Uo(t.type._context),null;case 22:case 23:return _i(),null;case 24:return null;default:return null}}var wl=!1,We=!1,kd=typeof WeakSet=="function"?WeakSet:Set,z=null;function Mn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){we(e,t,r)}else n.current=null}function di(e,t,n){try{n()}catch(r){we(e,t,r)}}var ou=!1;function Sd(e,t){if(_o=Ar,e=Fs(),mo(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break e}var i=0,s=-1,a=-1,h=0,w=0,k=e,y=null;t:for(;;){for(var N;k!==n||l!==0&&k.nodeType!==3||(s=i+l),k!==o||r!==0&&k.nodeType!==3||(a=i+r),k.nodeType===3&&(i+=k.nodeValue.length),(N=k.firstChild)!==null;)y=k,k=N;for(;;){if(k===e)break t;if(y===n&&++h===l&&(s=i),y===o&&++w===r&&(a=i),(N=k.nextSibling)!==null)break;k=y,y=k.parentNode}k=N}n=s===-1||a===-1?null:{start:s,end:a}}else n=null}n=n||{start:0,end:0}}else n=null;for(Eo={focusedElem:e,selectionRange:n},Ar=!1,z=t;z!==null;)if(t=z,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,z=e;else for(;z!==null;){t=z;try{var P=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(P!==null){var T=P.memoizedProps,_e=P.memoizedState,f=t.stateNode,c=f.getSnapshotBeforeUpdate(t.elementType===t.type?T:ft(t.type,T),_e);f.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var p=t.stateNode.containerInfo;p.nodeType===1?p.textContent="":p.nodeType===9&&p.documentElement&&p.removeChild(p.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(d(163))}}catch(_){we(t,t.return,_)}if(e=t.sibling,e!==null){e.return=t.return,z=e;break}z=t.return}return P=ou,ou=!1,P}function kr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var o=l.destroy;l.destroy=void 0,o!==void 0&&di(t,n,o)}l=l.next}while(l!==r)}}function xl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function fi(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function iu(e){var t=e.alternate;t!==null&&(e.alternate=null,iu(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[gt],delete t[cr],delete t[zo],delete t[ld],delete t[od])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function su(e){return e.tag===5||e.tag===3||e.tag===4}function au(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||su(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function pi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Zr));else if(r!==4&&(e=e.child,e!==null))for(pi(e,t,n),e=e.sibling;e!==null;)pi(e,t,n),e=e.sibling}function hi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(hi(e,t,n),e=e.sibling;e!==null;)hi(e,t,n),e=e.sibling}var De=null,pt=!1;function Ht(e,t,n){for(n=n.child;n!==null;)uu(e,t,n),n=n.sibling}function uu(e,t,n){if(vt&&typeof vt.onCommitFiberUnmount=="function")try{vt.onCommitFiberUnmount(Ir,n)}catch{}switch(n.tag){case 5:We||Mn(n,t);case 6:var r=De,l=pt;De=null,Ht(e,t,n),De=r,pt=l,De!==null&&(pt?(e=De,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):De.removeChild(n.stateNode));break;case 18:De!==null&&(pt?(e=De,n=n.stateNode,e.nodeType===8?jo(e.parentNode,n):e.nodeType===1&&jo(e,n),qn(e)):jo(De,n.stateNode));break;case 4:r=De,l=pt,De=n.stateNode.containerInfo,pt=!0,Ht(e,t,n),De=r,pt=l;break;case 0:case 11:case 14:case 15:if(!We&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var o=l,i=o.destroy;o=o.tag,i!==void 0&&((o&2)!==0||(o&4)!==0)&&di(n,t,i),l=l.next}while(l!==r)}Ht(e,t,n);break;case 1:if(!We&&(Mn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(s){we(n,t,s)}Ht(e,t,n);break;case 21:Ht(e,t,n);break;case 22:n.mode&1?(We=(r=We)||n.memoizedState!==null,Ht(e,t,n),We=r):Ht(e,t,n);break;default:Ht(e,t,n)}}function cu(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new kd),t.forEach(function(r){var l=Rd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function ht(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=i),r&=~o}if(r=l,r=Se()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Ed(r/1960))-r,10e?16:e,Bt===null)var r=!1;else{if(e=Bt,Bt=null,Cl=0,(ne&6)!==0)throw Error(d(331));var l=ne;for(ne|=4,z=e.current;z!==null;){var o=z,i=o.child;if((z.flags&16)!==0){var s=o.deletions;if(s!==null){for(var a=0;aSe()-gi?an(e,0):vi|=n),Ze(e,t)}function _u(e,t){t===0&&((e.mode&1)===0?t=1:(t=Or,Or<<=1,(Or&130023424)===0&&(Or=4194304)));var n=Qe();e=Ct(e,t),e!==null&&(Yn(e,t,n),Ze(e,n))}function Td(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),_u(e,n)}function Rd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(d(314))}r!==null&&r.delete(t),_u(e,n)}var Eu;Eu=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||be.current)Xe=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return Xe=!1,yd(e,t,n);Xe=(e.flags&131072)!==0}else Xe=!1,fe&&(t.flags&1048576)!==0&&ra(t,rl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;yl(e,t),e=t.pendingProps;var l=Cn(t,$e.current);Rn(t,n),l=Yo(null,t,r,e,l,n);var o=Xo();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Ye(r)?(o=!0,el(t)):o=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Vo(t),l.updater=vl,t.stateNode=l,l._reactInternals=t,ti(t,r,e,n),t=oi(null,t,r,!0,o,n)):(t.tag=0,fe&&o&&Ro(t),Be(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(yl(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Id(r),e=ft(r,e),l){case 0:t=li(null,t,r,e,n);break e;case 1:t=Ga(null,t,r,e,n);break e;case 11:t=Qa(null,t,r,e,n);break e;case 14:t=Ka(null,t,r,ft(r.type,e),n);break e}throw Error(d(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),li(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),Ga(e,t,r,l,n);case 3:e:{if(Za(t),e===null)throw Error(d(387));r=t.pendingProps,o=t.memoizedState,l=o.element,fa(e,t),ul(t,r,null,n);var i=t.memoizedState;if(r=i.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:i.cache,pendingSuspenseBoundaries:i.pendingSuspenseBoundaries,transitions:i.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){l=In(Error(d(423)),t),t=Ja(e,t,r,n,l);break e}else if(r!==l){l=In(Error(d(424)),t),t=Ja(e,t,r,n,l);break e}else for(tt=Dt(t.stateNode.containerInfo.firstChild),et=t,fe=!0,dt=null,n=ca(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(zn(),r===l){t=jt(e,t,n);break e}Be(e,t,r,n)}t=t.child}return t;case 5:return ma(t),e===null&&Mo(t),r=t.type,l=t.pendingProps,o=e!==null?e.memoizedProps:null,i=l.children,Co(r,l)?i=null:o!==null&&Co(r,o)&&(t.flags|=32),Xa(e,t),Be(e,t,i,n),t.child;case 6:return e===null&&Mo(t),null;case 13:return qa(e,t,n);case 4:return Ho(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=Pn(t,null,r,n):Be(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),Qa(e,t,r,l,n);case 7:return Be(e,t,t.pendingProps,n),t.child;case 8:return Be(e,t,t.pendingProps.children,n),t.child;case 12:return Be(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,o=t.memoizedProps,i=l.value,ae(il,r._currentValue),r._currentValue=i,o!==null)if(ct(o.value,i)){if(o.children===l.children&&!be.current){t=jt(e,t,n);break e}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var s=o.dependencies;if(s!==null){i=o.child;for(var a=s.firstContext;a!==null;){if(a.context===r){if(o.tag===1){a=Nt(-1,n&-n),a.tag=2;var h=o.updateQueue;if(h!==null){h=h.shared;var w=h.pending;w===null?a.next=a:(a.next=w.next,w.next=a),h.pending=a}}o.lanes|=n,a=o.alternate,a!==null&&(a.lanes|=n),Ao(o.return,n,t),s.lanes|=n;break}a=a.next}}else if(o.tag===10)i=o.type===t.type?null:o.child;else if(o.tag===18){if(i=o.return,i===null)throw Error(d(341));i.lanes|=n,s=i.alternate,s!==null&&(s.lanes|=n),Ao(i,n,t),i=o.sibling}else i=o.child;if(i!==null)i.return=o;else for(i=o;i!==null;){if(i===t){i=null;break}if(o=i.sibling,o!==null){o.return=i.return,i=o;break}i=i.return}o=i}Be(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Rn(t,n),l=ot(l),r=r(l),t.flags|=1,Be(e,t,r,n),t.child;case 14:return r=t.type,l=ft(r,t.pendingProps),l=ft(r.type,l),Ka(e,t,r,l,n);case 15:return ba(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ft(r,l),yl(e,t),t.tag=1,Ye(r)?(e=!0,el(t)):e=!1,Rn(t,n),Ua(t,r,l),ti(t,r,l,n),oi(null,t,r,!0,e,n);case 19:return tu(e,t,n);case 22:return Ya(e,t,n)}throw Error(d(156,t.tag))};function Cu(e,t){return ls(e,t)}function Ld(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function at(e,t,n,r){return new Ld(e,t,n,r)}function Ci(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Id(e){if(typeof e=="function")return Ci(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Le)return 11;if(e===Ue)return 14}return 2}function bt(e,t){var n=e.alternate;return n===null?(n=at(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Pl(e,t,n,r,l,o){var i=2;if(r=e,typeof e=="function")Ci(e)&&(i=1);else if(typeof e=="string")i=5;else e:switch(e){case Re:return cn(n.children,l,o,t);case he:i=8,l|=8;break;case Ne:return e=at(12,n,t,l|2),e.elementType=Ne,e.lanes=o,e;case me:return e=at(13,n,t,l),e.elementType=me,e.lanes=o,e;case ye:return e=at(19,n,t,l),e.elementType=ye,e.lanes=o,e;case W:return Tl(n,l,o,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case ue:i=10;break e;case Ke:i=9;break e;case Le:i=11;break e;case Ue:i=14;break e;case ze:i=16,r=null;break e}throw Error(d(130,e==null?e:typeof e,""))}return t=at(i,n,t,l),t.elementType=e,t.type=r,t.lanes=o,t}function cn(e,t,n,r){return e=at(7,e,r,t),e.lanes=n,e}function Tl(e,t,n,r){return e=at(22,e,r,t),e.elementType=W,e.lanes=n,e.stateNode={isHidden:!1},e}function Ni(e,t,n){return e=at(6,e,null,t),e.lanes=n,e}function ji(e,t,n){return t=at(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Md(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=ql(0),this.expirationTimes=ql(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=ql(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function zi(e,t,n,r,l,o,i,s,a){return e=new Md(e,t,n,s,a),t===1?(t=1,o===!0&&(t|=8)):t=0,o=at(3,null,null,t),e.current=o,o.stateNode=e,o.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Vo(o),e}function Od(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(S)}catch(j){console.error(j)}}return S(),Mi.exports=Yd(),Mi.exports}var Au;function Gd(){if(Au)return Fl;Au=1;var S=Xd();return Fl.createRoot=S.createRoot,Fl.hydrateRoot=S.hydrateRoot,Fl}var Zd=Gd();/** * @license lucide-react v0.487.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Jd=S=>S.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),qd=S=>S.replace(/^([A-Z])|[\s-_]+(\w)/g,(C,d,U)=>U?U.toUpperCase():d.toLowerCase()),$u=S=>{const C=qd(S);return C.charAt(0).toUpperCase()+C.slice(1)},Hu=(...S)=>S.filter((C,d,U)=>!!C&&C.trim()!==""&&U.indexOf(C)===d).join(" ").trim();/** + */const Jd=S=>S.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),qd=S=>S.replace(/^([A-Z])|[\s-_]+(\w)/g,(j,d,$)=>$?$.toUpperCase():d.toLowerCase()),$u=S=>{const j=qd(S);return j.charAt(0).toUpperCase()+j.slice(1)},Hu=(...S)=>S.filter((j,d,$)=>!!j&&j.trim()!==""&&$.indexOf(j)===d).join(" ").trim();/** * @license lucide-react v0.487.0 - ISC * * This source code is licensed under the ISC license. @@ -52,12 +52,12 @@ Error generating stack: `+o.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const tf=N.forwardRef(({color:S="currentColor",size:C=24,strokeWidth:d=2,absoluteStrokeWidth:U,className:R="",children:I,iconNode:Q,...G},A)=>N.createElement("svg",{ref:A,...ef,width:C,height:C,stroke:S,strokeWidth:U?Number(d)*24/Number(C):d,className:Hu("lucide",R),...G},[...Q.map(([b,X])=>N.createElement(b,X)),...Array.isArray(I)?I:[I]]));/** + */const tf=C.forwardRef(({color:S="currentColor",size:j=24,strokeWidth:d=2,absoluteStrokeWidth:$,className:R="",children:I,iconNode:Q,...Z},U)=>C.createElement("svg",{ref:U,...ef,width:j,height:j,stroke:S,strokeWidth:$?Number(d)*24/Number(j):d,className:Hu("lucide",R),...Z},[...Q.map(([b,X])=>C.createElement(b,X)),...Array.isArray(I)?I:[I]]));/** * @license lucide-react v0.487.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Xt=(S,C)=>{const d=N.forwardRef(({className:U,...R},I)=>N.createElement(tf,{ref:I,iconNode:C,className:Hu(`lucide-${Jd($u(S))}`,`lucide-${S}`,U),...R}));return d.displayName=$u(S),d};/** + */const Xt=(S,j)=>{const d=C.forwardRef(({className:$,...R},I)=>C.createElement(tf,{ref:I,iconNode:j,className:Hu(`lucide-${Jd($u(S))}`,`lucide-${S}`,$),...R}));return d.displayName=$u(S),d};/** * @license lucide-react v0.487.0 - ISC * * This source code is licensed under the ISC license. @@ -67,7 +67,7 @@ Error generating stack: `+o.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const rf=[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]],Fl=Xt("external-link",rf);/** + */const rf=[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]],Ul=Xt("external-link",rf);/** * @license lucide-react v0.487.0 - ISC * * This source code is licensed under the ISC license. @@ -97,5 +97,5 @@ Error generating stack: `+o.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const mf=[["rect",{width:"14",height:"20",x:"5",y:"2",rx:"2",ry:"2",key:"1yt0o3"}],["path",{d:"M12 18h.01",key:"mhygvu"}]],Vu=Xt("smartphone",mf);function vf(){return m.jsx("div",{className:"bg-white rounded-lg border border-slate-200 p-6 mb-4",children:m.jsx("div",{className:"flex items-start",children:m.jsxs("div",{className:"flex-1",children:[m.jsxs("div",{className:"flex items-center gap-3 mb-3",children:[m.jsx("div",{className:"w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center",children:m.jsx(Wu,{className:"w-6 h-6 text-blue-600"})}),m.jsx("h2",{className:"text-slate-800",children:"DDNSTO 远程访问"})]}),m.jsx("p",{className:"text-slate-600 text-sm mb-2",children:"DDNSTO 远程访问,不需要公网 IP、不需要开放端口。只需一个链接,即可从任何地方安全访问您的 NAS、路由器和桌面。"}),m.jsxs("a",{href:"https://www.ddnsto.com",target:"_blank",rel:"noopener noreferrer",className:"inline-flex items-center gap-1 text-sm text-blue-600 hover:underline",children:["了解更多",m.jsx(Fl,{className:"w-3 h-3"})]})]})})})}function gf({isRunning:S,isConfigured:C,hostname:d,address:U,deviceId:R,version:I,featEnabled:Q,tunnelOk:G,onRefreshStatus:A}){const[b,X]=N.useState(!1),K={label:S?"运行中":"已停止",bgColor:S?"bg-green-100":"bg-slate-200",textColor:S?"text-green-700":"text-slate-600"},$=U&&U.trim().length>0?U:"未配置",q=b,ie=G===!0,B=q?"检查中...":G===!0?"正常":G===!1?"无法连接服务器":"未知",Y=Q==="1"||Q===!0?"已开启":"未开启",Ee=R||U||d||"-",we=async()=>{if(A){X(!0);try{await A()}finally{X(!1)}}};return m.jsxs("div",{className:"bg-white rounded-lg border border-slate-200 p-6 mb-4",children:[m.jsx("h3",{className:"text-slate-800 mb-4",children:"运行状态"}),m.jsxs("div",{className:"flex items-center justify-between pb-6 border-b border-slate-100",children:[m.jsxs("div",{className:"flex items-center gap-4",children:[m.jsx("span",{className:`px-3 py-1 rounded-full text-sm ${K.bgColor} ${K.textColor}`,children:K.label}),C&&m.jsxs("div",{className:"flex items-center gap-2",children:[m.jsx("span",{className:"text-sm text-slate-500",children:"远程访问域名:"}),U&&U.trim().length>0?m.jsxs("a",{href:$,target:"_blank",rel:"noopener noreferrer",className:"text-sm text-blue-600 hover:underline flex items-center gap-1",children:[$,m.jsx(Fl,{className:"w-3 h-3"})]}):m.jsx("span",{className:"text-sm text-slate-500",children:$})]})]}),m.jsx("div",{className:"flex items-center gap-2",children:m.jsxs("button",{onClick:we,className:"px-3 py-2 rounded-md text-blue-600 text-sm hover:bg-blue-50 transition-colors flex items-center gap-1",children:[m.jsx(ff,{className:"w-4 h-4"}),"刷新状态"]})})]}),m.jsx("div",{className:"mt-6",children:m.jsxs("div",{className:"flex items-center gap-2 text-sm text-slate-700",children:[m.jsx(Wu,{className:"w-4 h-4 text-slate-400"}),q&&m.jsx("div",{className:"w-4 h-4 rounded-full border-2 border-slate-300 border-t-blue-600 animate-spin"}),m.jsxs("h4",{className:"text-sm text-slate-700",children:["服务器连接:",ie?m.jsx("span",{className:"text-green-600",children:B}):m.jsx("span",{children:B}),"| 设备 ID:",Ee," | 拓展功能:",Y,I?` | 版本:${I}`:""]})]})})]})}function Bu({checked:S,onChange:C,label:d,id:U,disabled:R,containerClassName:I,...Q}){return m.jsxs("div",{className:`ddnsto-toggle-container ${I||""}`,children:[d&&m.jsx("label",{htmlFor:U,className:"text-sm text-slate-700 mr-3 whitespace-nowrap",children:d}),m.jsxs("label",{className:"ddnsto-toggle-switch","aria-label":d||"toggle",children:[m.jsx("input",{id:U,type:"checkbox",checked:S,disabled:R,onChange:G=>C(G.target.checked),...Q}),m.jsx("span",{className:"ddnsto-toggle-slider","aria-hidden":!0})]})]})}function yf({onSave:S,isInTab:C,token:d,enabled:U,advancedConfig:R,onRegisterSave:I,onTokenChange:Q,onEnabledChange:G}){const[A,b]=N.useState(d||""),[X,K]=N.useState(U);N.useEffect(()=>{b(d||"")},[d]),N.useEffect(()=>{K(U)},[R,U]);const $=()=>{A.trim()&&S(A,X?"1":"0",R)};return N.useEffect(()=>{I&&I(()=>$())},[I,A,X,R]),m.jsxs("div",{className:C?"":"bg-white rounded-lg border border-slate-200 p-6 mb-4",children:[!C&&m.jsxs(m.Fragment,{children:[m.jsx("h3",{className:"text-slate-800 mb-2",children:"手动配置"}),m.jsx("p",{className:"text-xs text-slate-400 mb-6",children:"如果您已经在 DDNSTO 控制台获取了令牌,可以直接在此填写并启动插件。"})]}),m.jsxs("div",{className:"space-y-5",children:[m.jsx("div",{className:"pb-4",children:m.jsxs("div",{className:"flex items-center gap-2",children:[m.jsx("h4",{className:"text-sm text-slate-700",children:"启用 DDNSTO"}),m.jsx(Bu,{checked:X,onChange:q=>{K(q),G?.(q)},"aria-label":"启用 DDNSTO",containerClassName:"ml-6"})]})}),m.jsxs("div",{children:[m.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[m.jsxs("label",{className:"text-sm text-slate-700",children:["用户令牌(Token) ",m.jsx("span",{className:"text-red-500",children:"*"})]}),m.jsxs("a",{href:"https://www.ddnsto.com/docs/guide/token.html",target:"_blank",rel:"noopener noreferrer",className:"text-xs text-blue-600 hover:underline flex items-center gap-1",children:["如何查看用户令牌",m.jsx(Fl,{className:"w-3 h-3"})]})]}),m.jsx("div",{className:"relative",children:m.jsx("input",{type:"text",value:A,onChange:q=>{b(q.target.value),Q?.(q.target.value)},placeholder:"请输入您的 DDNSTO 令牌",className:"w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"})}),m.jsx("p",{className:"text-xs text-slate-400 mt-1",children:"令牌将保存在路由器本地,请勿泄露。"})]})]})]})}function wf({token:S,enabled:C,advancedConfig:d,onSave:U,isInTab:R,onRegisterSave:I}){const[Q,G]=N.useState(d.feat_enabled==="1"),[A,b]=N.useState(d.feat_disk_path_selected||"/mnt/sda1"),[X,K]=N.useState(d.feat_port||"3033"),[$,q]=N.useState(d.feat_username||"ddnsto"),[ie,B]=N.useState(d.feat_password||""),[Y,Ee]=N.useState(!1),[we,xe]=N.useState(d.index||"");N.useEffect(()=>{G(d.feat_enabled==="1"),d.feat_disk_path_selected&&b(d.feat_disk_path_selected),d.feat_port&&K(d.feat_port),d.feat_username&&q(d.feat_username),d.feat_password&&B(d.feat_password),d.index!==void 0&&xe(d.index)},[d]);const fe=()=>{U(S,C?"1":"0",{feat_enabled:Q?"1":"0",feat_port:X,feat_username:$,feat_password:ie,feat_disk_path_selected:A,mounts:d.mounts,index:we})};return N.useEffect(()=>{I&&I(()=>fe())},[I,S,C,Q,A,X,$,ie,we,d.mounts]),m.jsxs("div",{className:R?"":"bg-white rounded-lg border border-slate-200 p-6 mb-4",children:[!R&&m.jsx("h3",{className:"text-slate-800 mb-4",children:"高级功能"}),m.jsxs("div",{className:"space-y-5",children:[m.jsxs("div",{children:[m.jsx("label",{className:"block text-sm text-slate-700 mb-2",children:"设备编号(可选)"}),m.jsx("input",{type:"text",value:we,onChange:ee=>xe(ee.target.value),placeholder:"",className:"w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"}),m.jsx("p",{className:"text-xs text-slate-400 mt-1",children:"如有多台设备id重复,请修改此编号(0~100),正常情况不需要修改"})]}),m.jsxs("div",{className:"flex items-center gap-2",children:[m.jsx("h4",{className:"text-sm text-slate-700",children:"启用拓展功能"}),m.jsx(Bu,{checked:Q,onChange:ee=>G(ee),"aria-label":"启用拓展功能",containerClassName:"ml-6"})]}),m.jsxs("div",{className:"flex items-center gap-2 -mt-2",children:[m.jsx("p",{className:"text-xs text-slate-400",children:"启用后可支持控制台的「文件管理」及「远程开机」功能"}),m.jsxs("a",{href:"https://www.ddnsto.com/docs/guide/advanced.html",target:"_blank",rel:"noopener noreferrer",className:"text-xs text-blue-600 hover:underline flex items-center gap-1 whitespace-nowrap",children:["查看教程",m.jsx(Fl,{className:"w-3 h-3"})]})]}),Q&&m.jsxs("div",{className:"space-y-4",children:[m.jsxs("div",{className:"flex items-center gap-2",children:[m.jsx(cf,{className:"w-4 h-4 text-slate-600"}),m.jsx("h5",{className:"text-sm text-slate-700",children:"WebDAV 服务配置"})]}),m.jsxs("div",{className:"space-y-4 pl-6",children:[m.jsxs("div",{children:[m.jsx("label",{className:"block text-sm text-slate-600 mb-2",children:"可访问的文件目录"}),m.jsx("select",{value:A,onChange:ee=>b(ee.target.value),className:"w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white",children:d.mounts&&d.mounts.length>0?d.mounts.map(ee=>m.jsx("option",{value:ee,children:ee},ee)):m.jsx("option",{value:"",disabled:!0,children:"未检测到挂载点"})}),m.jsx("p",{className:"text-xs text-slate-400 mt-1",children:"控制台上的文件管理将只能看到此目录及其子目录。"})]}),m.jsxs("div",{children:[m.jsx("label",{className:"block text-sm text-slate-600 mb-2",children:"服务端口"}),m.jsx("input",{type:"text",value:X,onChange:ee=>K(ee.target.value),placeholder:"3033",className:"w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"})]}),m.jsxs("div",{children:[m.jsx("label",{className:"block text-sm text-slate-600 mb-2",children:"授权用户名"}),m.jsx("input",{type:"text",value:$,onChange:ee=>q(ee.target.value),placeholder:"ddnsto",className:"w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"})]}),m.jsxs("div",{children:[m.jsx("label",{className:"block text-sm text-slate-600 mb-2",children:"授权用户密码"}),m.jsxs("div",{className:"relative",children:[m.jsx("input",{type:Y?"text":"password",value:ie,onChange:ee=>B(ee.target.value),placeholder:"设置访问密码",className:"w-full px-3 py-2 pr-10 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"}),m.jsx("button",{type:"button",onClick:()=>Ee(!Y),className:"absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600",children:Y?m.jsx(of,{className:"w-4 h-4"}):m.jsx(af,{className:"w-4 h-4"})})]})]})]})]})]})]})}function xf({ddnstoToken:S,ddnstoEnabled:C,advancedConfig:d,onSave:U,saveBanner:R}){const[I,Q]=N.useState("basic"),G=N.useRef(null),A=N.useRef(),b=N.useRef(),[X,K]=N.useState(S||""),[$,q]=N.useState(C==="1");N.useEffect(()=>{K(S||"")},[S]),N.useEffect(()=>{q(C==="1")},[C]);const ie=()=>{I==="basic"?A.current?.():b.current?.()};return m.jsxs(m.Fragment,{children:[m.jsxs("div",{className:"bg-white rounded-lg border border-slate-200 mb-4",children:[m.jsx("div",{className:"border-b border-slate-200 px-6",children:m.jsx("div",{className:"flex items-center justify-between",children:m.jsxs("div",{className:"flex items-center -mb-px",children:[m.jsx("button",{onClick:()=>Q("basic"),className:`px-4 py-4 text-sm border-b-2 transition-colors ${I==="basic"?"border-blue-600 text-blue-600":"border-transparent text-slate-600 hover:text-slate-800"}`,children:"基础配置"}),m.jsx("button",{onClick:()=>Q("advanced"),className:`px-4 py-4 text-sm border-b-2 transition-colors ${I==="advanced"?"border-blue-600 text-blue-600":"border-transparent text-slate-600 hover:text-slate-800"}`,children:"高级功能"})]})})}),m.jsxs("div",{ref:G,className:"p-6",children:[I==="basic"&&m.jsx(yf,{token:X,enabled:$,advancedConfig:d,onSave:U,isInTab:!0,onRegisterSave:B=>{A.current=B},onTokenChange:K,onEnabledChange:q}),I==="advanced"&&m.jsx(wf,{token:X,enabled:$,advancedConfig:d,onSave:U,isInTab:!0,onRegisterSave:B=>{b.current=B}})]})]}),m.jsx("div",{className:"flex justify-end gap-3 mt-4",children:m.jsxs("button",{onClick:ie,disabled:!X.trim(),className:"px-4 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center gap-2",children:[m.jsx(hf,{className:"w-4 h-4"}),"保存配置并应用"]})}),R.state!=="idle"&&m.jsxs("div",{className:["mt-2 rounded-md border px-3 py-2 text-sm",R.state==="loading"?"bg-blue-50 border-blue-200 text-blue-800":"",R.state==="success"?"bg-emerald-50 border-emerald-200 text-emerald-800":"",R.state==="error"?"bg-red-50 border-red-200 text-red-800":""].join(" "),children:[m.jsx("div",{className:"font-medium",children:R.message}),R.description&&m.jsx("div",{className:"text-xs mt-1 opacity-80 break-words",children:R.description})]})]})}const An={auth:0,starting:1,binding:2,domain:3,checking:4};function kf({apiBase:S,csrfToken:C,deviceId:d,onboardingBase:U,onComplete:R,onRefreshStatus:I,isInTab:Q}){const[G,A]=N.useState(!1),[b,X]=N.useState(U||"https://www.kooldns.cn/bind"),[K,$]=N.useState(`${U||"https://www.kooldns.cn/bind"}/#/auth?send=1&source=openwrt&callback=*`),[q,ie]=N.useState("auth"),[B,Y]=N.useState(d||""),[Ee,we]=N.useState(""),[xe,fe]=N.useState(""),[ee,Ce]=N.useState(!1),[Re,pe]=N.useState(null),Ne=N.useRef("auth"),ke=N.useCallback(g=>{ie(y=>An[g]>An[y]?g:y)},[]);N.useEffect(()=>{Ne.current=q},[q]),N.useEffect(()=>{console.log("Onboarding iframe url:",K)},[K]);const Je=N.useCallback(()=>{ie("auth"),we(""),fe(""),pe(null),Y(d||""),$(`${b}/#/auth?send=1&source=openwrt&callback=*`)},[ke,d,b]);N.useEffect(()=>{const g="/#/auth?send=1&source=openwrt&callback=*",y=U||"https://www.kooldns.cn/bind";X(y),$(`${y}${g}`)},[U]),N.useEffect(()=>{Y(d||"")},[d]);const Oe=N.useCallback(async()=>{for(let c=0;c<20;c+=1){const v=await fetch(`${S}/admin/services/ddnsto/api/status`,{credentials:"same-origin"});if(v.ok){const H=(await v.json())?.data||{},M=H.deviceId||H.device_id||"";if(M&&Y(M),H.running)return{running:!0,deviceId:M}}await new Promise(F=>setTimeout(F,2e3))}throw new Error("ddnsto not running")},[S]),he=N.useCallback(async g=>{const y={url:g};C&&(y.token=C);const c=await fetch(`${S}/admin/services/ddnsto/api/onboarding/address`,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json",...C?{"X-LuCI-Token":C}:{}},body:JSON.stringify(y)});if(!c.ok)throw new Error(`HTTP ${c.status}`);const v=await c.json();if(!v?.ok)throw new Error(v?.error||"save address failed")},[S,C]),ge=N.useCallback(async(g,y)=>{Ce(!0),pe(null);const c=B||d||"",v=`${b}/#/bind?status=starting&token=${encodeURIComponent(g)}&sign=${encodeURIComponent(y)}${c?`&routerId=${encodeURIComponent(c)}`:""}`;$(v),ke("starting");try{const F=await fetch(`${S}/admin/services/ddnsto/api/onboarding/start`,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json",...C?{"X-LuCI-Token":C}:{}},body:JSON.stringify({token:g})});if(!F.ok)throw new Error(`HTTP ${F.status}`);const H=await F.json();if(!H?.ok)throw new Error(H?.error||"start failed");I&&await I();const J=(await Oe())?.deviceId||B||d||"";if(An[Ne.current]>=An.domain)return;const re=`${b}/#/bind?status=starting&routerId=${encodeURIComponent(J)}&token=${encodeURIComponent(g)}&sign=${encodeURIComponent(y)}`;$(re),ke("binding"),Y(J)}catch{pe("启动失败,请稍后重试")}finally{Ce(!1)}},[ke,S,C,d,b,I,Oe,Je,B]),Ue=N.useCallback(g=>{const y=g||B;if(!Ee||!xe||!y)return;const c=`${b}/#/domain?sign=${encodeURIComponent(xe)}&token=${encodeURIComponent(Ee)}&routerId=${encodeURIComponent(y)}&netaddr=127.0.0.1&source=openwrt`;$(c),ie("domain")},[xe,Ee,b,B]),ze=N.useCallback(async g=>{$(`${b}/#/check?url=${encodeURIComponent(g)}`),ie("checking"),R();try{await he(g),I&&await I()}catch{pe("保存域名失败,请重试")}},[b,R,I,he]),W=N.useCallback(()=>{A(!0),Je()},[Je]);N.useEffect(()=>{const g=y=>{if(!G)return;console.log("Onboarding message:",y.data);const c=y.data;let v=c;if(typeof c=="string")try{v=JSON.parse(c)}catch{return}if(!v||typeof v!="object")return;const F=v.data,M=F&&typeof F=="object"?F:v;if((typeof M.auth=="string"?M.auth:"")!=="ddnsto")return;const re=typeof M.sign=="string"?M.sign:"",te=typeof M.token=="string"?M.token:"",se=typeof M.step=="string"?M.step:"",We=typeof M.status=="string"?M.status:"",Gt=typeof M.url=="string"?M.url:"",fn=M.success,pn=typeof fn=="number"?fn:Number(fn);if(re&&te&&q==="auth"&&!ee){we(te),fe(re),ge(te,re);return}if(se==="bind"&&We==="success"){const Pt=typeof M.router_uid=="string"?M.router_uid:B;Pt&&Y(Pt),Ue(Pt);return}se==="domain"&&Gt&&pn===0&&An[Ne.current]window.removeEventListener("message",g)},[Ue,ze,ee,ge,G,q]);const E=()=>m.jsx("div",{className:"flex items-center justify-between mb-6",children:m.jsx("h3",{className:"text-slate-800",children:"快速向导"})});return G?m.jsxs("div",{className:Q?"":"bg-white rounded-lg border border-slate-200 p-6 mb-4",children:[!Q&&E(),m.jsx("div",{className:"rounded-lg border border-slate-200 overflow-hidden",children:m.jsx("iframe",{src:K,title:"快速向导",className:"w-full",style:{width:"100%",height:"70vh",maxHeight:"400px",border:0},loading:"lazy"})})]}):m.jsxs("div",{className:Q?"":"bg-white rounded-lg border border-slate-200 p-6 mb-4",children:[!Q&&E(),m.jsxs("div",{className:"text-center py-8",children:[m.jsx("div",{className:"mb-4 flex justify-center",children:m.jsx("div",{className:"w-16 h-16 rounded-full bg-blue-50 flex items-center justify-center",children:m.jsx(Vu,{className:"w-8 h-8 text-blue-600"})})}),m.jsx("h4",{className:"text-slate-800 mb-2",children:"欢迎使用 DDNSTO!"}),m.jsx("p",{className:"text-sm text-slate-600 mb-6 max-w-md mx-auto",children:"通过微信扫码登录,我们将引导您完成插件配置"}),m.jsxs("button",{onClick:W,className:"px-6 py-2.5 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors inline-flex items-center gap-2",children:[m.jsx(Vu,{className:"w-4 h-4"}),"开始配置"]})]})]})}function Sf({config:S}){const[C,d]=N.useState(!1),[U,R]=N.useState(!1),[I,Q]=N.useState("OpenWrt"),[G,A]=N.useState(""),[b,X]=N.useState(""),[K,$]=N.useState(""),[q,ie]=N.useState(""),[B,Y]=N.useState("1"),[Ee,we]=N.useState({feat_enabled:"0",feat_port:"",feat_username:"",feat_password:"",feat_disk_path_selected:"",mounts:[],index:""}),[,xe]=N.useState(null),[fe,ee]=N.useState(null),[Ce,Re]=N.useState({state:"idle",message:""}),pe=N.useRef(),Ne=(S?.api_base||"/cgi-bin/luci").replace(/\/$/,""),ke=S?.token||"",Je=(S?.onboarding_base||"https://www.kooldns.cn/bind").replace(/\/$/,""),Oe=N.useCallback((W,E,g,y)=>{pe.current&&window.clearTimeout(pe.current),Re({state:W,message:E,description:g}),y&&(pe.current=window.setTimeout(()=>{Re({state:"idle",message:""})},y))},[]);N.useEffect(()=>()=>{pe.current&&window.clearTimeout(pe.current)},[]);const he=N.useCallback(async()=>{try{const W=await fetch(`${Ne}/admin/services/ddnsto/api/config`,{credentials:"same-origin"});if(!W.ok)throw new Error(`HTTP ${W.status}`);const g=(await W.json())?.data||{};g.address&&A(g.address),g.device_id!==void 0?X(g.device_id):g.deviceId!==void 0&&X(g.deviceId),g.version!==void 0&&$(g.version),g.token&&ie(g.token),g.enabled!==void 0&&Y(g.enabled),we({feat_enabled:g.feat_enabled||"0",feat_port:g.feat_port||"",feat_username:g.feat_username||"",feat_password:g.feat_password||"",feat_disk_path_selected:g.feat_disk_path_selected||"",mounts:g.mounts||[],index:g.index||""})}catch(W){console.error("Failed to fetch ddnsto config",W)}},[Ne]),ge=N.useCallback(async()=>{try{const W=await fetch(`${Ne}/admin/services/ddnsto/api/status`,{credentials:"same-origin"});if(!W.ok)throw new Error(`HTTP ${W.status}`);const g=(await W.json())?.data||{};d(!!g.running),g.token_set&&R(!0),g.hostname&&Q(g.hostname),g.device_id!==void 0?X(g.device_id):g.deviceId!==void 0&&X(g.deviceId),g.address&&A(g.address),g.version!==void 0&&$(g.version),g.tunnel_ok!==void 0&&g.tunnel_ok!==null?ee(g.tunnel_ok===!0):ee(null),xe(null)}catch(W){console.error("Failed to fetch ddnsto status",W),xe("无法获取运行状态"),ee(null)}},[Ne]),Ue=N.useCallback(async()=>{await he(),await ge()},[he,ge]),ze=N.useCallback(async(W,E,g)=>{Oe("loading","正在保存配置...","正在将配置写入路由器,请稍候");try{const y=new URLSearchParams;ke&&y.append("token",ke),y.append("ddnsto_token",W),y.append("enabled",E),y.append("feat_enabled",g.feat_enabled),y.append("feat_port",g.feat_port),y.append("feat_username",g.feat_username),y.append("feat_password",g.feat_password),y.append("feat_disk_path_selected",g.feat_disk_path_selected),y.append("index",g.index||"");const c=await fetch(`${Ne}/admin/services/ddnsto/api/config`,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded",...ke?{"X-LuCI-Token":ke}:{}},body:y});if(!c.ok)throw new Error(`HTTP ${c.status}`);const v=await c.json();if(!v?.ok)throw new Error(v?.error||"Save failed");Oe("success","配置已保存并生效",void 0,3e3),R(!0),await he(),await ge()}catch(y){console.error("Failed to save config",y);const c=y instanceof Error?y.message:String(y);Oe("error","保存失败,请重试",c,4e3)}},[Ne,ke,he,ge]);return N.useEffect(()=>{let W;return he(),ge(),W=window.setInterval(ge,1e3*10),()=>{W&&window.clearInterval(W)}},[he,ge]),m.jsx("div",{className:"min-h-screen bg-slate-50",children:m.jsx("main",{children:m.jsxs("div",{className:"max-w-5xl mx-auto px-6 py-8",children:[m.jsx(vf,{}),m.jsx(gf,{isRunning:C,isConfigured:U,hostname:I,address:G,deviceId:b,version:K,featEnabled:Ee.feat_enabled,tunnelOk:fe,onRefreshStatus:Ue}),m.jsx(kf,{apiBase:Ne,csrfToken:ke,deviceId:b,onboardingBase:Je,onRefreshStatus:Ue,onComplete:()=>R(!0)}),m.jsx(xf,{ddnstoToken:q,ddnstoEnabled:B,advancedConfig:Ee,onSave:ze,saveBanner:Ce})]})})})}const _f='/*! tailwindcss v4.1.3 | MIT License | https://tailwindcss.com */*,:before,:after,::backdrop{--tw-border-style: solid}@layer properties{@supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x: 0;--tw-translate-y: 0;--tw-translate-z: 0;--tw-rotate-x: rotateX(0);--tw-rotate-y: rotateY(0);--tw-rotate-z: rotateZ(0);--tw-skew-x: skewX(0);--tw-skew-y: skewY(0);--tw-space-y-reverse: 0;--tw-border-style: solid;--tw-shadow: 0 0 #0000;--tw-shadow-color: initial;--tw-shadow-alpha: 100%;--tw-inset-shadow: 0 0 #0000;--tw-inset-shadow-color: initial;--tw-inset-shadow-alpha: 100%;--tw-ring-color: initial;--tw-ring-shadow: 0 0 #0000;--tw-inset-ring-color: initial;--tw-inset-ring-shadow: 0 0 #0000;--tw-ring-inset: initial;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-offset-shadow: 0 0 #0000}}}@layer theme{:root,:host{--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-500: oklch(.637 .237 25.331);--color-amber-50: oklch(.987 .022 95.277);--color-amber-200: oklch(.924 .12 95.746);--color-amber-600: oklch(.666 .179 58.318);--color-green-50: oklch(.982 .018 155.826);--color-green-100: oklch(.962 .044 156.743);--color-green-200: oklch(.925 .084 155.995);--color-green-600: oklch(.627 .194 149.214);--color-green-700: oklch(.527 .154 150.069);--color-green-800: oklch(.448 .119 151.328);--color-blue-50: oklch(.97 .014 254.604);--color-blue-200: oklch(.882 .059 254.128);--color-blue-500: oklch(.623 .214 259.815);--color-blue-600: oklch(.546 .245 262.881);--color-blue-700: oklch(.488 .243 264.376);--color-slate-50: oklch(.984 .003 247.858);--color-slate-100: oklch(.968 .007 247.896);--color-slate-200: oklch(.929 .013 255.508);--color-slate-300: oklch(.869 .022 252.894);--color-slate-400: oklch(.704 .04 256.788);--color-slate-500: oklch(.554 .046 257.417);--color-slate-600: oklch(.446 .043 257.281);--color-slate-700: oklch(.372 .044 257.287);--color-slate-800: oklch(.279 .041 260.031);--color-white: #fff;--spacing: .25rem;--container-md: 28rem;--container-5xl: 64rem;--text-xs: .75rem;--text-xs--line-height: calc(1 / .75);--text-sm: .875rem;--text-sm--line-height: calc(1.25 / .875);--text-base: 1rem;--text-lg: 1.125rem;--text-xl: 1.25rem;--text-2xl: 1.5rem;--font-weight-normal: 400;--font-weight-medium: 500;--default-transition-duration: .15s;--default-transition-timing-function: cubic-bezier(.4, 0, .2, 1);--default-font-family: var(--font-sans);--default-font-feature-settings: var(--font-sans--font-feature-settings);--default-font-variation-settings: var(--font-sans--font-variation-settings);--default-mono-font-family: var(--font-mono);--default-mono-font-feature-settings: var(--font-mono--font-feature-settings);--default-mono-font-variation-settings: var(--font-mono--font-variation-settings)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings, normal);font-variation-settings:var(--default-font-variation-settings, normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings, normal);font-variation-settings:var(--default-mono-font-variation-settings, normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:currentColor}@supports (color: color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentColor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}body{background-color:var(--background);color:var(--foreground)}*{border-color:var(--border);outline-color:var(--ring)}@supports (color: color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring) 50%,transparent)}}body{background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h1{font-size:var(--text-2xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h2{font-size:var(--text-xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h3{font-size:var(--text-lg);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h4,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) label,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) button{font-size:var(--text-base);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) input{font-size:var(--text-base);font-weight:var(--font-weight-normal);line-height:1.5}}@layer utilities{.absolute{position:absolute}.relative{position:relative}.inset-0{inset:calc(var(--spacing) * 0)}.top-1\\/2{top:50%}.right-2{right:calc(var(--spacing) * 2)}.z-10{z-index:10}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-auto{margin-inline:auto}.mt-0\\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.h-0\\.5{height:calc(var(--spacing) * .5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.h-40{height:calc(var(--spacing) * 40)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-40{width:calc(var(--spacing) * 40)}.w-full{width:100%}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.translate-x-1{--tw-translate-x: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-6{--tw-translate-x: calc(var(--spacing) * 6);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y)}.cursor-pointer{cursor:pointer}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.grid-rows-8{grid-template-rows:repeat(8,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-600{border-color:var(--color-blue-600)}.border-green-200{border-color:var(--color-green-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-transparent{border-color:#0000}.bg-amber-50{background-color:var(--color-amber-50)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-600{background-color:var(--color-green-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-300{background-color:var(--color-slate-300)}.bg-slate-800{background-color:var(--color-slate-800)}.bg-white{background-color:var(--color-white)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\\.5{padding-block:calc(var(--spacing) * 2.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-8{padding-block:calc(var(--spacing) * 8)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pl-7{padding-left:calc(var(--spacing) * 7)}.text-center{text-align:center}.text-right{text-align:right}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading, var(--text-xs--line-height))}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-amber-600{color:var(--color-amber-600)}.text-blue-600{color:var(--color-blue-600)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-red-500{color:var(--color-red-500)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-white{color:var(--color-white)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}@media(hover:hover){.hover\\:bg-blue-50:hover{background-color:var(--color-blue-50)}}@media(hover:hover){.hover\\:bg-blue-700:hover{background-color:var(--color-blue-700)}}@media(hover:hover){.hover\\:bg-green-700:hover{background-color:var(--color-green-700)}}@media(hover:hover){.hover\\:bg-slate-50:hover{background-color:var(--color-slate-50)}}@media(hover:hover){.hover\\:text-slate-600:hover{color:var(--color-slate-600)}}@media(hover:hover){.hover\\:text-slate-800:hover{color:var(--color-slate-800)}}@media(hover:hover){.hover\\:underline:hover{text-decoration-line:underline}}.focus\\:ring-2:focus{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\\:ring-blue-500:focus{--tw-ring-color: var(--color-blue-500)}.focus\\:outline-none:focus{--tw-outline-style: none;outline-style:none}.disabled\\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\\:bg-slate-300:disabled{background-color:var(--color-slate-300)}}:root,:host{--font-size: 16px;--background: #fff;--foreground: oklch(.145 0 0);--card: #fff;--card-foreground: oklch(.145 0 0);--popover: oklch(1 0 0);--popover-foreground: oklch(.145 0 0);--primary: #030213;--primary-foreground: oklch(1 0 0);--secondary: oklch(.95 .0058 264.53);--secondary-foreground: #030213;--muted: #ececf0;--muted-foreground: #717182;--accent: #e9ebef;--accent-foreground: #030213;--destructive: #d4183d;--destructive-foreground: #fff;--border: #0000001a;--input: transparent;--input-background: #f3f3f5;--switch-background: #cbced4;--font-weight-medium: 500;--font-weight-normal: 400;--ring: oklch(.708 0 0);--chart-1: oklch(.646 .222 41.116);--chart-2: oklch(.6 .118 184.704);--chart-3: oklch(.398 .07 227.392);--chart-4: oklch(.828 .189 84.429);--chart-5: oklch(.769 .188 70.08);--radius: .625rem;--sidebar: oklch(.985 0 0);--sidebar-foreground: oklch(.145 0 0);--sidebar-primary: #030213;--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.97 0 0);--sidebar-accent-foreground: oklch(.205 0 0);--sidebar-border: oklch(.922 0 0);--sidebar-ring: oklch(.708 0 0)}.dark{--background: oklch(.145 0 0);--foreground: oklch(.985 0 0);--card: oklch(.145 0 0);--card-foreground: oklch(.985 0 0);--popover: oklch(.145 0 0);--popover-foreground: oklch(.985 0 0);--primary: oklch(.985 0 0);--primary-foreground: oklch(.205 0 0);--secondary: oklch(.269 0 0);--secondary-foreground: oklch(.985 0 0);--muted: oklch(.269 0 0);--muted-foreground: oklch(.708 0 0);--accent: oklch(.269 0 0);--accent-foreground: oklch(.985 0 0);--destructive: oklch(.396 .141 25.723);--destructive-foreground: oklch(.637 .237 25.331);--border: oklch(.269 0 0);--input: oklch(.269 0 0);--ring: oklch(.439 0 0);--font-weight-medium: 500;--font-weight-normal: 400;--chart-1: oklch(.488 .243 264.376);--chart-2: oklch(.696 .17 162.48);--chart-3: oklch(.769 .188 70.08);--chart-4: oklch(.627 .265 303.9);--chart-5: oklch(.645 .246 16.439);--sidebar: oklch(.205 0 0);--sidebar-foreground: oklch(.985 0 0);--sidebar-primary: oklch(.488 .243 264.376);--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.269 0 0);--sidebar-accent-foreground: oklch(.985 0 0);--sidebar-border: oklch(.269 0 0);--sidebar-ring: oklch(.439 0 0)}html{font-size:var(--font-size);font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}@property --tw-translate-x{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-y{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-z{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-rotate-x{syntax: "*"; inherits: false; initial-value: rotateX(0);}@property --tw-rotate-y{syntax: "*"; inherits: false; initial-value: rotateY(0);}@property --tw-rotate-z{syntax: "*"; inherits: false; initial-value: rotateZ(0);}@property --tw-skew-x{syntax: "*"; inherits: false; initial-value: skewX(0);}@property --tw-skew-y{syntax: "*"; inherits: false; initial-value: skewY(0);}@property --tw-space-y-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-border-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-shadow-color{syntax: "*"; inherits: false}@property --tw-shadow-alpha{syntax: ""; inherits: false; initial-value: 100%;}@property --tw-inset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-shadow-color{syntax: "*"; inherits: false}@property --tw-inset-shadow-alpha{syntax: ""; inherits: false; initial-value: 100%;}@property --tw-ring-color{syntax: "*"; inherits: false}@property --tw-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-ring-color{syntax: "*"; inherits: false}@property --tw-inset-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-ring-inset{syntax: "*"; inherits: false}@property --tw-ring-offset-width{syntax: ""; inherits: false; initial-value: 0;}@property --tw-ring-offset-color{syntax: "*"; inherits: false; initial-value: #fff;}@property --tw-ring-offset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}',Ef=".ddnsto-host{padding:16px 20px;background-color:#f8fafc;border-radius:16px;overflow:hidden}.ddnsto-host #app{display:block}",Cf='.ddnsto-toggle-container{display:inline-flex;align-items:center}.ddnsto-toggle-switch{position:relative;display:inline-block;width:44px;height:22px;cursor:pointer}.ddnsto-toggle-switch input{opacity:0;width:0;height:0;position:absolute;inset:0;margin:0}.ddnsto-toggle-slider{position:absolute;cursor:pointer;inset:0;background-color:#e5e7eb;transition:.2s ease;border-radius:22px;border:1px solid #cbd5e1;box-shadow:0 1px 2px #0000000a inset}.ddnsto-toggle-slider:before{position:absolute;content:"";height:16px;width:16px;left:2px;top:2px;background-color:#fff;transition:.2s ease;border-radius:50%;box-shadow:0 1px 2px #0003}.ddnsto-toggle-switch input:checked+.ddnsto-toggle-slider{background-color:#3b82f6;border-color:#3b82f6}.ddnsto-toggle-switch input:checked+.ddnsto-toggle-slider:before{transform:translate(22px)}.ddnsto-toggle-switch input:focus-visible+.ddnsto-toggle-slider{box-shadow:0 0 0 3px #3b82f640}.ddnsto-toggle-switch input:disabled+.ddnsto-toggle-slider{opacity:.6;cursor:not-allowed}',Nr={token:"",prefix:"",api_base:"/cgi-bin/luci",lang:"zh-cn",onboarding_base:"https://web.ddnsto.com/openwrt-bind"},jr=typeof window<"u"&&window.ddnstoConfig||{},Nf={token:jr.token??Nr.token,prefix:jr.prefix??Nr.prefix,api_base:jr.api_base??Nr.api_base,lang:jr.lang??Nr.lang,onboarding_base:jr.onboarding_base??Nr.onboarding_base},dn=document.getElementById("root")||document.getElementById("app"),jf=()=>{if(!document.head.querySelector("style[data-ddnsto-host]")){const S=document.createElement("style");S.setAttribute("data-ddnsto-host","true"),S.textContent=Ef,document.head.appendChild(S)}},zf=dn?.hasAttribute("data-ddnsto-shadow")||dn?.dataset.shadow==="true";let Di=dn;if(dn){jf();const S=`${_f} -${Cf}`;if(zf&&dn.attachShadow){const C=dn.shadowRoot||dn.attachShadow({mode:"open"});if(!C.querySelector("style[data-ddnsto-style]")){const d=document.createElement("style");d.setAttribute("data-ddnsto-style","true"),d.textContent=S,C.appendChild(d)}Di=C}else if(!document.head.querySelector("style[data-ddnsto-style]")){const C=document.createElement("style");C.setAttribute("data-ddnsto-style","true"),C.textContent=S,document.head.appendChild(C)}Di&&Zd.createRoot(Di).render(m.jsx(Qd.StrictMode,{children:m.jsx(Sf,{config:Nf})}))} + */const mf=[["rect",{width:"14",height:"20",x:"5",y:"2",rx:"2",ry:"2",key:"1yt0o3"}],["path",{d:"M12 18h.01",key:"mhygvu"}]],Vu=Xt("smartphone",mf);function vf(){return m.jsx("div",{className:"bg-white rounded-lg border border-slate-200 p-6 mb-4",children:m.jsx("div",{className:"flex items-start",children:m.jsxs("div",{className:"flex-1",children:[m.jsxs("div",{className:"flex items-center gap-3 mb-3",children:[m.jsx("div",{className:"w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center",children:m.jsx(Wu,{className:"w-6 h-6 text-blue-600"})}),m.jsx("h2",{className:"text-slate-800",children:"DDNSTO 远程访问"})]}),m.jsx("p",{className:"text-slate-600 text-sm mb-2",children:"DDNSTO 远程访问,不需要公网 IP、不需要开放端口。只需一个链接,即可从任何地方安全访问您的 NAS、路由器和桌面。"}),m.jsxs("a",{href:"https://www.ddnsto.com",target:"_blank",rel:"noopener noreferrer",className:"inline-flex items-center gap-1 text-sm text-blue-600 hover:underline",children:["了解更多",m.jsx(Ul,{className:"w-3 h-3"})]})]})})})}function gf({isRunning:S,isConfigured:j,hostname:d,address:$,deviceId:R,version:I,featEnabled:Q,tunnelOk:Z,onRefreshStatus:U}){const[b,X]=C.useState(!1),K={label:S?"运行中":"已停止",bgColor:S?"bg-green-100":"bg-slate-200",textColor:S?"text-green-700":"text-slate-600"},V=$&&$.trim().length>0?$:"未配置",ee=b,se=Z===!0,B=ee?"检查中...":Z===!0?"正常":Z===!1?"无法连接服务器":"未知",Y=Q==="1"||Q===!0?"已开启":"未开启",Ee=R||"-",xe=async()=>{if(U){X(!0);try{await U()}finally{X(!1)}}};return m.jsxs("div",{className:"bg-white rounded-lg border border-slate-200 p-6 mb-4",children:[m.jsx("h3",{className:"text-slate-800 mb-4",children:"运行状态"}),m.jsxs("div",{className:"flex items-center justify-between pb-6 border-b border-slate-100",children:[m.jsxs("div",{className:"flex items-center gap-4",children:[m.jsx("span",{className:`px-3 py-1 rounded-full text-sm ${K.bgColor} ${K.textColor}`,children:K.label}),j&&m.jsxs("div",{className:"flex items-center gap-2",children:[m.jsx("span",{className:"text-sm text-slate-500",children:"远程访问域名:"}),$&&$.trim().length>0?m.jsxs("a",{href:V,target:"_blank",rel:"noopener noreferrer",className:"text-sm text-blue-600 hover:underline flex items-center gap-1",children:[V,m.jsx(Ul,{className:"w-3 h-3"})]}):m.jsx("span",{className:"text-sm text-slate-500",children:V})]})]}),m.jsx("div",{className:"flex items-center gap-2",children:m.jsxs("button",{onClick:xe,className:"px-3 py-2 rounded-md text-blue-600 text-sm hover:bg-blue-50 transition-colors flex items-center gap-1",children:[m.jsx(ff,{className:"w-4 h-4"}),"刷新状态"]})})]}),m.jsx("div",{className:"mt-6",children:m.jsxs("div",{className:"flex items-center gap-2 text-sm text-slate-700",children:[m.jsx(Wu,{className:"w-4 h-4 text-slate-400"}),ee&&m.jsx("div",{className:"w-4 h-4 rounded-full border-2 border-slate-300 border-t-blue-600 animate-spin"}),m.jsxs("h4",{className:"text-sm text-slate-700",children:["服务器连接:",se?m.jsx("span",{className:"text-green-600",children:B}):m.jsx("span",{children:B}),"| 设备 ID:",Ee," | 拓展功能:",Y,I?` | 版本:${I}`:""]})]})})]})}function Bu({checked:S,onChange:j,label:d,id:$,disabled:R,containerClassName:I,...Q}){return m.jsxs("div",{className:`ddnsto-toggle-container ${I||""}`,children:[d&&m.jsx("label",{htmlFor:$,className:"text-sm text-slate-700 mr-3 whitespace-nowrap",children:d}),m.jsxs("label",{className:"ddnsto-toggle-switch","aria-label":d||"toggle",children:[m.jsx("input",{id:$,type:"checkbox",checked:S,disabled:R,onChange:Z=>j(Z.target.checked),...Q}),m.jsx("span",{className:"ddnsto-toggle-slider","aria-hidden":!0})]})]})}function yf({onSave:S,isInTab:j,token:d,enabled:$,advancedConfig:R,onRegisterSave:I,onTokenChange:Q,onEnabledChange:Z}){const[U,b]=C.useState(d||""),[X,K]=C.useState($);C.useEffect(()=>{b(d||"")},[d]),C.useEffect(()=>{K($)},[R,$]);const V=()=>{U.trim()&&S(U,X?"1":"0",R)};return C.useEffect(()=>{I&&I(()=>V())},[I,U,X,R]),m.jsxs("div",{className:j?"":"bg-white rounded-lg border border-slate-200 p-6 mb-4",children:[!j&&m.jsxs(m.Fragment,{children:[m.jsx("h3",{className:"text-slate-800 mb-2",children:"手动配置"}),m.jsx("p",{className:"text-xs text-slate-400 mb-6",children:"如果您已经在 DDNSTO 控制台获取了令牌,可以直接在此填写并启动插件。"})]}),m.jsxs("div",{className:"space-y-5",children:[m.jsx("div",{className:"pb-4",children:m.jsxs("div",{className:"flex items-center gap-2",children:[m.jsx("h4",{className:"text-sm text-slate-700",children:"启用 DDNSTO"}),m.jsx(Bu,{checked:X,onChange:ee=>{K(ee),Z?.(ee)},"aria-label":"启用 DDNSTO",containerClassName:"ml-6"})]})}),m.jsxs("div",{children:[m.jsxs("div",{className:"flex items-center gap-2 mb-2",children:[m.jsxs("label",{className:"text-sm text-slate-700",children:["用户令牌(Token) ",m.jsx("span",{className:"text-red-500",children:"*"})]}),m.jsxs("a",{href:"https://www.ddnsto.com/docs/guide/token.html",target:"_blank",rel:"noopener noreferrer",className:"text-xs text-blue-600 hover:underline flex items-center gap-1",children:["如何查看用户令牌",m.jsx(Ul,{className:"w-3 h-3"})]})]}),m.jsx("div",{className:"relative",children:m.jsx("input",{type:"text",value:U,onChange:ee=>{b(ee.target.value),Q?.(ee.target.value)},placeholder:"请输入您的 DDNSTO 令牌",className:"w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"})}),m.jsx("p",{className:"text-xs text-slate-400 mt-1",children:"令牌将保存在路由器本地,请勿泄露。"})]})]})]})}function wf({token:S,enabled:j,advancedConfig:d,onSave:$,isInTab:R,onRegisterSave:I}){const[Q,Z]=C.useState(d.feat_enabled==="1"),[U,b]=C.useState(d.feat_disk_path_selected||"/mnt/sda1"),[X,K]=C.useState(d.feat_port||"3033"),[V,ee]=C.useState(d.feat_username||"ddnsto"),[se,B]=C.useState(d.feat_password||""),[Y,Ee]=C.useState(!1),[xe,ke]=C.useState(d.index||"");C.useEffect(()=>{Z(d.feat_enabled==="1"),d.feat_disk_path_selected&&b(d.feat_disk_path_selected),d.feat_port&&K(d.feat_port),d.feat_username&&ee(d.feat_username),d.feat_password&&B(d.feat_password),d.index!==void 0&&ke(d.index)},[d]);const pe=()=>{$(S,j?"1":"0",{feat_enabled:Q?"1":"0",feat_port:X,feat_username:V,feat_password:se,feat_disk_path_selected:U,mounts:d.mounts,index:xe})};return C.useEffect(()=>{I&&I(()=>pe())},[I,S,j,Q,U,X,V,se,xe,d.mounts]),m.jsxs("div",{className:R?"":"bg-white rounded-lg border border-slate-200 p-6 mb-4",children:[!R&&m.jsx("h3",{className:"text-slate-800 mb-4",children:"高级功能"}),m.jsxs("div",{className:"space-y-5",children:[m.jsxs("div",{children:[m.jsx("label",{className:"block text-sm text-slate-700 mb-2",children:"设备编号(可选)"}),m.jsx("input",{type:"text",value:xe,onChange:te=>ke(te.target.value),placeholder:"",className:"w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"}),m.jsx("p",{className:"text-xs text-slate-400 mt-1",children:"如有多台设备id重复,请修改此编号(0~100),正常情况不需要修改"})]}),m.jsxs("div",{className:"flex items-center gap-2",children:[m.jsx("h4",{className:"text-sm text-slate-700",children:"启用拓展功能"}),m.jsx(Bu,{checked:Q,onChange:te=>Z(te),"aria-label":"启用拓展功能",containerClassName:"ml-6"})]}),m.jsxs("div",{className:"flex items-center gap-2 -mt-2",children:[m.jsx("p",{className:"text-xs text-slate-400",children:"启用后可支持控制台的「文件管理」及「远程开机」功能"}),m.jsxs("a",{href:"https://www.ddnsto.com/docs/guide/advanced.html",target:"_blank",rel:"noopener noreferrer",className:"text-xs text-blue-600 hover:underline flex items-center gap-1 whitespace-nowrap",children:["查看教程",m.jsx(Ul,{className:"w-3 h-3"})]})]}),Q&&m.jsxs("div",{className:"space-y-4",children:[m.jsxs("div",{className:"flex items-center gap-2",children:[m.jsx(cf,{className:"w-4 h-4 text-slate-600"}),m.jsx("h5",{className:"text-sm text-slate-700",children:"WebDAV 服务配置"})]}),m.jsxs("div",{className:"space-y-4 pl-6",children:[m.jsxs("div",{children:[m.jsx("label",{className:"block text-sm text-slate-600 mb-2",children:"可访问的文件目录"}),m.jsx("select",{value:U,onChange:te=>b(te.target.value),className:"w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white",children:d.mounts&&d.mounts.length>0?d.mounts.map(te=>m.jsx("option",{value:te,children:te},te)):m.jsx("option",{value:"",disabled:!0,children:"未检测到挂载点"})}),m.jsx("p",{className:"text-xs text-slate-400 mt-1",children:"控制台上的文件管理将只能看到此目录及其子目录。"})]}),m.jsxs("div",{children:[m.jsx("label",{className:"block text-sm text-slate-600 mb-2",children:"服务端口"}),m.jsx("input",{type:"text",value:X,onChange:te=>K(te.target.value),placeholder:"3033",className:"w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"})]}),m.jsxs("div",{children:[m.jsx("label",{className:"block text-sm text-slate-600 mb-2",children:"授权用户名"}),m.jsx("input",{type:"text",value:V,onChange:te=>ee(te.target.value),placeholder:"ddnsto",className:"w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"})]}),m.jsxs("div",{children:[m.jsx("label",{className:"block text-sm text-slate-600 mb-2",children:"授权用户密码"}),m.jsxs("div",{className:"relative",children:[m.jsx("input",{type:Y?"text":"password",value:se,onChange:te=>B(te.target.value),placeholder:"设置访问密码",className:"w-full px-3 py-2 pr-10 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"}),m.jsx("button",{type:"button",onClick:()=>Ee(!Y),className:"absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600",children:Y?m.jsx(of,{className:"w-4 h-4"}):m.jsx(af,{className:"w-4 h-4"})})]})]})]})]})]})]})}function xf({ddnstoToken:S,ddnstoEnabled:j,advancedConfig:d,onSave:$,saveBanner:R}){const[I,Q]=C.useState("basic"),Z=C.useRef(null),U=C.useRef(),b=C.useRef(),[X,K]=C.useState(S||""),[V,ee]=C.useState(j==="1");C.useEffect(()=>{K(S||"")},[S]),C.useEffect(()=>{ee(j==="1")},[j]);const se=()=>{I==="basic"?U.current?.():b.current?.()};return m.jsxs(m.Fragment,{children:[m.jsxs("div",{className:"bg-white rounded-lg border border-slate-200 mb-4",children:[m.jsx("div",{className:"border-b border-slate-200 px-6",children:m.jsx("div",{className:"flex items-center justify-between",children:m.jsxs("div",{className:"flex items-center -mb-px",children:[m.jsx("button",{onClick:()=>Q("basic"),className:`px-4 py-4 text-sm border-b-2 transition-colors ${I==="basic"?"border-blue-600 text-blue-600":"border-transparent text-slate-600 hover:text-slate-800"}`,children:"基础配置"}),m.jsx("button",{onClick:()=>Q("advanced"),className:`px-4 py-4 text-sm border-b-2 transition-colors ${I==="advanced"?"border-blue-600 text-blue-600":"border-transparent text-slate-600 hover:text-slate-800"}`,children:"高级功能"})]})})}),m.jsxs("div",{ref:Z,className:"p-6",children:[I==="basic"&&m.jsx(yf,{token:X,enabled:V,advancedConfig:d,onSave:$,isInTab:!0,onRegisterSave:B=>{U.current=B},onTokenChange:K,onEnabledChange:ee}),I==="advanced"&&m.jsx(wf,{token:X,enabled:V,advancedConfig:d,onSave:$,isInTab:!0,onRegisterSave:B=>{b.current=B}})]})]}),m.jsx("div",{className:"flex justify-end gap-3 mt-4",children:m.jsxs("button",{onClick:se,disabled:!X.trim(),className:"px-4 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center gap-2",children:[m.jsx(hf,{className:"w-4 h-4"}),"保存配置并应用"]})}),R.state!=="idle"&&m.jsxs("div",{className:["mt-2 rounded-md border px-3 py-2 text-sm",R.state==="loading"?"bg-blue-50 border-blue-200 text-blue-800":"",R.state==="success"?"bg-emerald-50 border-emerald-200 text-emerald-800":"",R.state==="error"?"bg-red-50 border-red-200 text-red-800":""].join(" "),children:[m.jsx("div",{className:"font-medium",children:R.message}),R.description&&m.jsx("div",{className:"text-xs mt-1 opacity-80 break-words",children:R.description})]})]})}const Un={auth:0,starting:1,binding:2,domain:3,checking:4};function kf({apiBase:S,csrfToken:j,deviceId:d,onboardingBase:$,onComplete:R,onRefreshStatus:I,isInTab:Q}){const[Z,U]=C.useState(!1),[b,X]=C.useState($||"https://www.kooldns.cn/bind"),[K,V]=C.useState(`${$||"https://www.kooldns.cn/bind"}/#/auth?send=1&source=openwrt&callback=*`),[ee,se]=C.useState("auth"),[B,Y]=C.useState(d||""),[Ee,xe]=C.useState(""),[ke,pe]=C.useState(""),[te,Ce]=C.useState(!1),[Re,he]=C.useState(null),Ne=C.useRef("auth"),ue=j||(typeof window<"u"?window.ddnstoCsrfToken:""),Ke=C.useCallback(g=>{se(u=>Un[g]>Un[u]?g:u)},[]);C.useEffect(()=>{Ne.current=ee},[ee]),C.useEffect(()=>{console.log("Onboarding iframe url:",K)},[K]);const Le=C.useCallback(()=>{se("auth"),xe(""),pe(""),he(null),Y(d||""),V(`${b}/#/auth?send=1&source=openwrt&callback=*`)},[Ke,d,b]);C.useEffect(()=>{const g="/#/auth?send=1&source=openwrt&callback=*",u=$||"https://www.kooldns.cn/bind";X(u),V(`${u}${g}`)},[$]),C.useEffect(()=>{Y(d||"")},[d]);const me=C.useCallback(async()=>{for(let v=0;v<20;v+=1){const M=await fetch(`${S}/admin/services/ddnsto/api/status`,{credentials:"same-origin"});if(M.ok){const G=(await M.json())?.data||{},O=G.deviceId||G.device_id||"";if(O&&Y(O),G.running)return{running:!0,deviceId:O}}await new Promise(A=>setTimeout(A,2e3))}throw new Error("ddnsto not running")},[S]),ye=C.useCallback(async g=>{const u={url:g};ue&&(u.token=ue);const v=await fetch(`${S}/admin/services/ddnsto/api/onboarding/address`,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json",...ue?{"X-LuCI-Token":ue}:{}},body:JSON.stringify(u)});if(!v.ok)throw new Error(`HTTP ${v.status}`);const M=await v.json();if(!M?.ok)throw new Error(M?.error||"save address failed")},[S,ue]),Ue=C.useCallback(async(g,u)=>{Ce(!0),he(null);const v=B||d||"",M=`${b}/#/bind?status=starting&token=${encodeURIComponent(g)}&sign=${encodeURIComponent(u)}${v?`&routerId=${encodeURIComponent(v)}`:""}`;V(M),Ke("starting");try{const A=await fetch(`${S}/admin/services/ddnsto/api/onboarding/start`,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json",...ue?{"X-LuCI-Token":ue}:{}},body:JSON.stringify({token:g})});if(!A.ok)throw new Error(`HTTP ${A.status}`);const G=await A.json();if(!G?.ok)throw new Error(G?.error||"start failed");I&&await I();const le=(await me())?.deviceId||B||d||"";if(Un[Ne.current]>=Un.domain)return;const q=`${b}/#/bind?status=starting&routerId=${encodeURIComponent(le)}&token=${encodeURIComponent(g)}&sign=${encodeURIComponent(u)}`;V(q),Ke("binding"),Y(le)}catch{he("启动失败,请稍后重试")}finally{Ce(!1)}},[Ke,S,d,ue,b,I,me,Le,B]),ze=C.useCallback(g=>{const u=g||B;if(!Ee||!ke||!u)return;const v=`${b}/#/domain?sign=${encodeURIComponent(ke)}&token=${encodeURIComponent(Ee)}&routerId=${encodeURIComponent(u)}&netaddr=127.0.0.1&source=openwrt`;V(v),se("domain")},[ke,Ee,b,B]),W=C.useCallback(async g=>{V(`${b}/#/check?url=${encodeURIComponent(g)}`),se("checking"),R();try{await ye(g),I&&await I()}catch{he("保存域名失败,请重试")}},[b,R,I,ye]),E=C.useCallback(()=>{U(!0),Le()},[Le]);C.useEffect(()=>{const g=u=>{if(!Z)return;console.log("Onboarding message:",u.data);const v=u.data;let M=v;if(typeof v=="string")try{M=JSON.parse(v)}catch{return}if(!M||typeof M!="object")return;const A=M.data,O=A&&typeof A=="object"?A:M;if((typeof O.auth=="string"?O.auth:"")!=="ddnsto")return;const q=typeof O.sign=="string"?O.sign:"",ie=typeof O.token=="string"?O.token:"",Ae=typeof O.step=="string"?O.step:"",fn=typeof O.status=="string"?O.status:"",An=typeof O.url=="string"?O.url:"",Pt=O.success,$n=typeof Pt=="number"?Pt:Number(Pt);if(q&&ie&&ee==="auth"&&!te){xe(ie),pe(q),Ue(ie,q);return}if(Ae==="bind"&&fn==="success"){const Gt=typeof O.router_uid=="string"?O.router_uid:B;Gt&&Y(Gt),ze(Gt);return}Ae==="domain"&&An&&$n===0&&Un[Ne.current]window.removeEventListener("message",g)},[ze,W,te,Ue,Z,ee]);const x=()=>m.jsx("div",{className:"flex items-center justify-between mb-6",children:m.jsx("h3",{className:"text-slate-800",children:"快速向导"})});return Z?m.jsxs("div",{className:Q?"":"bg-white rounded-lg border border-slate-200 p-6 mb-4",children:[!Q&&x(),m.jsx("div",{className:"rounded-lg border border-slate-200 overflow-hidden",children:m.jsx("iframe",{src:K,title:"快速向导",className:"w-full",style:{width:"100%",height:"70vh",maxHeight:"400px",border:0},loading:"lazy"})})]}):m.jsxs("div",{className:Q?"":"bg-white rounded-lg border border-slate-200 p-6 mb-4",children:[!Q&&x(),m.jsxs("div",{className:"text-center py-8",children:[m.jsx("div",{className:"mb-4 flex justify-center",children:m.jsx("div",{className:"w-16 h-16 rounded-full bg-blue-50 flex items-center justify-center",children:m.jsx(Vu,{className:"w-8 h-8 text-blue-600"})})}),m.jsx("h4",{className:"text-slate-800 mb-2",children:"欢迎使用 DDNSTO!"}),m.jsx("p",{className:"text-sm text-slate-600 mb-6 max-w-md mx-auto",children:"通过微信扫码登录,我们将引导您完成插件配置"}),m.jsxs("button",{onClick:E,className:"px-6 py-2.5 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors inline-flex items-center gap-2",children:[m.jsx(Vu,{className:"w-4 h-4"}),"开始配置"]})]})]})}function Sf({config:S}){const[j,d]=C.useState(!1),[$,R]=C.useState(!1),[I,Q]=C.useState("OpenWrt"),[Z,U]=C.useState(""),[b,X]=C.useState(""),[K,V]=C.useState(""),[ee,se]=C.useState(""),[B,Y]=C.useState("1"),[Ee,xe]=C.useState({feat_enabled:"0",feat_port:"",feat_username:"",feat_password:"",feat_disk_path_selected:"",mounts:[],index:""}),[,ke]=C.useState(null),[pe,te]=C.useState(null),[Ce,Re]=C.useState({state:"idle",message:""}),he=C.useRef(),Ne=(S?.api_base||"/cgi-bin/luci").replace(/\/$/,""),ue=S?.token||"",Ke=(S?.onboarding_base||"https://www.kooldns.cn/bind").replace(/\/$/,""),Le=C.useCallback((W,E,x,g)=>{he.current&&window.clearTimeout(he.current),Re({state:W,message:E,description:x}),g&&(he.current=window.setTimeout(()=>{Re({state:"idle",message:""})},g))},[]);C.useEffect(()=>()=>{he.current&&window.clearTimeout(he.current)},[]);const me=C.useCallback(async()=>{try{const W=await fetch(`${Ne}/admin/services/ddnsto/api/config`,{credentials:"same-origin"});if(!W.ok)throw new Error(`HTTP ${W.status}`);const x=(await W.json())?.data||{};x.address&&U(x.address),x.device_id!==void 0?X(x.device_id):x.deviceId!==void 0&&X(x.deviceId),x.version!==void 0&&V(x.version),x.token&&se(x.token),x.enabled!==void 0&&Y(x.enabled),xe({feat_enabled:x.feat_enabled||"0",feat_port:x.feat_port||"",feat_username:x.feat_username||"",feat_password:x.feat_password||"",feat_disk_path_selected:x.feat_disk_path_selected||"",mounts:x.mounts||[],index:x.index||""})}catch(W){console.error("Failed to fetch ddnsto config",W)}},[Ne]),ye=C.useCallback(async()=>{try{const W=await fetch(`${Ne}/admin/services/ddnsto/api/status`,{credentials:"same-origin"});if(!W.ok)throw new Error(`HTTP ${W.status}`);const x=(await W.json())?.data||{};d(!!x.running),x.token_set&&R(!0),x.hostname&&Q(x.hostname),x.device_id!==void 0?X(x.device_id):x.deviceId!==void 0&&X(x.deviceId),x.address&&U(x.address),x.version!==void 0&&V(x.version),x.tunnel_ok!==void 0&&x.tunnel_ok!==null?te(x.tunnel_ok===!0):te(null),ke(null)}catch(W){console.error("Failed to fetch ddnsto status",W),ke("无法获取运行状态"),te(null)}},[Ne]),Ue=C.useCallback(async()=>{await me(),await ye()},[me,ye]),ze=C.useCallback(async(W,E,x)=>{Le("loading","正在保存配置...","正在将配置写入路由器,请稍候");try{const g=new URLSearchParams;ue&&g.append("token",ue),g.append("ddnsto_token",W),g.append("enabled",E),g.append("feat_enabled",x.feat_enabled),g.append("feat_port",x.feat_port),g.append("feat_username",x.feat_username),g.append("feat_password",x.feat_password),g.append("feat_disk_path_selected",x.feat_disk_path_selected),g.append("index",x.index||"");const u=await fetch(`${Ne}/admin/services/ddnsto/api/config`,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded",...ue?{"X-LuCI-Token":ue}:{}},body:g});if(!u.ok)throw new Error(`HTTP ${u.status}`);const v=await u.json();if(!v?.ok)throw new Error(v?.error||"Save failed");Le("success","配置已保存并生效",void 0,3e3),R(!0),await me(),await ye()}catch(g){console.error("Failed to save config",g);const u=g instanceof Error?g.message:String(g);Le("error","保存失败,请重试",u,4e3)}},[Ne,ue,me,ye]);return C.useEffect(()=>{let W;return me(),ye(),W=window.setInterval(ye,1e3*10),()=>{W&&window.clearInterval(W)}},[me,ye]),m.jsx("div",{className:"min-h-screen bg-slate-50",children:m.jsx("main",{children:m.jsxs("div",{className:"max-w-5xl mx-auto px-6 py-8",children:[m.jsx(vf,{}),m.jsx(gf,{isRunning:j,isConfigured:$,hostname:I,address:Z,deviceId:b,version:K,featEnabled:Ee.feat_enabled,tunnelOk:pe,onRefreshStatus:Ue}),m.jsx(kf,{apiBase:Ne,csrfToken:ue,deviceId:b,onboardingBase:Ke,onRefreshStatus:Ue,onComplete:()=>R(!0)}),m.jsx(xf,{ddnstoToken:ee,ddnstoEnabled:B,advancedConfig:Ee,onSave:ze,saveBanner:Ce})]})})})}const _f='/*! tailwindcss v4.1.3 | MIT License | https://tailwindcss.com */*,:before,:after,::backdrop{--tw-border-style: solid}@layer properties{@supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x: 0;--tw-translate-y: 0;--tw-translate-z: 0;--tw-rotate-x: rotateX(0);--tw-rotate-y: rotateY(0);--tw-rotate-z: rotateZ(0);--tw-skew-x: skewX(0);--tw-skew-y: skewY(0);--tw-space-y-reverse: 0;--tw-border-style: solid;--tw-shadow: 0 0 #0000;--tw-shadow-color: initial;--tw-shadow-alpha: 100%;--tw-inset-shadow: 0 0 #0000;--tw-inset-shadow-color: initial;--tw-inset-shadow-alpha: 100%;--tw-ring-color: initial;--tw-ring-shadow: 0 0 #0000;--tw-inset-ring-color: initial;--tw-inset-ring-shadow: 0 0 #0000;--tw-ring-inset: initial;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-offset-shadow: 0 0 #0000}}}@layer theme{:root,:host{--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-500: oklch(.637 .237 25.331);--color-amber-50: oklch(.987 .022 95.277);--color-amber-200: oklch(.924 .12 95.746);--color-amber-600: oklch(.666 .179 58.318);--color-green-50: oklch(.982 .018 155.826);--color-green-100: oklch(.962 .044 156.743);--color-green-200: oklch(.925 .084 155.995);--color-green-600: oklch(.627 .194 149.214);--color-green-700: oklch(.527 .154 150.069);--color-green-800: oklch(.448 .119 151.328);--color-blue-50: oklch(.97 .014 254.604);--color-blue-200: oklch(.882 .059 254.128);--color-blue-500: oklch(.623 .214 259.815);--color-blue-600: oklch(.546 .245 262.881);--color-blue-700: oklch(.488 .243 264.376);--color-slate-50: oklch(.984 .003 247.858);--color-slate-100: oklch(.968 .007 247.896);--color-slate-200: oklch(.929 .013 255.508);--color-slate-300: oklch(.869 .022 252.894);--color-slate-400: oklch(.704 .04 256.788);--color-slate-500: oklch(.554 .046 257.417);--color-slate-600: oklch(.446 .043 257.281);--color-slate-700: oklch(.372 .044 257.287);--color-slate-800: oklch(.279 .041 260.031);--color-white: #fff;--spacing: .25rem;--container-md: 28rem;--container-5xl: 64rem;--text-xs: .75rem;--text-xs--line-height: calc(1 / .75);--text-sm: .875rem;--text-sm--line-height: calc(1.25 / .875);--text-base: 1rem;--text-lg: 1.125rem;--text-xl: 1.25rem;--text-2xl: 1.5rem;--font-weight-normal: 400;--font-weight-medium: 500;--default-transition-duration: .15s;--default-transition-timing-function: cubic-bezier(.4, 0, .2, 1);--default-font-family: var(--font-sans);--default-font-feature-settings: var(--font-sans--font-feature-settings);--default-font-variation-settings: var(--font-sans--font-variation-settings);--default-mono-font-family: var(--font-mono);--default-mono-font-feature-settings: var(--font-mono--font-feature-settings);--default-mono-font-variation-settings: var(--font-mono--font-variation-settings)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings, normal);font-variation-settings:var(--default-font-variation-settings, normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings, normal);font-variation-settings:var(--default-mono-font-variation-settings, normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:currentColor}@supports (color: color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentColor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}body{background-color:var(--background);color:var(--foreground)}*{border-color:var(--border);outline-color:var(--ring)}@supports (color: color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring) 50%,transparent)}}body{background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h1{font-size:var(--text-2xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h2{font-size:var(--text-xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h3{font-size:var(--text-lg);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h4,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) label,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) button{font-size:var(--text-base);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) input{font-size:var(--text-base);font-weight:var(--font-weight-normal);line-height:1.5}}@layer utilities{.absolute{position:absolute}.relative{position:relative}.inset-0{inset:calc(var(--spacing) * 0)}.top-1\\/2{top:50%}.right-2{right:calc(var(--spacing) * 2)}.z-10{z-index:10}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-auto{margin-inline:auto}.mt-0\\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.h-0\\.5{height:calc(var(--spacing) * .5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.h-40{height:calc(var(--spacing) * 40)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-40{width:calc(var(--spacing) * 40)}.w-full{width:100%}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.flex-shrink-0{flex-shrink:0}.translate-x-1{--tw-translate-x: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-6{--tw-translate-x: calc(var(--spacing) * 6);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y)}.cursor-pointer{cursor:pointer}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.grid-rows-8{grid-template-rows:repeat(8,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-600{border-color:var(--color-blue-600)}.border-green-200{border-color:var(--color-green-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-transparent{border-color:#0000}.bg-amber-50{background-color:var(--color-amber-50)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-600{background-color:var(--color-green-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-300{background-color:var(--color-slate-300)}.bg-slate-800{background-color:var(--color-slate-800)}.bg-white{background-color:var(--color-white)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\\.5{padding-block:calc(var(--spacing) * 2.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-8{padding-block:calc(var(--spacing) * 8)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pl-7{padding-left:calc(var(--spacing) * 7)}.text-center{text-align:center}.text-right{text-align:right}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading, var(--text-xs--line-height))}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-amber-600{color:var(--color-amber-600)}.text-blue-600{color:var(--color-blue-600)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-red-500{color:var(--color-red-500)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-white{color:var(--color-white)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}@media(hover:hover){.hover\\:bg-blue-50:hover{background-color:var(--color-blue-50)}}@media(hover:hover){.hover\\:bg-blue-700:hover{background-color:var(--color-blue-700)}}@media(hover:hover){.hover\\:bg-green-700:hover{background-color:var(--color-green-700)}}@media(hover:hover){.hover\\:bg-slate-50:hover{background-color:var(--color-slate-50)}}@media(hover:hover){.hover\\:text-slate-600:hover{color:var(--color-slate-600)}}@media(hover:hover){.hover\\:text-slate-800:hover{color:var(--color-slate-800)}}@media(hover:hover){.hover\\:underline:hover{text-decoration-line:underline}}.focus\\:ring-2:focus{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\\:ring-blue-500:focus{--tw-ring-color: var(--color-blue-500)}.focus\\:outline-none:focus{--tw-outline-style: none;outline-style:none}.disabled\\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\\:bg-slate-300:disabled{background-color:var(--color-slate-300)}}:root,:host{--font-size: 16px;--background: #fff;--foreground: oklch(.145 0 0);--card: #fff;--card-foreground: oklch(.145 0 0);--popover: oklch(1 0 0);--popover-foreground: oklch(.145 0 0);--primary: #030213;--primary-foreground: oklch(1 0 0);--secondary: oklch(.95 .0058 264.53);--secondary-foreground: #030213;--muted: #ececf0;--muted-foreground: #717182;--accent: #e9ebef;--accent-foreground: #030213;--destructive: #d4183d;--destructive-foreground: #fff;--border: #0000001a;--input: transparent;--input-background: #f3f3f5;--switch-background: #cbced4;--font-weight-medium: 500;--font-weight-normal: 400;--ring: oklch(.708 0 0);--chart-1: oklch(.646 .222 41.116);--chart-2: oklch(.6 .118 184.704);--chart-3: oklch(.398 .07 227.392);--chart-4: oklch(.828 .189 84.429);--chart-5: oklch(.769 .188 70.08);--radius: .625rem;--sidebar: oklch(.985 0 0);--sidebar-foreground: oklch(.145 0 0);--sidebar-primary: #030213;--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.97 0 0);--sidebar-accent-foreground: oklch(.205 0 0);--sidebar-border: oklch(.922 0 0);--sidebar-ring: oklch(.708 0 0)}.dark{--background: oklch(.145 0 0);--foreground: oklch(.985 0 0);--card: oklch(.145 0 0);--card-foreground: oklch(.985 0 0);--popover: oklch(.145 0 0);--popover-foreground: oklch(.985 0 0);--primary: oklch(.985 0 0);--primary-foreground: oklch(.205 0 0);--secondary: oklch(.269 0 0);--secondary-foreground: oklch(.985 0 0);--muted: oklch(.269 0 0);--muted-foreground: oklch(.708 0 0);--accent: oklch(.269 0 0);--accent-foreground: oklch(.985 0 0);--destructive: oklch(.396 .141 25.723);--destructive-foreground: oklch(.637 .237 25.331);--border: oklch(.269 0 0);--input: oklch(.269 0 0);--ring: oklch(.439 0 0);--font-weight-medium: 500;--font-weight-normal: 400;--chart-1: oklch(.488 .243 264.376);--chart-2: oklch(.696 .17 162.48);--chart-3: oklch(.769 .188 70.08);--chart-4: oklch(.627 .265 303.9);--chart-5: oklch(.645 .246 16.439);--sidebar: oklch(.205 0 0);--sidebar-foreground: oklch(.985 0 0);--sidebar-primary: oklch(.488 .243 264.376);--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.269 0 0);--sidebar-accent-foreground: oklch(.985 0 0);--sidebar-border: oklch(.269 0 0);--sidebar-ring: oklch(.439 0 0)}html{font-size:var(--font-size);font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}@property --tw-translate-x{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-y{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-z{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-rotate-x{syntax: "*"; inherits: false; initial-value: rotateX(0);}@property --tw-rotate-y{syntax: "*"; inherits: false; initial-value: rotateY(0);}@property --tw-rotate-z{syntax: "*"; inherits: false; initial-value: rotateZ(0);}@property --tw-skew-x{syntax: "*"; inherits: false; initial-value: skewX(0);}@property --tw-skew-y{syntax: "*"; inherits: false; initial-value: skewY(0);}@property --tw-space-y-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-border-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-shadow-color{syntax: "*"; inherits: false}@property --tw-shadow-alpha{syntax: ""; inherits: false; initial-value: 100%;}@property --tw-inset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-shadow-color{syntax: "*"; inherits: false}@property --tw-inset-shadow-alpha{syntax: ""; inherits: false; initial-value: 100%;}@property --tw-ring-color{syntax: "*"; inherits: false}@property --tw-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-ring-color{syntax: "*"; inherits: false}@property --tw-inset-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-ring-inset{syntax: "*"; inherits: false}@property --tw-ring-offset-width{syntax: ""; inherits: false; initial-value: 0;}@property --tw-ring-offset-color{syntax: "*"; inherits: false; initial-value: #fff;}@property --tw-ring-offset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}',Ef=".ddnsto-host{padding:16px 20px;background-color:#f8fafc;border-radius:16px;overflow:hidden}.ddnsto-host #app{display:block}",Cf='.ddnsto-toggle-container{display:inline-flex;align-items:center}.ddnsto-toggle-switch{position:relative;display:inline-block;width:44px;height:22px;cursor:pointer}.ddnsto-toggle-switch input{opacity:0;width:0;height:0;position:absolute;inset:0;margin:0}.ddnsto-toggle-slider{position:absolute;cursor:pointer;inset:0;background-color:#e5e7eb;transition:.2s ease;border-radius:22px;border:1px solid #cbd5e1;box-shadow:0 1px 2px #0000000a inset}.ddnsto-toggle-slider:before{position:absolute;content:"";height:16px;width:16px;left:2px;top:2px;background-color:#fff;transition:.2s ease;border-radius:50%;box-shadow:0 1px 2px #0003}.ddnsto-toggle-switch input:checked+.ddnsto-toggle-slider{background-color:#3b82f6;border-color:#3b82f6}.ddnsto-toggle-switch input:checked+.ddnsto-toggle-slider:before{transform:translate(22px)}.ddnsto-toggle-switch input:focus-visible+.ddnsto-toggle-slider{box-shadow:0 0 0 3px #3b82f640}.ddnsto-toggle-switch input:disabled+.ddnsto-toggle-slider{opacity:.6;cursor:not-allowed}',jr={token:"",prefix:"",api_base:"/cgi-bin/luci",lang:"zh-cn",onboarding_base:"https://web.ddnsto.com/openwrt-bind"},zr=typeof window<"u"&&window.ddnstoConfig||{},Nf={token:zr.token??jr.token,prefix:zr.prefix??jr.prefix,api_base:zr.api_base??jr.api_base,lang:zr.lang??jr.lang,onboarding_base:zr.onboarding_base??jr.onboarding_base},dn=document.getElementById("root")||document.getElementById("app"),jf=()=>{if(!document.head.querySelector("style[data-ddnsto-host]")){const S=document.createElement("style");S.setAttribute("data-ddnsto-host","true"),S.textContent=Ef,document.head.appendChild(S)}},zf=dn?.hasAttribute("data-ddnsto-shadow")||dn?.dataset.shadow==="true";let Fi=dn;if(dn){jf();const S=`${_f} +${Cf}`;if(zf&&dn.attachShadow){const j=dn.shadowRoot||dn.attachShadow({mode:"open"});if(!j.querySelector("style[data-ddnsto-style]")){const d=document.createElement("style");d.setAttribute("data-ddnsto-style","true"),d.textContent=S,j.appendChild(d)}Fi=j}else if(!document.head.querySelector("style[data-ddnsto-style]")){const j=document.createElement("style");j.setAttribute("data-ddnsto-style","true"),j.textContent=S,document.head.appendChild(j)}Fi&&Zd.createRoot(Fi).render(m.jsx(Qd.StrictMode,{children:m.jsx(Sf,{config:Nf})}))} diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua index 532783701d..adccab6d46 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua @@ -730,6 +730,9 @@ o = s:option(Value, _n("xudp_concurrency"), translate("XUDP Mux concurrency")) o.default = 8 o:depends({ [_n("mux")] = true }) +o = s:option(Flag, _n("tcp_fast_open"), "TCP " .. translate("Fast Open")) +o.default = 0 + --[[tcpMptcp]] o = s:option(Flag, _n("tcpMptcp"), "tcpMptcp", translate("Enable Multipath TCP, need to be enabled in both server and client configuration.")) o.default = 0 diff --git a/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_sing-box.lua b/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_sing-box.lua index 22d8dcda05..15c460d5d1 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_sing-box.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_sing-box.lua @@ -95,7 +95,7 @@ function gen_outbound(flag, node, tag, proxy_table) local relay_port = node.port new_port = get_new_port() local config_file = string.format("%s_%s_%s.json", flag, tag, new_port) - if tag and node_id and tag ~= node_id then + if tag and node_id and not tag:find(node_id) then config_file = string.format("%s_%s_%s_%s.json", flag, tag, node_id, new_port) end if run_socks_instance then diff --git a/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_xray.lua b/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_xray.lua index d18a0dd996..7017ba6dca 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_xray.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_xray.lua @@ -74,7 +74,7 @@ function gen_outbound(flag, node, tag, proxy_table) local relay_port = node.port new_port = get_new_port() local config_file = string.format("%s_%s_%s.json", flag, tag, new_port) - if tag and node_id and tag ~= node_id then + if tag and node_id and not tag:find(node_id) then config_file = string.format("%s_%s_%s_%s.json", flag, tag, node_id, new_port) end if run_socks_instance then @@ -146,6 +146,7 @@ function gen_outbound(flag, node, tag, proxy_table) streamSettings = (node.streamSettings or node.protocol == "vmess" or node.protocol == "vless" or node.protocol == "socks" or node.protocol == "shadowsocks" or node.protocol == "trojan") and { sockopt = { mark = 255, + tcpFastOpen = (node.tcp_fast_open == "1") and true or nil, tcpMptcp = (node.tcpMptcp == "1") and true or nil, dialerProxy = (fragment or noise) and "dialerproxy" or nil }, diff --git a/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/global/footer.htm b/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/global/footer.htm index 9798a1bf72..8ba0c93cca 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/global/footer.htm +++ b/openwrt-passwall/luci-app-passwall/luasrc/view/passwall/global/footer.htm @@ -35,59 +35,46 @@ local api = require "luci.passwall.api" } var global_id = null; - var global = document.getElementById("cbi-passwall-global"); + var global = document.getElementById("cbi-<%=api.appname%>-global"); if (global) { var node = global.getElementsByClassName("cbi-section-node")[0]; var node_id = node.getAttribute("id"); global_id = node_id; - var reg1 = new RegExp("(?<=" + node_id + "-).*?(?=(_node))"); + var all_node = node.querySelectorAll("[id]"); + var reg1 = /^cbid\..*node\.main$/; - for (var i = 0; i < node.childNodes.length; i++) { - var row = node.childNodes[i]; - if (!row || !row.childNodes) continue; + for (var i = 0; i < all_node.length; i++) { + var el = all_node[i]; + if (!reg1.test(el.id)) continue; - for (var k = 0; k < row.childNodes.length; k++) { - try { - var dom = row.childNodes[k]; - if (!dom || !dom.id) continue; - var s = dom.id.match(reg1); - if (!s) continue; - var cbi_id = global_id + "-"; - var cbid = dom.id.split(cbi_id).join(cbi_id.split("-").join(".")).split("cbi.").join("cbid."); - var dom_id = cbid + ".main"; - if (!/_node\.main$/.test(dom_id)) continue; - - var node_select = document.getElementById(dom_id); - if (!node_select) continue; - - var hidden_select = document.getElementById(cbid); - var node_select_value = hidden_select ? hidden_select.options[0].value : ""; - if (!node_select_value || node_select_value.indexOf("_default") === 0 || node_select_value.indexOf("_direct") === 0 || node_select_value.indexOf("_blackhole") === 0) { - continue; - } - - var to_url = '<%=api.url("node_config")%>/' + node_select_value; - if (node_select_value.indexOf("Socks_") === 0) { - to_url = '<%=api.url("socks_config")%>/' + - node_select_value.substring("Socks_".length); - } - var html = '<%:Edit%>'; - - if (s[0] === "tcp" || s[0] === "udp") { - html += '?name=default&proto=' + s[0] + '\', \'_blank\')"><%:Log%>'; - } - - node_select.insertAdjacentHTML("beforeend", - '
' - + html + '
' - ); - } catch (e) { - } + var node_select = el; + if (!node_select) continue; + var cbid = el.id.replace(/\.main$/, ""); + var hidden_select = document.getElementById(cbid); + var node_select_value = hidden_select ? hidden_select.options[0].value : ""; + if (!node_select_value || node_select_value.indexOf("_default") === 0 || node_select_value.indexOf("_direct") === 0 || node_select_value.indexOf("_blackhole") === 0) { + continue; } + + var to_url = '<%=api.url("node_config")%>/' + node_select_value; + if (node_select_value.indexOf("Socks_") === 0) { + to_url = '<%=api.url("socks_config")%>/' + node_select_value.substring("Socks_".length); + } + var html = '<%:Edit%>'; + + var m = cbid.match(/\.(tcp|udp)_node$/); + if (m && (m[1] === "tcp" || m[1] === "udp")) { + html += '?name=default&proto=' + m[1] + '\', \'_blank\')"><%:Log%>'; + } + + node_select.insertAdjacentHTML("beforeend", + '
' + + html + '
' + ); } } - var socks = document.getElementById("cbi-passwall-socks"); + var socks = document.getElementById("cbi-<%=api.appname%>-socks"); if (socks) { var socks_enabled_dom = document.getElementById(global_id + "-socks_enabled"); socks_enabled_dom.parentNode.removeChild(socks_enabled_dom); diff --git a/openwrt-passwall/luci-app-passwall/root/usr/share/passwall/app.sh b/openwrt-passwall/luci-app-passwall/root/usr/share/passwall/app.sh index 9701240f91..1375aa1a88 100755 --- a/openwrt-passwall/luci-app-passwall/root/usr/share/passwall/app.sh +++ b/openwrt-passwall/luci-app-passwall/root/usr/share/passwall/app.sh @@ -254,7 +254,8 @@ check_ver() { } first_type() { - for p in "/bin/$1" "${TMP_BIN_PATH:-/tmp}/$1" "$1"; do + [ "${1#/}" != "$1" ] && [ -x "$1" ] && echo "$1" && return + for p in "/bin/$1" "/usr/bin/$1" "${TMP_BIN_PATH:-/tmp}/$1"; do [ -x "$p" ] && echo "$p" && return done command -v "$1" 2>/dev/null || command -v "$2" 2>/dev/null @@ -690,7 +691,7 @@ run_socks() { case "$type" in socks) - local _socks_address _socks_port _socks_username _socks_password + local _socks_address _socks_port _socks_username _socks_password _extra_param microsocks_fwd if [ "$node2socks_port" = "0" ]; then _socks_address=$(config_n_get $node address) _socks_port=$(config_n_get $node port) @@ -700,13 +701,24 @@ run_socks() { _socks_address="127.0.0.1" _socks_port=$node2socks_port fi - [ "$http_port" != "0" ] && { + if [ "$http_port" != "0" ]; then http_flag=1 config_file="${config_file//SOCKS/HTTP_SOCKS}" - local _extra_param="-local_http_address $bind -local_http_port $http_port" - } + _extra_param="-local_http_address $bind -local_http_port $http_port" + else + # 仅 passwall-packages 专用的 microsocks 才支持 socks 转发规则! + microsocks_fwd="$($(first_type microsocks) -V 2>/dev/null | grep -i forward)" + fi local bin=$(first_type $(config_t_get global_app sing_box_file) sing-box) - if [ -n "$bin" ]; then + if [ -n "$microsocks_fwd" ]; then + local ext_name=$(echo "$config_file" | sed "s|^${TMP_PATH}/||; s|\.json\$||; s|/|_|g") + if [ -n "$_socks_username" ] && [ -n "$_socks_password" ]; then + _extra_param="-f \"0.0.0.0:0,${_socks_username}:${_socks_password}@${_socks_address}:${_socks_port},0.0.0.0:0\"" + else + _extra_param="-f \"0.0.0.0:0,${_socks_address}:${_socks_port},0.0.0.0:0\"" + fi + ln_run "$(first_type microsocks)" "microsocks_${ext_name}" $log_file -i $bind -p $socks_port ${_extra_param} + elif [ -n "$bin" ]; then type="sing-box" lua $UTIL_SINGBOX gen_proto_config -local_socks_address $bind -local_socks_port $socks_port ${_extra_param} -server_proto socks -server_address ${_socks_address} -server_port ${_socks_port} -server_username ${_socks_username} -server_password ${_socks_password} > $config_file ln_run "$bin" ${type} $log_file run -c "$config_file" diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/ray.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/ray.lua index af4ba33349..3e3ade419c 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/ray.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/ray.lua @@ -111,6 +111,10 @@ o:depends({ [_n("protocol")] = "_balancing" }) o.widget = "checkbox" o.template = appname .. "/cbi/nodes_multivalue" o.group = {} +for k, v in pairs(socks_list) do + o:value(v.id, v.remark) + o.group[#o.group+1] = v.group or "" +end for i, v in pairs(nodes_table) do o:value(v.id, v.remark) o.group[#o.group+1] = v.group or "" @@ -166,6 +170,10 @@ end if is_balancer then check_fallback_chain(arg[1]) end +for k, v in pairs(socks_list) do + o:value(v.id, v.remark) + o.group[#o.group+1] = (v.group and v.group ~= "") and v.group or translate("default") +end for k, v in pairs(fallback_table) do o:value(v.id, v.remark) o.group[#o.group+1] = (v.group and v.group ~= "") and v.group or translate("default") @@ -723,6 +731,9 @@ o = s:option(Value, _n("xudp_concurrency"), translate("XUDP Mux concurrency")) o.default = 8 o:depends({ [_n("mux")] = true }) +o = s:option(Flag, _n("tcp_fast_open"), "TCP " .. translate("Fast Open")) +o.default = 0 + --[[tcpMptcp]] o = s:option(Flag, _n("tcpMptcp"), "tcpMptcp", translate("Enable Multipath TCP, need to be enabled in both server and client configuration.")) o.default = 0 diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/sing-box.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/sing-box.lua index d70bdfa9c6..dd791aaab7 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/sing-box.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/sing-box.lua @@ -120,6 +120,10 @@ o:depends({ [_n("protocol")] = "_urltest" }) o.widget = "checkbox" o.template = appname .. "/cbi/nodes_multivalue" o.group = {} +for k, v in pairs(socks_list) do + o:value(v.id, v.remark) + o.group[#o.group+1] = v.group or "" +end for i, v in pairs(nodes_table) do o:value(v.id, v.remark) o.group[#o.group+1] = v.group or "" diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_sing-box.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_sing-box.lua index 38ac042d64..40e9ae6fb7 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_sing-box.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_sing-box.lua @@ -46,7 +46,7 @@ function gen_outbound(flag, node, tag, proxy_table) local relay_port = node.port new_port = get_new_port() local config_file = string.format("%s_%s_%s.json", flag, tag, new_port) - if tag and node_id and tag ~= node_id then + if tag and node_id and not tag:find(node_id) then config_file = string.format("%s_%s_%s_%s.json", flag, tag, node_id, new_port) end if run_socks_instance then @@ -1064,12 +1064,30 @@ function gen_config(var) end end if is_new_ut_node then - local ut_node = uci:get_all(appname, ut_node_id) - local outbound = gen_outbound(flag, ut_node, ut_node_tag, { fragment = singbox_settings.fragment == "1" or nil, record_fragment = singbox_settings.record_fragment == "1" or nil, run_socks_instance = not no_run }) - if outbound then - outbound.tag = outbound.tag .. ":" .. ut_node.remarks - table.insert(outbounds, outbound) - valid_nodes[#valid_nodes + 1] = outbound.tag + local ut_node + if ut_node_id:find("Socks_") then + local socks_id = ut_node_id:sub(1 + #"Socks_") + local socks_node = uci:get_all(appname, socks_id) or nil + if socks_node then + ut_node = { + type = "sing-box", + protocol = "socks", + address = "127.0.0.1", + port = socks_node.port, + uot = "1", + remarks = "Socks_" .. socks_node.port + } + end + else + ut_node = uci:get_all(appname, ut_node_id) + end + if ut_node then + local outbound = gen_outbound(flag, ut_node, ut_node_tag, { fragment = singbox_settings.fragment == "1" or nil, record_fragment = singbox_settings.record_fragment == "1" or nil, run_socks_instance = not no_run }) + if outbound then + outbound.tag = outbound.tag .. ":" .. ut_node.remarks + table.insert(outbounds, outbound) + valid_nodes[#valid_nodes + 1] = outbound.tag + end end end end diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_xray.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_xray.lua index 0eed865848..4830487667 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_xray.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_xray.lua @@ -71,7 +71,7 @@ function gen_outbound(flag, node, tag, proxy_table) local relay_port = node.port new_port = get_new_port() local config_file = string.format("%s_%s_%s.json", flag, tag, new_port) - if tag and node_id and tag ~= node_id then + if tag and node_id and not tag:find(node_id) then config_file = string.format("%s_%s_%s_%s.json", flag, tag, node_id, new_port) end if run_socks_instance then @@ -142,6 +142,7 @@ function gen_outbound(flag, node, tag, proxy_table) streamSettings = (node.streamSettings or node.protocol == "vmess" or node.protocol == "vless" or node.protocol == "socks" or node.protocol == "shadowsocks" or node.protocol == "trojan") and { sockopt = { mark = 255, + tcpFastOpen = (node.tcp_fast_open == "1") and true or nil, tcpMptcp = (node.tcpMptcp == "1") and true or nil, dialerProxy = (fragment or noise) and "dialerproxy" or nil }, @@ -741,12 +742,31 @@ function gen_config(var) end end if is_new_blc_node then - local blc_node = uci:get_all(appname, blc_node_id) - local outbound = gen_outbound(flag, blc_node, blc_node_tag, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.noise == "1" or nil, run_socks_instance = not no_run }) - if outbound then - outbound.tag = outbound.tag .. ":" .. blc_node.remarks - table.insert(outbounds, outbound) - valid_nodes[#valid_nodes + 1] = outbound.tag + local blc_node + if blc_node_id:find("Socks_") then + local socks_id = blc_node_id:sub(1 + #"Socks_") + local socks_node = uci:get_all(appname, socks_id) or nil + if socks_node then + blc_node = { + type = "Xray", + protocol = "socks", + address = "127.0.0.1", + port = socks_node.port, + transport = "tcp", + stream_security = "none", + remarks = "Socks_" .. socks_node.port + } + end + else + blc_node = uci:get_all(appname, blc_node_id) + end + if blc_node then + local outbound = gen_outbound(flag, blc_node, blc_node_tag, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.noise == "1" or nil, run_socks_instance = not no_run }) + if outbound then + outbound.tag = outbound.tag .. ":" .. blc_node.remarks + table.insert(outbounds, outbound) + valid_nodes[#valid_nodes + 1] = outbound.tag + end end end end @@ -766,17 +786,36 @@ function gen_config(var) end end if is_new_node then - local fallback_node = uci:get_all(appname, fallback_node_id) - if fallback_node.protocol ~= "_balancing" then - local outbound = gen_outbound(flag, fallback_node, fallback_node_id, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.noise == "1" or nil, run_socks_instance = not no_run }) - if outbound then - outbound.tag = outbound.tag .. ":" .. fallback_node.remarks - table.insert(outbounds, outbound) - fallback_node_tag = outbound.tag + local fallback_node + if fallback_node_id:find("Socks_") then + local socks_id = fallback_node_id:sub(1 + #"Socks_") + local socks_node = uci:get_all(appname, socks_id) or nil + if socks_node then + fallback_node = { + type = "Xray", + protocol = "socks", + address = "127.0.0.1", + port = socks_node.port, + transport = "tcp", + stream_security = "none", + remarks = "Socks_" .. socks_node.port + } end else - if gen_balancer(fallback_node) then - fallback_node_tag = fallback_node_id + fallback_node = uci:get_all(appname, fallback_node_id) + end + if fallback_node then + if fallback_node.protocol ~= "_balancing" then + local outbound = gen_outbound(flag, fallback_node, fallback_node_id, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.noise == "1" or nil, run_socks_instance = not no_run }) + if outbound then + outbound.tag = outbound.tag .. ":" .. fallback_node.remarks + table.insert(outbounds, outbound) + fallback_node_tag = outbound.tag + end + else + if gen_balancer(fallback_node) then + fallback_node_tag = fallback_node_id + end end end end @@ -1604,7 +1643,11 @@ function gen_config(var) end for index, value in ipairs(config.outbounds) do - if not value["_flag_proxy_tag"] and value["_id"] and value.server and value.server_port and not no_run then + local s = value.settings + if not value["_flag_proxy_tag"] and value["_id"] and s and not no_run and + ((s.vnext and s.vnext[1] and s.vnext[1].address and s.vnext[1].port) or + (s.servers and s.servers[1] and s.servers[1].address and s.servers[1].port) or + (s.peers and s.peers[1] and s.peers[1].endpoint)) then sys.call(string.format("echo '%s' >> %s", value["_id"], api.TMP_PATH .. "/direct_node_list")) end for k, v in pairs(config.outbounds[index]) do diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/view/passwall2/global/footer.htm b/openwrt-passwall2/luci-app-passwall2/luasrc/view/passwall2/global/footer.htm index 2eb7e1ee32..aa61b49c7c 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/view/passwall2/global/footer.htm +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/view/passwall2/global/footer.htm @@ -36,59 +36,46 @@ local api = require "luci.passwall2.api" var global_id = null; - var global = document.getElementById("cbi-passwall2-global"); + var global = document.getElementById("cbi-<%=api.appname%>-global"); if (global) { var node = global.getElementsByClassName("cbi-section-node")[0]; var node_id = node.getAttribute("id"); global_id = node_id; - var reg1 = new RegExp("(?<=" + node_id + "-).*?(?=(_node))"); + var all_node = node.querySelectorAll("[id]"); + var reg1 = /^cbid\..*node\.main$/; - for (var i = 0; i < node.childNodes.length; i++) { - var row = node.childNodes[i]; - if (!row || !row.childNodes) continue; + for (var i = 0; i < all_node.length; i++) { + var el = all_node[i]; + if (!reg1.test(el.id)) continue; - for (var k = 0; k < row.childNodes.length; k++) { - try { - var dom = row.childNodes[k]; - if (!dom || !dom.id) continue; - var s = dom.id.match(reg1); - if (!s) continue; - var cbi_id = global_id + "-"; - var cbid = dom.id.split(cbi_id).join(cbi_id.split("-").join(".")).split("cbi.").join("cbid."); - var dom_id = cbid + ".main"; - if (!/_node\.main$/.test(dom_id)) continue; - - var node_select = document.getElementById(dom_id); - if (!node_select) continue; - - var hidden_select = document.getElementById(cbid); - var node_select_value = hidden_select ? hidden_select.options[0].value : ""; - if (!node_select_value || node_select_value.indexOf("_default") === 0 || node_select_value.indexOf("_direct") === 0 || node_select_value.indexOf("_blackhole") === 0) { - continue; - } - - var to_url = '<%=api.url("node_config")%>/' + node_select_value; - if (node_select_value.indexOf("Socks_") === 0) { - to_url = '<%=api.url("socks_config")%>/' + - node_select_value.substring("Socks_".length); - } - var html = '<%:Edit%>'; - - if (s[0] === "tcp" || s[0] === "udp") { - html += '?name=default&proto=' + s[0] + '\', \'_blank\')"><%:Log%>'; - } - - node_select.insertAdjacentHTML("beforeend", - '
' - + html + '
' - ); - } catch (e) { - } + var node_select = el; + if (!node_select) continue; + var cbid = el.id.replace(/\.main$/, ""); + var hidden_select = document.getElementById(cbid); + var node_select_value = hidden_select ? hidden_select.options[0].value : ""; + if (!node_select_value || node_select_value.indexOf("_default") === 0 || node_select_value.indexOf("_direct") === 0 || node_select_value.indexOf("_blackhole") === 0) { + continue; } + + var to_url = '<%=api.url("node_config")%>/' + node_select_value; + if (node_select_value.indexOf("Socks_") === 0) { + to_url = '<%=api.url("socks_config")%>/' + node_select_value.substring("Socks_".length); + } + var html = '<%:Edit%>'; + + var m = cbid.match(/\.node$/); + if (m) { + html += '?id=default&name=' + 'global' + '\', \'_blank\')"><%:Log%>'; + } + + node_select.insertAdjacentHTML("beforeend", + '
' + + html + '
' + ); } } - var socks = document.getElementById("cbi-passwall2-socks"); + var socks = document.getElementById("cbi-<%=api.appname%>-socks"); if (socks) { var socks_enabled_dom = document.getElementById(global_id + "-socks_enabled"); socks_enabled_dom.parentNode.removeChild(socks_enabled_dom); diff --git a/openwrt-passwall2/luci-app-passwall2/root/usr/share/passwall2/app.sh b/openwrt-passwall2/luci-app-passwall2/root/usr/share/passwall2/app.sh index 337c32fbba..a875dff3dd 100755 --- a/openwrt-passwall2/luci-app-passwall2/root/usr/share/passwall2/app.sh +++ b/openwrt-passwall2/luci-app-passwall2/root/usr/share/passwall2/app.sh @@ -255,8 +255,11 @@ check_depends() { } first_type() { - local path_name=${1} - type -t -p "/bin/${path_name}" -p "${TMP_BIN_PATH}/${path_name}" -p "${path_name}" "$@" | head -n1 + [ "${1#/}" != "$1" ] && [ -x "$1" ] && echo "$1" && return + for p in "/bin/$1" "/usr/bin/$1" "${TMP_BIN_PATH:-/tmp}/$1"; do + [ -x "$p" ] && echo "$p" && return + done + command -v "$1" 2>/dev/null || command -v "$2" 2>/dev/null } eval_set_val() { diff --git a/shadowsocks-rust/Cargo.lock b/shadowsocks-rust/Cargo.lock index 8a0b541f8e..86873c550a 100644 --- a/shadowsocks-rust/Cargo.lock +++ b/shadowsocks-rust/Cargo.lock @@ -162,9 +162,12 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] [[package]] name = "arrayref" @@ -821,7 +824,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -928,7 +931,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2127,7 +2130,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2491,7 +2494,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2604,9 +2607,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.26" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -2758,7 +2761,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3433,7 +3436,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3760,9 +3763,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tun" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1527bfc1f78acb1287bbd132ee44bdbf9d064c9f3ca176cb2635253f891f76" +checksum = "b35f176015650e3bd849e85808d809e5b54da2ba7df983c5c3b601a2a8f1095e" dependencies = [ "bytes", "cfg-if", @@ -3775,7 +3778,7 @@ dependencies = [ "thiserror", "tokio", "tokio-util", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "wintun-bindings", ] diff --git a/small/luci-app-fchomo/htdocs/luci-static/resources/fchomo.js b/small/luci-app-fchomo/htdocs/luci-static/resources/fchomo.js index 686fecda58..dda40d2829 100644 --- a/small/luci-app-fchomo/htdocs/luci-static/resources/fchomo.js +++ b/small/luci-app-fchomo/htdocs/luci-static/resources/fchomo.js @@ -647,7 +647,7 @@ const CBIHandleImport = baseclass.extend(/** @lends hm.HandleImport.prototype */ E('br'), //type_file_count ? _("%s rule-set of type '%s' need to be filled in manually.") // .format(type_file_count, 'file') : '' - ])); + ]), 'info'); } if (imported_count) @@ -1149,7 +1149,7 @@ function renderResDownload(section_id) { click: ui.createHandlerFn(this, (section_type, section_id, type, url, header) => { if (type === 'http') { return downloadFile(section_type, section_id, url, header).then((res) => { - ui.addNotification(null, E('p', _('Download successful.'))); + ui.addNotification(null, E('p', _('Download successful.')), 'info'); }).catch((e) => { ui.addNotification(null, E('p', _('Download failed: %s').format(e)), 'error'); }); @@ -1593,7 +1593,7 @@ function uploadCertificate(type, filename, ev) { .then(L.bind((btn, res) => { return L.resolveDefault(callWriteCertificate(filename), {}).then((ret) => { if (ret.result === true) - ui.addNotification(null, E('p', _('Your %s was successfully uploaded. Size: %sB.').format(type, res.size))); + ui.addNotification(null, E('p', _('Your %s was successfully uploaded. Size: %sB.').format(type, res.size)), 'info'); else ui.addNotification(null, E('p', _('Failed to upload %s, error: %s.').format(type, ret.error)), 'error'); }); @@ -1611,7 +1611,7 @@ function uploadInitialPack(ev, section_id) { .then(L.bind((btn, res) => { return L.resolveDefault(callWriteInitialPack(), {}).then((ret) => { if (ret.result === true) { - ui.addNotification(null, E('p', _('Successfully uploaded.'))); + ui.addNotification(null, E('p', _('Successfully uploaded.')), 'info'); return window.location = window.location.href.split('#')[0]; } else ui.addNotification(null, E('p', _('Failed to upload, error: %s.').format(ret.error)), 'error'); diff --git a/small/luci-app-fchomo/htdocs/luci-static/resources/view/fchomo/ruleset.js b/small/luci-app-fchomo/htdocs/luci-static/resources/view/fchomo/ruleset.js index 270a083b2a..ad6916ae32 100644 --- a/small/luci-app-fchomo/htdocs/luci-static/resources/view/fchomo/ruleset.js +++ b/small/luci-app-fchomo/htdocs/luci-static/resources/view/fchomo/ruleset.js @@ -205,7 +205,7 @@ return view.extend({ ui.addNotification(null, E('p', _('No valid rule-set link found.'))); else ui.addNotification(null, E('p', _('Successfully imported %s %s of total %s.') - .format(imported_count, _('rule-set'), input_links.length))); + .format(imported_count, _('rule-set'), input_links.length)), 'info'); } if (imported_count) diff --git a/small/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua b/small/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua index 532783701d..adccab6d46 100644 --- a/small/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua +++ b/small/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua @@ -730,6 +730,9 @@ o = s:option(Value, _n("xudp_concurrency"), translate("XUDP Mux concurrency")) o.default = 8 o:depends({ [_n("mux")] = true }) +o = s:option(Flag, _n("tcp_fast_open"), "TCP " .. translate("Fast Open")) +o.default = 0 + --[[tcpMptcp]] o = s:option(Flag, _n("tcpMptcp"), "tcpMptcp", translate("Enable Multipath TCP, need to be enabled in both server and client configuration.")) o.default = 0 diff --git a/small/luci-app-passwall/luasrc/passwall/util_sing-box.lua b/small/luci-app-passwall/luasrc/passwall/util_sing-box.lua index 22d8dcda05..15c460d5d1 100644 --- a/small/luci-app-passwall/luasrc/passwall/util_sing-box.lua +++ b/small/luci-app-passwall/luasrc/passwall/util_sing-box.lua @@ -95,7 +95,7 @@ function gen_outbound(flag, node, tag, proxy_table) local relay_port = node.port new_port = get_new_port() local config_file = string.format("%s_%s_%s.json", flag, tag, new_port) - if tag and node_id and tag ~= node_id then + if tag and node_id and not tag:find(node_id) then config_file = string.format("%s_%s_%s_%s.json", flag, tag, node_id, new_port) end if run_socks_instance then diff --git a/small/luci-app-passwall/luasrc/passwall/util_xray.lua b/small/luci-app-passwall/luasrc/passwall/util_xray.lua index d18a0dd996..7017ba6dca 100644 --- a/small/luci-app-passwall/luasrc/passwall/util_xray.lua +++ b/small/luci-app-passwall/luasrc/passwall/util_xray.lua @@ -74,7 +74,7 @@ function gen_outbound(flag, node, tag, proxy_table) local relay_port = node.port new_port = get_new_port() local config_file = string.format("%s_%s_%s.json", flag, tag, new_port) - if tag and node_id and tag ~= node_id then + if tag and node_id and not tag:find(node_id) then config_file = string.format("%s_%s_%s_%s.json", flag, tag, node_id, new_port) end if run_socks_instance then @@ -146,6 +146,7 @@ function gen_outbound(flag, node, tag, proxy_table) streamSettings = (node.streamSettings or node.protocol == "vmess" or node.protocol == "vless" or node.protocol == "socks" or node.protocol == "shadowsocks" or node.protocol == "trojan") and { sockopt = { mark = 255, + tcpFastOpen = (node.tcp_fast_open == "1") and true or nil, tcpMptcp = (node.tcpMptcp == "1") and true or nil, dialerProxy = (fragment or noise) and "dialerproxy" or nil }, diff --git a/small/luci-app-passwall/root/usr/share/passwall/app.sh b/small/luci-app-passwall/root/usr/share/passwall/app.sh index 9701240f91..1375aa1a88 100755 --- a/small/luci-app-passwall/root/usr/share/passwall/app.sh +++ b/small/luci-app-passwall/root/usr/share/passwall/app.sh @@ -254,7 +254,8 @@ check_ver() { } first_type() { - for p in "/bin/$1" "${TMP_BIN_PATH:-/tmp}/$1" "$1"; do + [ "${1#/}" != "$1" ] && [ -x "$1" ] && echo "$1" && return + for p in "/bin/$1" "/usr/bin/$1" "${TMP_BIN_PATH:-/tmp}/$1"; do [ -x "$p" ] && echo "$p" && return done command -v "$1" 2>/dev/null || command -v "$2" 2>/dev/null @@ -690,7 +691,7 @@ run_socks() { case "$type" in socks) - local _socks_address _socks_port _socks_username _socks_password + local _socks_address _socks_port _socks_username _socks_password _extra_param microsocks_fwd if [ "$node2socks_port" = "0" ]; then _socks_address=$(config_n_get $node address) _socks_port=$(config_n_get $node port) @@ -700,13 +701,24 @@ run_socks() { _socks_address="127.0.0.1" _socks_port=$node2socks_port fi - [ "$http_port" != "0" ] && { + if [ "$http_port" != "0" ]; then http_flag=1 config_file="${config_file//SOCKS/HTTP_SOCKS}" - local _extra_param="-local_http_address $bind -local_http_port $http_port" - } + _extra_param="-local_http_address $bind -local_http_port $http_port" + else + # 仅 passwall-packages 专用的 microsocks 才支持 socks 转发规则! + microsocks_fwd="$($(first_type microsocks) -V 2>/dev/null | grep -i forward)" + fi local bin=$(first_type $(config_t_get global_app sing_box_file) sing-box) - if [ -n "$bin" ]; then + if [ -n "$microsocks_fwd" ]; then + local ext_name=$(echo "$config_file" | sed "s|^${TMP_PATH}/||; s|\.json\$||; s|/|_|g") + if [ -n "$_socks_username" ] && [ -n "$_socks_password" ]; then + _extra_param="-f \"0.0.0.0:0,${_socks_username}:${_socks_password}@${_socks_address}:${_socks_port},0.0.0.0:0\"" + else + _extra_param="-f \"0.0.0.0:0,${_socks_address}:${_socks_port},0.0.0.0:0\"" + fi + ln_run "$(first_type microsocks)" "microsocks_${ext_name}" $log_file -i $bind -p $socks_port ${_extra_param} + elif [ -n "$bin" ]; then type="sing-box" lua $UTIL_SINGBOX gen_proto_config -local_socks_address $bind -local_socks_port $socks_port ${_extra_param} -server_proto socks -server_address ${_socks_address} -server_port ${_socks_port} -server_username ${_socks_username} -server_password ${_socks_password} > $config_file ln_run "$bin" ${type} $log_file run -c "$config_file" diff --git a/small/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua b/small/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua index b57d437066..2cca52135a 100644 --- a/small/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua +++ b/small/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/advanced.lua @@ -249,10 +249,9 @@ o = s:option(Flag, "adblock", translate("Enable adblock")) o.rmempty = false o = s:option(Value, "adblock_url", translate("adblock_url")) -o:value("https://raw.githubusercontent.com/neodevpro/neodevhost/master/lite_dnsmasq.conf", translate("NEO DEV HOST Lite")) -o:value("https://raw.githubusercontent.com/neodevpro/neodevhost/master/dnsmasq.conf", translate("NEO DEV HOST Full")) +o:value("https://raw.githubusercontent.com/neodevpro/neodevhost/master/dnsmasq.conf", translate("NEO DEV HOST")) o:value("https://anti-ad.net/anti-ad-for-dnsmasq.conf", translate("anti-AD")) -o.default = "https://raw.githubusercontent.com/neodevpro/neodevhost/master/lite_dnsmasq.conf" +o.default = "https://raw.githubusercontent.com/neodevpro/neodevhost/master/dnsmasq.conf" o:depends("adblock", "1") o.description = translate("Support AdGuardHome and DNSMASQ format list") diff --git a/small/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua b/small/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua index 1becebf6e7..1c970e771f 100644 --- a/small/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua +++ b/small/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua @@ -789,8 +789,12 @@ o:depends({type = "v2ray", v2ray_protocol = "vless"}) o = s:option(Value, "vless_encryption", translate("VLESS Encryption")) o.rmempty = true o.default = "none" -o:value("none") +o.placeholder = "none" o:depends({type = "v2ray", v2ray_protocol = "vless"}) +o.validate = function(self, value) + value = value and value:match("^%s*(.-)%s*$") or value + return value ~= "" and value or "none" +end -- 加密方式 o = s:option(ListValue, "security", translate("Encrypt Method")) @@ -1180,10 +1184,8 @@ if is_finded("xray") then end end o.rmempty = true - o:depends({type = "v2ray", v2ray_protocol = "vless", transport = "raw", tls = true}) - o:depends({type = "v2ray", v2ray_protocol = "vless", transport = "xhttp", tls = true}) - o:depends({type = "v2ray", v2ray_protocol = "vless", transport = "raw", reality = true}) - o:depends({type = "v2ray", v2ray_protocol = "vless", transport = "xhttp", reality = true}) + o:depends({type = "v2ray", v2ray_protocol = "vless", transport = "raw"}) + o:depends({type = "v2ray", v2ray_protocol = "vless", transport = "xhttp"}) -- [[ uTLS ]]-- o = s:option(ListValue, "fingerprint", translate("Finger Print")) diff --git a/small/luci-app-ssr-plus/luasrc/view/shadowsocksr/ssrurl.htm b/small/luci-app-ssr-plus/luasrc/view/shadowsocksr/ssrurl.htm index abba53cfe5..fbb11dce49 100644 --- a/small/luci-app-ssr-plus/luasrc/view/shadowsocksr/ssrurl.htm +++ b/small/luci-app-ssr-plus/luasrc/view/shadowsocksr/ssrurl.htm @@ -716,12 +716,17 @@ function import_ssr_url(btn, urlname, sid) { element.dispatchEvent(event); } } + var server = url.hostname; + /* 如果带 [],再去掉 [] */ + if (server[0] === "[" && server[server.length - 1] === "]") { + server = server.slice(1, -1); + } setElementValue('cbid.shadowsocksr.' + sid + '.alias', url.hash ? decodeURIComponent(url.hash.slice(1)) : ""); setElementValue('cbid.shadowsocksr.' + sid + '.type', "v2ray"); dispatchEventIfExists('cbid.shadowsocksr.' + sid + '.type', event); setElementValue('cbid.shadowsocksr.' + sid + '.v2ray_protocol', "vless"); dispatchEventIfExists('cbid.shadowsocksr.' + sid + '.v2ray_protocol', event); - setElementValue('cbid.shadowsocksr.' + sid + '.server', url.hostname); + setElementValue('cbid.shadowsocksr.' + sid + '.server', server); setElementValue('cbid.shadowsocksr.' + sid + '.server_port', url.port || "80"); setElementValue('cbid.shadowsocksr.' + sid + '.vmess_id', url.username); setElementValue('cbid.shadowsocksr.' + sid + '.transport', @@ -732,6 +737,8 @@ function import_ssr_url(btn, urlname, sid) { ); dispatchEventIfExists('cbid.shadowsocksr.' + sid + '.transport', event); setElementValue('cbid.shadowsocksr.' + sid + '.vless_encryption', params.get("encryption") || "none"); + setElementValue('cbid.shadowsocksr.' + sid + '.tls_flow', params.get("flow") || "none"); + dispatchEventIfExists('cbid.shadowsocksr.' + sid + '.tls_flow', event); if ([ "tls", "xtls", "reality" ].includes(params.get("security"))) { setElementValue('cbid.shadowsocksr.' + sid + '.' + params.get("security"), true); dispatchEventIfExists('cbid.shadowsocksr.' + sid + '.' + params.get("security"), event); @@ -758,8 +765,6 @@ function import_ssr_url(btn, urlname, sid) { setElementValue('cbid.shadowsocksr.' + sid + '.reality_mldsa65verify', params.get("pqv") || ""); } } - setElementValue('cbid.shadowsocksr.' + sid + '.tls_flow', params.get("flow") || "none"); - dispatchEventIfExists('cbid.shadowsocksr.' + sid + '.tls_flow', event); setElementValue('cbid.shadowsocksr.' + sid + '.tls_alpn', params.get("alpn") || ""); setElementValue('cbid.shadowsocksr.' + sid + '.fingerprint', params.get("fp") || ""); diff --git a/small/luci-app-ssr-plus/root/etc/init.d/shadowsocksr b/small/luci-app-ssr-plus/root/etc/init.d/shadowsocksr index d905c97ca9..e45d2954e7 100755 --- a/small/luci-app-ssr-plus/root/etc/init.d/shadowsocksr +++ b/small/luci-app-ssr-plus/root/etc/init.d/shadowsocksr @@ -587,7 +587,7 @@ start_udp() { gen_config_file $UDP_RELAY_SERVER $type 2 $tmp_udp_local_port ln_start_bin $(first_type tuic-client) tuic-client --config $udp_config_file ln_start_bin $(first_type ipt2socks) ipt2socks -U -b 0.0.0.0 -4 -s 127.0.0.1 -p $tmp_udp_local_port -l $tmp_udp_port - echolog "UDP TPROXY Relay:tuic-client $($(first_type tuic-client) --version) Started!" + echolog "UDP TPROXY Relay:$($(first_type tuic-client) --version) Started!" echolog "TUIC UDP TPROXY Relay not supported!" #redir_udp=0 #ARG_UDP="" @@ -879,7 +879,7 @@ start_shunt() { gen_config_file $SHUNT_SERVER $type 3 $tmp_port # make a tuic socks :304 ln_start_bin $(first_type tuic-client) tuic-client --config $shunt_dns_config_file shunt_dns_command $tmp_port - echolog "Netflix Separated Shunt Server:tuic-client $($(first_type tuic-client) --version) Started!" + echolog "Netflix Separated Shunt Server:$($(first_type tuic-client) --version) Started!" # FIXME: ipt2socks cannot handle udp reply from tuic #redir_udp=0 ;; @@ -985,7 +985,7 @@ start_local() { if [ "$_local" == "2" ]; then gen_config_file $LOCAL_SERVER $type 4 $local_port ln_start_bin $(first_type tuic-client) tuic-client --config $local_config_file - echolog "Global Socks5:tuic-client $($(first_type tuic-client) --version) Started!" + echolog "Global Socks5:$($(first_type tuic-client) --version) Started!" fi ;; shadowtls) @@ -1104,9 +1104,9 @@ Start_Run() { if [ -n $socks_port ] && [ $GLOBAL_SERVER == $LOCAL_SERVER ]; then #start a new tuic instance gen_config_file $GLOBAL_SERVER $type 4 $socks_port ln_start_bin $(first_type tuic-client) tuic-client --config $local_config_file - echolog "Global Socks5:tuic-client $($(first_type tuic-client) --version) Started!" + echolog "Global Socks5:$($(first_type tuic-client) --version) Started!" fi - echolog "Main node:tuic-client $($(first_type tuic-client) --version) Started!" + echolog "Main node:$($(first_type tuic-client) --version) Started!" ;; shadowtls) if [ -z "$socks_port" ]; then diff --git a/small/luci-app-ssr-plus/root/usr/share/shadowsocksr/gen_config.lua b/small/luci-app-ssr-plus/root/usr/share/shadowsocksr/gen_config.lua index f84bbb7392..087a812e3e 100755 --- a/small/luci-app-ssr-plus/root/usr/share/shadowsocksr/gen_config.lua +++ b/small/luci-app-ssr-plus/root/usr/share/shadowsocksr/gen_config.lua @@ -32,7 +32,10 @@ function vmess_vless() alterId = (server.v2ray_protocol == "vmess" or not server.v2ray_protocol) and tonumber(server.alter_id) or nil, security = (server.v2ray_protocol == "vmess" or not server.v2ray_protocol) and server.security or nil, encryption = (server.v2ray_protocol == "vless") and server.vless_encryption or "none", - flow = (((server.xtls == '1') or (server.tls == '1') or (server.reality == '1')) and (((server.tls_flow ~= "none") and server.tls_flow) or ((server.xhttp_tls_flow ~= "none") and server.xhttp_tls_flow))) or nil + flow = (server.v2ray_protocol == "vless" and (server.xtls == "1" or server.tls == "1" or server.reality == "1" + or (server.vless_encryption and server.vless_encryption ~= "" and server.vless_encryption ~= "none")) and ( + server.transport == "raw" or server.transport == "tcp" or server.transport == "xhttp" or server.transport == "splithttp") and ( + server.tls_flow and server.tls_flow ~= "none")) and server.tls_flow or nil } } } @@ -717,4 +720,3 @@ function config:handleIndex(index) end local f = config:new() f:handleIndex(server.type) - diff --git a/small/luci-app-ssr-plus/root/usr/share/shadowsocksr/subscribe.lua b/small/luci-app-ssr-plus/root/usr/share/shadowsocksr/subscribe.lua index 348b4dff46..59a8dd5cf9 100755 --- a/small/luci-app-ssr-plus/root/usr/share/shadowsocksr/subscribe.lua +++ b/small/luci-app-ssr-plus/root/usr/share/shadowsocksr/subscribe.lua @@ -892,7 +892,7 @@ local function processData(szType, content) -- 统一 TLS / Reality 公共字段 result.tls_host = params.sni result.fingerprint = params.fp - result.tls_flow = (security == "tls" or security == "reality") and params.flow or nil + result.tls_flow = params.flow or nil -- 处理 alpn 列表 if params.alpn and params.alpn ~= "" then diff --git a/small/v2ray-geodata/Makefile b/small/v2ray-geodata/Makefile index f1937ac029..cc1ee1d075 100644 --- a/small/v2ray-geodata/Makefile +++ b/small/v2ray-geodata/Makefile @@ -21,13 +21,13 @@ define Download/geoip HASH:=6878dbacfb1fcb1ee022f63ed6934bcefc95a3c4ba10c88f1131fb88dbf7c337 endef -GEOSITE_VER:=20251222003838 +GEOSITE_VER:=20251223103233 GEOSITE_FILE:=dlc.dat.$(GEOSITE_VER) define Download/geosite URL:=https://github.com/v2fly/domain-list-community/releases/download/$(GEOSITE_VER)/ URL_FILE:=dlc.dat FILE:=$(GEOSITE_FILE) - HASH:=5fe8b1f522962e05791ac23bd1ba4895f6c18473ed4d2019b8df086960b0e651 + HASH:=496c31e5174d75f443efc3980e24d3b3b5b49a70ce348f52b1c2ef181e8da754 endef GEOSITE_IRAN_VER:=202512220045 diff --git a/xray-core/app/dns/dns.go b/xray-core/app/dns/dns.go index 79f9e8e1f1..603640f154 100644 --- a/xray-core/app/dns/dns.go +++ b/xray-core/app/dns/dns.go @@ -106,13 +106,12 @@ func New(ctx context.Context, config *Config) (*DNS, error) { for _, ns := range config.NameServer { clientIdx := len(clients) - updateDomain := func(domainRule strmatcher.Matcher, originalRuleIdx int, matcherInfos []*DomainMatcherInfo) error { + updateDomain := func(domainRule strmatcher.Matcher, originalRuleIdx int, matcherInfos []*DomainMatcherInfo) { midx := domainMatcher.Add(domainRule) matcherInfos[midx] = &DomainMatcherInfo{ clientIdx: uint16(clientIdx), domainRuleIdx: uint16(originalRuleIdx), } - return nil } myClientIP := clientIP diff --git a/xray-core/app/dns/hosts.go b/xray-core/app/dns/hosts.go index c2f7649de4..0ee4fdd0bb 100644 --- a/xray-core/app/dns/hosts.go +++ b/xray-core/app/dns/hosts.go @@ -27,7 +27,8 @@ func NewStaticHosts(hosts []*Config_HostMapping) (*StaticHosts, error) { for _, mapping := range hosts { matcher, err := toStrMatcher(mapping.Type, mapping.Domain) if err != nil { - return nil, errors.New("failed to create domain matcher").Base(err) + errors.LogErrorInner(context.Background(), err, "failed to create domain matcher, ignore domain rule [type: ", mapping.Type, ", domain: ", mapping.Domain, "]") + continue } id := g.Add(matcher) ips := make([]net.Address, 0, len(mapping.Ip)+1) @@ -46,10 +47,14 @@ func NewStaticHosts(hosts []*Config_HostMapping) (*StaticHosts, error) { for _, ip := range mapping.Ip { addr := net.IPAddress(ip) if addr == nil { - return nil, errors.New("invalid IP address in static hosts: ", ip).AtWarning() + errors.LogError(context.Background(), "invalid IP address in static hosts: ", ip, ", ignore this ip for rule [type: ", mapping.Type, ", domain: ", mapping.Domain, "]") + continue } ips = append(ips, addr) } + if len(ips) == 0 { + continue + } } sh.ips[id] = ips diff --git a/xray-core/app/dns/nameserver.go b/xray-core/app/dns/nameserver.go index e606d4b38f..bad9277c76 100644 --- a/xray-core/app/dns/nameserver.go +++ b/xray-core/app/dns/nameserver.go @@ -97,7 +97,7 @@ func NewClient( tag string, ipOption dns.IPOption, matcherInfos *[]*DomainMatcherInfo, - updateDomainRule func(strmatcher.Matcher, int, []*DomainMatcherInfo) error, + updateDomainRule func(strmatcher.Matcher, int, []*DomainMatcherInfo), ) (*Client, error) { client := &Client{} @@ -134,7 +134,8 @@ func NewClient( for _, domain := range ns.PrioritizedDomain { domainRule, err := toStrMatcher(domain.Type, domain.Domain) if err != nil { - return errors.New("failed to create prioritized domain").Base(err).AtWarning() + errors.LogErrorInner(ctx, err, "failed to create domain matcher, ignore domain rule [type: ", domain.Type, ", domain: ", domain.Domain, "]") + domainRule, _ = toStrMatcher(DomainMatchingType_Full, "hack.fix.index.for.illegal.domain.rule") } originalRuleIdx := ruleCurr if ruleCurr < len(ns.OriginalRules) { @@ -151,10 +152,7 @@ func NewClient( rules = append(rules, domainRule.String()) ruleCurr++ } - err = updateDomainRule(domainRule, originalRuleIdx, *matcherInfos) - if err != nil { - return errors.New("failed to create prioritized domain").Base(err).AtWarning() - } + updateDomainRule(domainRule, originalRuleIdx, *matcherInfos) } // Establish expected IPs diff --git a/xray-core/app/router/condition.go b/xray-core/app/router/condition.go index 083cbfafc1..d21487a203 100644 --- a/xray-core/app/router/condition.go +++ b/xray-core/app/router/condition.go @@ -1,6 +1,7 @@ package router import ( + "context" "regexp" "strings" @@ -56,11 +57,13 @@ func NewMphMatcherGroup(domains []*Domain) (*DomainMatcher, error) { for _, d := range domains { matcherType, f := matcherTypeMap[d.Type] if !f { - return nil, errors.New("unsupported domain type", d.Type) + errors.LogError(context.Background(), "ignore unsupported domain type ", d.Type, " of rule ", d.Value) + continue } _, err := g.AddPattern(d.Value, matcherType) if err != nil { - return nil, err + errors.LogErrorInner(context.Background(), err, "ignore domain rule ", d.Type, " ", d.Value) + continue } } g.Build() diff --git a/xray-core/common/strmatcher/strmatcher.go b/xray-core/common/strmatcher/strmatcher.go index 294e6e73bd..4035acc3b2 100644 --- a/xray-core/common/strmatcher/strmatcher.go +++ b/xray-core/common/strmatcher/strmatcher.go @@ -1,6 +1,7 @@ package strmatcher import ( + "errors" "regexp" ) @@ -44,7 +45,7 @@ func (t Type) New(pattern string) (Matcher, error) { pattern: r, }, nil default: - panic("Unknown type") + return nil, errors.New("unk type") } } diff --git a/xray-core/go.mod b/xray-core/go.mod index 6e4edc9207..ab8bed0632 100644 --- a/xray-core/go.mod +++ b/xray-core/go.mod @@ -3,7 +3,7 @@ module github.com/xtls/xray-core go 1.25 require ( - github.com/cloudflare/circl v1.6.1 + github.com/cloudflare/circl v1.6.2 github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 github.com/golang/mock v1.7.0-rc.1 github.com/google/go-cmp v0.7.0 diff --git a/xray-core/go.sum b/xray-core/go.sum index 58029a62bd..4b65b8b0bf 100644 --- a/xray-core/go.sum +++ b/xray-core/go.sum @@ -1,7 +1,7 @@ github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= +github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/xray-core/proxy/dokodemo/dokodemo.go b/xray-core/proxy/dokodemo/dokodemo.go index 90fc53e8ca..e56363db0a 100644 --- a/xray-core/proxy/dokodemo/dokodemo.go +++ b/xray-core/proxy/dokodemo/dokodemo.go @@ -111,7 +111,8 @@ func (d *DokodemoDoor) Process(ctx context.Context, network net.Network, conn st destinationOverridden = true } } - if tlsConn, ok := conn.(tls.Interface); ok && !destinationOverridden { + iConn := stat.TryUnwrapStatsConn(conn) + if tlsConn, ok := iConn.(tls.Interface); ok && !destinationOverridden { if serverName := tlsConn.HandshakeContextServerName(ctx); serverName != "" { dest.Address = net.DomainAddress(serverName) destinationOverridden = true diff --git a/xray-core/proxy/http/client.go b/xray-core/proxy/http/client.go index 9544ad374a..1f804d0bb7 100644 --- a/xray-core/proxy/http/client.go +++ b/xray-core/proxy/http/client.go @@ -296,10 +296,7 @@ func setUpHTTPTunnel(ctx context.Context, dest net.Destination, target string, u return nil, err } - iConn := rawConn - if statConn, ok := iConn.(*stat.CounterConnection); ok { - iConn = statConn.Connection - } + iConn := stat.TryUnwrapStatsConn(rawConn) nextProto := "" if tlsConn, ok := iConn.(*tls.Conn); ok { diff --git a/xray-core/proxy/proxy.go b/xray-core/proxy/proxy.go index d6a797cbb7..29548d9fb1 100644 --- a/xray-core/proxy/proxy.go +++ b/xray-core/proxy/proxy.go @@ -787,10 +787,7 @@ func readV(ctx context.Context, reader buf.Reader, writer buf.Writer, timer sign } func IsRAWTransportWithoutSecurity(conn stat.Connection) bool { - iConn := conn - if statConn, ok := iConn.(*stat.CounterConnection); ok { - iConn = statConn.Connection - } + iConn := stat.TryUnwrapStatsConn(conn) _, ok1 := iConn.(*proxyproto.Conn) _, ok2 := iConn.(*net.TCPConn) _, ok3 := iConn.(*internet.UnixConnWrapper) diff --git a/xray-core/proxy/trojan/server.go b/xray-core/proxy/trojan/server.go index 8ed3b0e6ee..d66219c48d 100644 --- a/xray-core/proxy/trojan/server.go +++ b/xray-core/proxy/trojan/server.go @@ -147,11 +147,7 @@ func (s *Server) Network() []net.Network { // Process implements proxy.Inbound.Process(). func (s *Server) Process(ctx context.Context, network net.Network, conn stat.Connection, dispatcher routing.Dispatcher) error { - iConn := conn - statConn, ok := iConn.(*stat.CounterConnection) - if ok { - iConn = statConn.Connection - } + iConn := stat.TryUnwrapStatsConn(conn) sessionPolicy := s.policyManager.ForLevel(0) if err := conn.SetReadDeadline(time.Now().Add(sessionPolicy.Timeouts.Handshake)); err != nil { diff --git a/xray-core/proxy/vless/encoding/encoding.go b/xray-core/proxy/vless/encoding/encoding.go index fe2c6bc871..b3b43bacdc 100644 --- a/xray-core/proxy/vless/encoding/encoding.go +++ b/xray-core/proxy/vless/encoding/encoding.go @@ -10,6 +10,7 @@ import ( "github.com/xtls/xray-core/common/protocol" "github.com/xtls/xray-core/common/session" "github.com/xtls/xray-core/common/signal" + "github.com/xtls/xray-core/common/uuid" "github.com/xtls/xray-core/proxy" "github.com/xtls/xray-core/proxy/vless" ) @@ -91,7 +92,8 @@ func DecodeRequestHeader(isfb bool, first *buf.Buffer, reader io.Reader, validat } if request.User = validator.Get(id); request.User == nil { - return nil, nil, nil, isfb, errors.New("invalid request user id") + u := uuid.UUID(id) + return nil, nil, nil, isfb, errors.New("invalid request user id: %s" + u.String()) } if isfb { diff --git a/xray-core/proxy/vless/inbound/inbound.go b/xray-core/proxy/vless/inbound/inbound.go index 89ed0e724f..eeb1a25f79 100644 --- a/xray-core/proxy/vless/inbound/inbound.go +++ b/xray-core/proxy/vless/inbound/inbound.go @@ -265,10 +265,7 @@ func (*Handler) Network() []net.Network { // Process implements proxy.Inbound.Process(). func (h *Handler) Process(ctx context.Context, network net.Network, connection stat.Connection, dispatcher routing.Dispatcher) error { - iConn := connection - if statConn, ok := iConn.(*stat.CounterConnection); ok { - iConn = statConn.Connection - } + iConn := stat.TryUnwrapStatsConn(connection) if h.decryption != nil { var err error diff --git a/xray-core/proxy/vless/outbound/outbound.go b/xray-core/proxy/vless/outbound/outbound.go index 17e82210e4..a4fe1e93bd 100644 --- a/xray-core/proxy/vless/outbound/outbound.go +++ b/xray-core/proxy/vless/outbound/outbound.go @@ -192,10 +192,7 @@ func (h *Handler) Process(ctx context.Context, link *transport.Link, dialer inte ob.Conn = conn // for Vision's pre-connect - iConn := conn - if statConn, ok := iConn.(*stat.CounterConnection); ok { - iConn = statConn.Connection - } + iConn := stat.TryUnwrapStatsConn(conn) target := ob.Target errors.LogInfo(ctx, "tunneling request to ", target, " via ", rec.Destination.NetAddr()) diff --git a/xray-core/proxy/vmess/inbound/inbound.go b/xray-core/proxy/vmess/inbound/inbound.go index 7975551bd5..6a8591ad69 100644 --- a/xray-core/proxy/vmess/inbound/inbound.go +++ b/xray-core/proxy/vmess/inbound/inbound.go @@ -229,10 +229,7 @@ func (h *Handler) Process(ctx context.Context, network net.Network, connection s return errors.New("unable to set read deadline").Base(err).AtWarning() } - iConn := connection - if statConn, ok := iConn.(*stat.CounterConnection); ok { - iConn = statConn.Connection - } + iConn := stat.TryUnwrapStatsConn(connection) _, isDrain := iConn.(*net.TCPConn) if !isDrain { _, isDrain = iConn.(*net.UnixConn) diff --git a/xray-core/transport/internet/reality/reality.go b/xray-core/transport/internet/reality/reality.go index 8cb59342b4..21b185ea1d 100644 --- a/xray-core/transport/internet/reality/reality.go +++ b/xray-core/transport/internet/reality/reality.go @@ -180,11 +180,14 @@ func UClient(c net.Conn, config *Config, ctx context.Context, dest net.Destinati fmt.Printf("REALITY localAddr: %v\tuConn.Verified: %v\n", localAddr, uConn.Verified) } if !uConn.Verified { + errors.LogError(ctx, "REALITY: received real certificate (potential MITM or redirection)") go func() { client := &http.Client{ Transport: &http2.Transport{ DialTLSContext: func(ctx context.Context, network, addr string, cfg *gotls.Config) (net.Conn, error) { - fmt.Printf("REALITY localAddr: %v\tDialTLSContext\n", localAddr) + if config.Show { + fmt.Printf("REALITY localAddr: %v\tDialTLSContext\n", localAddr) + } return uConn, nil }, }, diff --git a/xray-core/transport/internet/stat/connection.go b/xray-core/transport/internet/stat/connection.go index 6921943d46..b039b29cf0 100644 --- a/xray-core/transport/internet/stat/connection.go +++ b/xray-core/transport/internet/stat/connection.go @@ -32,3 +32,13 @@ func (c *CounterConnection) Write(b []byte) (int, error) { } return nBytes, err } + +func TryUnwrapStatsConn(conn net.Conn) net.Conn { + if conn == nil { + return conn + } + if conn, ok := conn.(*CounterConnection); ok { + return conn.Connection + } + return conn +} diff --git a/xray-core/transport/internet/tls/ech.go b/xray-core/transport/internet/tls/ech.go index 2cd07c9dc2..c1c55effef 100644 --- a/xray-core/transport/internet/tls/ech.go +++ b/xray-core/transport/internet/tls/ech.go @@ -246,7 +246,7 @@ func dnsQuery(server string, domain string, sockopt *internet.SocketConfig) ([]b }, } c := &http.Client{ - Timeout: 5 * time.Second, + Timeout: 30 * time.Second, Transport: tr, } client, _ = clientForECHDOH.LoadOrStore(serverKey, c)