diff --git a/constant/proxy.go b/constant/proxy.go index 6d34811780..6b9037ef53 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -46,6 +46,7 @@ const ( TypeAwg = "awg" //H TypeBalancer = "balancer" //H TypeDNSTT = "dnstt" //H + TypeTooska = "tooska" //H ) const ( @@ -131,6 +132,8 @@ func ProxyDisplayName(proxyType string) string { return "Balancer" case TypeDNSTT: return "DNSTT" + case TypeTooska: + return "Tooska" default: return "Unknown" } diff --git a/include/registry.go b/include/registry.go index dc00bdc8ff..dd7fd21e09 100644 --- a/include/registry.go +++ b/include/registry.go @@ -26,6 +26,7 @@ import ( "github.com/sagernet/sing-box/protocol/group/balancer" "github.com/sagernet/sing-box/protocol/hiddify/dnstt" "github.com/sagernet/sing-box/protocol/hiddify/hinvalid" + "github.com/sagernet/sing-box/protocol/hiddify/tooska" "github.com/sagernet/sing-box/protocol/hiddify/xray" "github.com/sagernet/sing-box/protocol/http" @@ -109,6 +110,7 @@ func OutboundRegistry() *outbound.Registry { hinvalid.RegisterOutbound(registry) xray.RegisterOutbound(registry) dnstt.RegisterOutbound(registry) + tooska.RegisterOutbound(registry) balancer.RegisterLoadBalance(registry) registerQUICOutbounds(registry) diff --git a/option/tooska.go b/option/tooska.go new file mode 100644 index 0000000000..67623fb112 --- /dev/null +++ b/option/tooska.go @@ -0,0 +1,42 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +// TooskaOutboundOptions configures the Tooska outbound. Tooska embeds the +// goBrrrr proxy scanner: it scans the configured target ranges for working +// SOCKS5 / HTTP CONNECT proxies, scores each endpoint between -10 and +10 +// based on past results, and tunnels traffic through the best-scored hits. +// +// Note: large CIDR sweeps with high concurrency look like network abuse to +// many ISPs and can trip CGNAT or carrier abuse detection on the user's +// side. Operators should aim Tooska only at trusted ranges and choose +// concurrency / target size with that in mind. +type TooskaOutboundOptions struct { + DialerOptions + + // Targets is a list of IPs, CIDRs, or "ip:port" entries to scan. + // Plain IPs/CIDRs are paired with every entry in Ports; "ip:port" is + // taken as-is and ignores Ports. + Targets []string `json:"targets,omitempty"` + + // Ports is the list of TCP ports paired with bare IP/CIDR targets. If + // empty a sensible default proxy-port set is used. + Ports []int `json:"ports,omitempty"` + + // Concurrency caps simultaneous in-flight TCP connections. Default 256. + Concurrency int `json:"concurrency,omitempty"` + + // PoolSize caps the number of working endpoints kept warm. Default 16. + PoolSize int `json:"pool_size,omitempty"` + + // PreferProtocol picks which protocol (socks5 / http) to test first + // when both are valid for an endpoint. Default empty (try socks5 first). + PreferProtocol string `json:"prefer_protocol,omitempty"` + + // UserAgent sent in scanner HTTP probes. Default "tooska/1.0". + UserAgent string `json:"user_agent,omitempty"` + + DialTimeout *badoption.Duration `json:"dial_timeout,omitempty"` + FingerprintTimeout *badoption.Duration `json:"fingerprint_timeout,omitempty"` + ProbeTimeout *badoption.Duration `json:"probe_timeout,omitempty"` +} diff --git a/protocol/hiddify/tooska/dial.go b/protocol/hiddify/tooska/dial.go new file mode 100644 index 0000000000..94e2df9d49 --- /dev/null +++ b/protocol/hiddify/tooska/dial.go @@ -0,0 +1,180 @@ +package tooska + +import ( + "bufio" + "context" + "net" + "net/http" + "net/url" + "time" + + "github.com/sagernet/sing-box/protocol/hiddify/tooska/scanner" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/protocol/socks" + "github.com/sagernet/sing/protocol/socks/socks5" +) + +const maxDialAttempts = 5 + +// dialOutcome separates "proxy is broken" from "proxy is fine but the +// destination is unreachable". Only the former feeds back into scoring. +type dialOutcome int + +const ( + dialOK dialOutcome = iota + dialProxyFault + dialDestinationFault +) + +func (h *Outbound) dialThroughPool(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if N.NetworkName(network) != N.NetworkTCP { + return nil, E.Extend(N.ErrUnknownNetwork, network) + } + working := h.pool.working(0) + if len(working) == 0 { + return nil, E.New("tooska: no working endpoint available yet") + } + + attempts := maxDialAttempts + if attempts > len(working) { + attempts = len(working) + } + + var lastErr error + for i := 0; i < attempts; i++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + ep := working[i] + serverAddr := M.ParseSocksaddrHostPort(ep.IP, uint16(ep.Port)) + conn, outcome, err := h.dialOne(ctx, ep.Protocol, serverAddr, destination) + if outcome == dialOK { + h.pool.recordHit(scanner.Result{ + IP: ep.IP, + Port: ep.Port, + Protocol: ep.Protocol, + LatencyMS: ep.LatencyMS, + CheckedAt: time.Now().UTC(), + }) + h.kickRescanOnConnect() + return conn, nil + } + lastErr = err + h.logger.DebugContext(ctx, "tooska dial via ", ep.IP, ":", ep.Port, " (", ep.Protocol, ") ", outcomeLabel(outcome), ": ", err) + if outcome == dialProxyFault { + h.pool.recordMiss(ep.IP, ep.Port) + } + } + + h.kickRescanOnConnect() + if lastErr == nil { + lastErr = E.New("tooska: every working endpoint refused the dial") + } + return nil, lastErr +} + +func outcomeLabel(o dialOutcome) string { + switch o { + case dialProxyFault: + return "proxy fault" + case dialDestinationFault: + return "destination fault" + default: + return "ok" + } +} + +func (h *Outbound) dialOne(ctx context.Context, proto scanner.Protocol, server, destination M.Socksaddr) (net.Conn, dialOutcome, error) { + tcpConn, err := h.upstream.DialContext(ctx, N.NetworkTCP, server) + if err != nil { + return nil, dialProxyFault, err + } + if deadline, ok := ctx.Deadline(); ok { + _ = tcpConn.SetDeadline(deadline) + } + switch proto { + case scanner.ProtoSOCKS5: + resp, err := socks.ClientHandshake5(tcpConn, socks5.CommandConnect, destination, "", "") + if err != nil { + tcpConn.Close() + switch resp.ReplyCode { + case socks5.ReplyCodeNetworkUnreachable, + socks5.ReplyCodeHostUnreachable, + socks5.ReplyCodeConnectionRefused, + socks5.ReplyCodeTTLExpired: + return nil, dialDestinationFault, err + } + return nil, dialProxyFault, err + } + _ = tcpConn.SetDeadline(time.Time{}) + return tcpConn, dialOK, nil + case scanner.ProtoHTTP: + return h.handshakeHTTP(ctx, tcpConn, destination) + default: + tcpConn.Close() + return nil, dialProxyFault, E.New("tooska: unsupported endpoint protocol ", proto) + } +} + +// handshakeHTTP performs CONNECT over an already-dialled conn. Builds +// the request manually so the conn isn't hijacked by net/http, and +// preserves any bytes the proxy pipelined after the 200 status. +func (h *Outbound) handshakeHTTP(ctx context.Context, conn net.Conn, destination M.Socksaddr) (net.Conn, dialOutcome, error) { + target := destination.String() + req := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Host: target}, + Host: target, + Header: http.Header{}, + } + req.Header.Set("User-Agent", "tooska/1.0") + if err := req.Write(conn); err != nil { + conn.Close() + return nil, dialProxyFault, err + } + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, req) + if err != nil { + conn.Close() + return nil, dialProxyFault, err + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + conn.Close() + outcome := dialProxyFault + switch resp.StatusCode { + case http.StatusBadGateway, http.StatusGatewayTimeout: + outcome = dialDestinationFault + } + return nil, outcome, E.New("tooska: http connect ", resp.Status) + } + _ = conn.SetDeadline(time.Time{}) + if br.Buffered() > 0 { + conn = &bufferedConn{Conn: conn, r: br} + } + return conn, dialOK, nil +} + +// bufferedConn drains bytes that bufio.Reader pulled past the CONNECT +// 200-OK status before falling back to the underlying conn. +type bufferedConn struct { + net.Conn + r *bufio.Reader +} + +func (b *bufferedConn) Read(p []byte) (int, error) { return b.r.Read(p) } + +// kickRescanOnConnect satisfies "loop on every connect" — fires one +// background sweep per dial. The scanGate is checked lock-free first so +// we do not pay the cost of spawning a goroutine when one is already in +// flight. +func (h *Outbound) kickRescanOnConnect() { + if h.scanGate.busy() { + return + } + go h.runScan(h.ctx, "post-connect") +} diff --git a/protocol/hiddify/tooska/outbound.go b/protocol/hiddify/tooska/outbound.go new file mode 100644 index 0000000000..4ca81b1a79 --- /dev/null +++ b/protocol/hiddify/tooska/outbound.go @@ -0,0 +1,136 @@ +package tooska + +import ( + "context" + "fmt" + "net" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/monitoring" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/hiddify/tooska/scanner" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.TooskaOutboundOptions](registry, C.TypeTooska, NewOutbound) +} + +var _ adapter.Outbound = (*Outbound)(nil) + +type Outbound struct { + outbound.Adapter + logger logger.ContextLogger + ctx context.Context + + options option.TooskaOutboundOptions + upstream N.Dialer + cache adapter.CacheFile + pool *pool + candidates []scanner.Endpoint + scanGate scanGate + + started atomic.Bool +} + +func NewOutbound(ctx context.Context, _ adapter.Router, logger log.ContextLogger, tag string, options option.TooskaOutboundOptions) (adapter.Outbound, error) { + candidates, err := parseCandidates(options.Targets, options.Ports) + if err != nil { + return nil, err + } + + if options.Concurrency <= 0 { + options.Concurrency = 256 + } + if options.PoolSize <= 0 { + options.PoolSize = 16 + } + if options.UserAgent == "" { + options.UserAgent = "tooska/1.0" + } + + upstream, err := dialer.New(ctx, options.DialerOptions, false) + if err != nil { + return nil, err + } + + return &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeTooska, tag, []string{N.NetworkTCP}, options.DialerOptions), + logger: logger, + ctx: ctx, + options: options, + upstream: upstream, + pool: newPool(), + candidates: candidates, + }, nil +} + +func (h *Outbound) PreStart() error { + h.cache = service.FromContext[adapter.CacheFile](h.ctx) + h.pool.load(h.cache, h.Tag()) + return nil +} + +func (h *Outbound) PostStart() error { + go h.runScan(h.ctx, "bootstrap") + return nil +} + +func (h *Outbound) Close() error { return nil } + +func (h *Outbound) IsReady() bool { return h.pool.workingCount() > 0 } + +func (h *Outbound) DisplayType() string { + base := C.ProxyDisplayName(h.Type()) + if !h.started.Load() { + return base + " ⚠️ Connecting..." + } + return fmt.Sprint(base, " ✔️ ", h.pool.workingCount(), " endpoints") +} + +func (h *Outbound) markStarted() { + if h.started.CompareAndSwap(false, true) { + h.logger.InfoContext(h.ctx, "tooska first endpoint validated, outbound is ready") + monitoring.Get(h.ctx).TestNow(h.Tag()) + } +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + if N.NetworkName(network) != N.NetworkTCP { + return nil, E.Extend(N.ErrUnknownNetwork, network) + } + if !h.IsReady() { + go h.runScan(h.ctx, "on-demand") + return nil, E.New("tooska: no working endpoint, scanner is still warming up") + } + h.logger.InfoContext(ctx, "tooska outbound connection to ", destination) + return h.dialThroughPool(ctx, network, destination) +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, E.New("tooska: UDP is not supported") +} + +func durationOr(d *badoption.Duration, fallback time.Duration) time.Duration { + if d == nil { + return fallback + } + if v := d.Build(); v > 0 { + return v + } + return fallback +} diff --git a/protocol/hiddify/tooska/pool.go b/protocol/hiddify/tooska/pool.go new file mode 100644 index 0000000000..b3f0e99873 --- /dev/null +++ b/protocol/hiddify/tooska/pool.go @@ -0,0 +1,181 @@ +package tooska + +import ( + "encoding/json" + "sort" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/protocol/hiddify/tooska/scanner" +) + +const ( + scoreFloor = -10 + scoreCap = 10 + scoreHit = 1 + scoreMiss = 1 +) + +func historyKey(tag string) string { return "tooska_endpoints_" + tag } + +type poolEntry struct { + IP string `json:"ip"` + Port int `json:"port"` + Protocol scanner.Protocol `json:"protocol,omitempty"` + Score int `json:"score"` + LatencyMS int64 `json:"latency_ms,omitempty"` + LastCheck time.Time `json:"last_check,omitempty"` +} + +func (e *poolEntry) key() string { return joinHostPort(e.IP, e.Port) } +func (e *poolEntry) ok() bool { return e.Score >= 0 && e.Protocol != "" } + +type pool struct { + mu sync.RWMutex + entries map[string]*poolEntry +} + +func newPool() *pool { return &pool{entries: map[string]*poolEntry{}} } + +func (p *pool) load(cache adapter.CacheFile, tag string) { + if cache == nil { + return + } + saved := cache.LoadBinary(historyKey(tag)) + if saved == nil { + return + } + var dump []*poolEntry + if err := json.Unmarshal(saved.Content, &dump); err != nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + for _, e := range dump { + if e == nil || e.IP == "" { + continue + } + if e.Score < scoreFloor { + e.Score = scoreFloor + } else if e.Score > scoreCap { + e.Score = scoreCap + } + p.entries[e.key()] = e + } +} + +func (p *pool) save(cache adapter.CacheFile, tag string) { + if cache == nil { + return + } + p.mu.RLock() + dump := make([]*poolEntry, 0, len(p.entries)) + for _, e := range p.entries { + dump = append(dump, e) + } + p.mu.RUnlock() + content, err := json.Marshal(dump) + if err != nil { + return + } + _ = cache.SaveBinary(historyKey(tag), &adapter.SavedBinary{ + LastUpdated: time.Now(), + Content: content, + }) +} + +// recordHit resets a negative score to zero before incrementing — same +// rule the dnstt resolver pool uses, so a previously-bad endpoint that +// just came back online climbs the queue without clawing back from -10. +func (p *pool) recordHit(r scanner.Result) { + p.mu.Lock() + defer p.mu.Unlock() + key := joinHostPort(r.IP, r.Port) + e, ok := p.entries[key] + if !ok { + e = &poolEntry{IP: r.IP, Port: r.Port} + p.entries[key] = e + } + if e.Score < 0 { + e.Score = 0 + } + e.Score += scoreHit + if e.Score > scoreCap { + e.Score = scoreCap + } + if r.Protocol != "" { + e.Protocol = r.Protocol + } + if r.LatencyMS > 0 { + e.LatencyMS = r.LatencyMS + } + e.LastCheck = time.Now() +} + +func (p *pool) recordMiss(ip string, port int) { + p.mu.Lock() + defer p.mu.Unlock() + key := joinHostPort(ip, port) + e, ok := p.entries[key] + if !ok { + e = &poolEntry{IP: ip, Port: port} + p.entries[key] = e + } + e.Score -= scoreMiss + if e.Score < scoreFloor { + e.Score = scoreFloor + } + e.LastCheck = time.Now() +} + +func (p *pool) score(ip string, port int) int { + p.mu.RLock() + defer p.mu.RUnlock() + if e, ok := p.entries[joinHostPort(ip, port)]; ok { + return e.Score + } + return 0 +} + +func (p *pool) working(limit int) []poolEntry { + p.mu.RLock() + out := make([]poolEntry, 0, len(p.entries)) + for _, e := range p.entries { + if e.ok() { + out = append(out, *e) + } + } + p.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { + if out[i].Score != out[j].Score { + return out[i].Score > out[j].Score + } + if out[i].LatencyMS != out[j].LatencyMS { + if out[i].LatencyMS == 0 { + return false + } + if out[j].LatencyMS == 0 { + return true + } + return out[i].LatencyMS < out[j].LatencyMS + } + return out[i].key() < out[j].key() + }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out +} + +func (p *pool) workingCount() int { + p.mu.RLock() + defer p.mu.RUnlock() + n := 0 + for _, e := range p.entries { + if e.ok() { + n++ + } + } + return n +} diff --git a/protocol/hiddify/tooska/pool_test.go b/protocol/hiddify/tooska/pool_test.go new file mode 100644 index 0000000000..aea8982c47 --- /dev/null +++ b/protocol/hiddify/tooska/pool_test.go @@ -0,0 +1,114 @@ +package tooska + +import ( + "testing" + + "github.com/sagernet/sing-box/protocol/hiddify/tooska/scanner" +) + +func TestPoolHitClampsAtCeiling(t *testing.T) { + p := newPool() + for i := 0; i < 25; i++ { + p.recordHit(scanner.Result{IP: "1.1.1.1", Port: 80, Protocol: scanner.ProtoSOCKS5}) + } + if got := p.score("1.1.1.1", 80); got != scoreCap { + t.Fatalf("want score=%d, got %d", scoreCap, got) + } +} + +func TestPoolMissClampsAtFloor(t *testing.T) { + p := newPool() + for i := 0; i < 25; i++ { + p.recordMiss("1.1.1.1", 80) + } + if got := p.score("1.1.1.1", 80); got != scoreFloor { + t.Fatalf("want score=%d, got %d", scoreFloor, got) + } +} + +func TestPoolNegativeScoreResetsOnHit(t *testing.T) { + p := newPool() + for i := 0; i < 5; i++ { + p.recordMiss("1.1.1.1", 80) + } + if p.score("1.1.1.1", 80) != -5 { + t.Fatalf("expected -5 after 5 misses, got %d", p.score("1.1.1.1", 80)) + } + p.recordHit(scanner.Result{IP: "1.1.1.1", Port: 80, Protocol: scanner.ProtoSOCKS5}) + if got := p.score("1.1.1.1", 80); got != 1 { + t.Fatalf("want score=1 after reset+hit, got %d", got) + } +} + +func TestPoolScoreUnknownIsZero(t *testing.T) { + p := newPool() + if got := p.score("9.9.9.9", 1234); got != 0 { + t.Fatalf("unknown should default 0, got %d", got) + } +} + +func TestPoolWorkingExcludesNegative(t *testing.T) { + p := newPool() + p.recordHit(scanner.Result{IP: "1.1.1.1", Port: 80, Protocol: scanner.ProtoSOCKS5}) + p.recordMiss("2.2.2.2", 80) + w := p.working(0) + if len(w) != 1 || w[0].IP != "1.1.1.1" { + t.Fatalf("want only 1.1.1.1 as working, got %+v", w) + } +} + +func TestPoolWorkingExcludesNoProtocol(t *testing.T) { + p := newPool() + p.recordMiss("3.3.3.3", 80) + if got := p.workingCount(); got != 0 { + t.Fatalf("want 0 working, got %d", got) + } + p.recordHit(scanner.Result{IP: "3.3.3.3", Port: 80, Protocol: scanner.ProtoHTTP}) + if got := p.workingCount(); got != 1 { + t.Fatalf("want 1 working after hit, got %d", got) + } +} + +func TestPoolWorkingOrderedByScoreThenLatency(t *testing.T) { + p := newPool() + p.recordHit(scanner.Result{IP: "1.1.1.1", Port: 80, Protocol: scanner.ProtoSOCKS5, LatencyMS: 200}) + p.recordHit(scanner.Result{IP: "2.2.2.2", Port: 80, Protocol: scanner.ProtoSOCKS5, LatencyMS: 50}) + p.recordHit(scanner.Result{IP: "2.2.2.2", Port: 80, Protocol: scanner.ProtoSOCKS5, LatencyMS: 50}) + w := p.working(0) + if len(w) != 2 { + t.Fatalf("want 2 working, got %d", len(w)) + } + if w[0].IP != "2.2.2.2" { + t.Fatalf("higher score first; got %s", w[0].IP) + } +} + +func TestPoolWorkingLimitTruncates(t *testing.T) { + p := newPool() + for _, ip := range []string{"1.1.1.1", "2.2.2.2", "3.3.3.3"} { + p.recordHit(scanner.Result{IP: ip, Port: 80, Protocol: scanner.ProtoSOCKS5}) + } + if got := p.working(2); len(got) != 2 { + t.Fatalf("want 2 with limit, got %d", len(got)) + } +} + +func TestScanGateMutualExclusion(t *testing.T) { + var g scanGate + if !g.tryAcquire() { + t.Fatal("first acquire should succeed") + } + if g.tryAcquire() { + t.Fatal("second acquire while held must fail") + } + if !g.busy() { + t.Fatal("busy() should report true while held") + } + g.release() + if g.busy() { + t.Fatal("busy() should report false after release") + } + if !g.tryAcquire() { + t.Fatal("re-acquire after release should succeed") + } +} diff --git a/protocol/hiddify/tooska/scan.go b/protocol/hiddify/tooska/scan.go new file mode 100644 index 0000000000..8c8f0716ba --- /dev/null +++ b/protocol/hiddify/tooska/scan.go @@ -0,0 +1,166 @@ +package tooska + +import ( + "context" + "net" + "net/netip" + "sort" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/protocol/hiddify/tooska/scanner" + E "github.com/sagernet/sing/common/exceptions" +) + +// defaultPorts is the cross-product source for bare IP / CIDR targets +// that don't specify their own ports. +var defaultPorts = []int{1080, 8080, 3128, 8888, 1085, 8118, 8123} + +// maxCandidates caps target expansion so a misconfigured "0.0.0.0/0" +// doesn't OOM the host before the scan ever starts. +const maxCandidates = 1_000_000 + +func parseCandidates(targets []string, ports []int) ([]scanner.Endpoint, error) { + if len(targets) == 0 { + return nil, E.New("tooska: no targets configured") + } + if len(ports) == 0 { + ports = defaultPorts + } + + seen := make(map[string]struct{}) + out := make([]scanner.Endpoint, 0, 64) + + add := func(ep scanner.Endpoint) error { + key := joinHostPort(ep.IP.String(), ep.Port) + if _, ok := seen[key]; ok { + return nil + } + if len(seen) >= maxCandidates { + return E.New("tooska: target expansion exceeds ", maxCandidates, " endpoints; tighten your CIDR/ports") + } + seen[key] = struct{}{} + out = append(out, ep) + return nil + } + + for _, raw := range targets { + t := strings.TrimSpace(raw) + if t == "" { + continue + } + if host, portStr, err := net.SplitHostPort(t); err == nil { + ip, err := netip.ParseAddr(host) + if err != nil { + return nil, E.New("tooska: invalid target ", t, ": ", err) + } + port, err := strconv.Atoi(portStr) + if err != nil || port <= 0 || port > 65535 { + return nil, E.New("tooska: invalid port in target ", t) + } + if err := add(scanner.Endpoint{IP: ip, Port: port}); err != nil { + return nil, err + } + continue + } + if strings.Contains(t, "/") { + prefix, err := netip.ParsePrefix(t) + if err != nil { + return nil, E.New("tooska: invalid CIDR ", t, ": ", err) + } + for ip := prefix.Masked().Addr(); prefix.Contains(ip); ip = ip.Next() { + for _, p := range ports { + if err := add(scanner.Endpoint{IP: ip, Port: p}); err != nil { + return nil, err + } + } + } + continue + } + ip, err := netip.ParseAddr(t) + if err != nil { + return nil, E.New("tooska: invalid target ", t, ": ", err) + } + for _, p := range ports { + if err := add(scanner.Endpoint{IP: ip, Port: p}); err != nil { + return nil, err + } + } + } + + if len(out) == 0 { + return nil, E.New("tooska: target expansion produced zero candidates") + } + return out, nil +} + +func (h *Outbound) orderForScan(in []scanner.Endpoint) []scanner.Endpoint { + out := make([]scanner.Endpoint, len(in)) + copy(out, in) + sort.SliceStable(out, func(i, j int) bool { + return h.pool.score(out[i].IP.String(), out[i].Port) > h.pool.score(out[j].IP.String(), out[j].Port) + }) + return out +} + +func (h *Outbound) runScan(parent context.Context, reason string) { + if !h.scanGate.tryAcquire() { + return + } + defer h.scanGate.release() + + ctx, cancel := context.WithCancel(parent) + defer cancel() + + endpoints := h.orderForScan(h.candidates) + cfg := scanner.Config{ + Endpoints: endpoints, + Concurrency: h.options.Concurrency, + DialTimeout: durationOr(h.options.DialTimeout, 1200*time.Millisecond), + FingerprintTimeout: durationOr(h.options.FingerprintTimeout, time.Second), + ProbeTimeout: durationOr(h.options.ProbeTimeout, 6*time.Second), + UserAgent: h.options.UserAgent, + EmitFailures: true, + } + h.logger.InfoContext(ctx, "tooska scan starting (", reason, "): ", len(endpoints), " candidates, concurrency=", cfg.Concurrency) + + results := scanner.Scan(ctx, cfg) + hits, misses := 0, 0 + for r := range results { + if r.OK() { + h.pool.recordHit(r) + hits++ + h.markStarted() + } else { + h.pool.recordMiss(r.IP, r.Port) + misses++ + } + if h.pool.workingCount() >= h.options.PoolSize { + cancel() + break + } + } + for range results { + } + h.pool.save(h.cache, h.Tag()) + h.logger.InfoContext(ctx, "tooska scan finished (", reason, "): ", hits, " hits / ", misses, " misses, pool=", h.pool.workingCount()) +} + +// scanGate serializes scan invocations. busy() is a lock-free fast path +// for cheap callers like kickRescanOnConnect. +type scanGate struct { + active atomic.Bool +} + +func (g *scanGate) tryAcquire() bool { return g.active.CompareAndSwap(false, true) } +func (g *scanGate) release() { g.active.Store(false) } +func (g *scanGate) busy() bool { return g.active.Load() } + +func joinHostPort(ip string, port int) string { + if strings.Contains(ip, ":") { + return "[" + ip + "]:" + strconv.Itoa(port) + } + return ip + ":" + strconv.Itoa(port) +} diff --git a/protocol/hiddify/tooska/scan_test.go b/protocol/hiddify/tooska/scan_test.go new file mode 100644 index 0000000000..5079191157 --- /dev/null +++ b/protocol/hiddify/tooska/scan_test.go @@ -0,0 +1,154 @@ +package tooska + +import ( + "strings" + "testing" +) + +func TestParseCandidatesBareIP(t *testing.T) { + got, err := parseCandidates([]string{"1.2.3.4"}, []int{1080, 8080}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 candidates, got %d", len(got)) + } + if got[0].IP.String() != "1.2.3.4" || got[1].IP.String() != "1.2.3.4" { + t.Fatalf("ip mismatch: %#v", got) + } + ports := map[int]bool{got[0].Port: true, got[1].Port: true} + if !ports[1080] || !ports[8080] { + t.Fatalf("ports mismatch: %#v", got) + } +} + +func TestParseCandidatesIPPortPin(t *testing.T) { + got, err := parseCandidates([]string{"1.2.3.4:9999"}, []int{1080, 8080}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 1 { + t.Fatalf("want exactly 1 candidate, got %d", len(got)) + } + if got[0].Port != 9999 { + t.Fatalf("want port 9999, got %d", got[0].Port) + } +} + +func TestParseCandidatesCIDRExpansion(t *testing.T) { + got, err := parseCandidates([]string{"10.0.0.0/30"}, []int{1080}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 4 { + t.Fatalf("want 4 candidates, got %d", len(got)) + } +} + +func TestParseCandidatesDedup(t *testing.T) { + got, err := parseCandidates( + []string{"1.2.3.4", "1.2.3.4:1080", "1.2.3.4"}, + []int{1080}, + ) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 1 { + t.Fatalf("want 1 deduped candidate, got %d", len(got)) + } +} + +func TestParseCandidatesIPv6Bracketed(t *testing.T) { + got, err := parseCandidates([]string{"[::1]:1080"}, nil) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 1 || got[0].Port != 1080 { + t.Fatalf("ipv6 parse failed: %#v", got) + } + if got[0].IP.String() != "::1" { + t.Fatalf("ipv6 addr mismatch: %s", got[0].IP) + } +} + +func TestParseCandidatesIPv6Bare(t *testing.T) { + got, err := parseCandidates([]string{"::1"}, []int{1080}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 1 { + t.Fatalf("want 1 candidate, got %d", len(got)) + } +} + +func TestParseCandidatesDefaultPorts(t *testing.T) { + got, err := parseCandidates([]string{"1.2.3.4"}, nil) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != len(defaultPorts) { + t.Fatalf("want %d default ports, got %d", len(defaultPorts), len(got)) + } +} + +func TestParseCandidatesEmptyTargets(t *testing.T) { + if _, err := parseCandidates(nil, []int{1080}); err == nil { + t.Fatal("want error for empty targets") + } +} + +func TestParseCandidatesInvalidIP(t *testing.T) { + if _, err := parseCandidates([]string{"not-an-ip"}, []int{1080}); err == nil { + t.Fatal("want error for invalid ip") + } +} + +func TestParseCandidatesInvalidCIDR(t *testing.T) { + if _, err := parseCandidates([]string{"10.0.0.0/77"}, []int{1080}); err == nil { + t.Fatal("want error for invalid cidr") + } +} + +func TestParseCandidatesPortOutOfRange(t *testing.T) { + if _, err := parseCandidates([]string{"1.2.3.4:0"}, nil); err == nil { + t.Fatal("want error for port=0") + } + if _, err := parseCandidates([]string{"1.2.3.4:99999"}, nil); err == nil { + t.Fatal("want error for port=99999") + } +} + +func TestParseCandidatesCapEnforced(t *testing.T) { + if _, err := parseCandidates([]string{"10.0.0.0/16"}, nil); err != nil { + t.Fatalf("unexpected err for /16: %v", err) + } + _, err := parseCandidates([]string{"10.0.0.0/8"}, nil) + if err == nil { + t.Fatal("want cap error for /8 sweep") + } + if !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("want cap error message, got %v", err) + } +} + +func TestParseCandidatesTrimsAndSkipsEmpty(t *testing.T) { + got, err := parseCandidates([]string{" 1.2.3.4 ", "", " "}, []int{1080}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != 1 { + t.Fatalf("want 1 candidate, got %d", len(got)) + } +} + +func TestJoinHostPortIPv4(t *testing.T) { + if got := joinHostPort("1.2.3.4", 80); got != "1.2.3.4:80" { + t.Fatalf("ipv4 join: got %q", got) + } +} + +func TestJoinHostPortIPv6(t *testing.T) { + if got := joinHostPort("::1", 80); got != "[::1]:80" { + t.Fatalf("ipv6 join: got %q", got) + } +} diff --git a/protocol/hiddify/tooska/scanner/dialer.go b/protocol/hiddify/tooska/scanner/dialer.go new file mode 100644 index 0000000000..e36eb5ad6c --- /dev/null +++ b/protocol/hiddify/tooska/scanner/dialer.go @@ -0,0 +1,42 @@ +package scanner + +import ( + "context" + "net" + "strconv" + "time" +) + +func dialFast(ctx context.Context, ep Endpoint, timeout time.Duration) (net.Conn, error) { + addr := net.JoinHostPort(ep.IP.String(), strconv.Itoa(ep.Port)) + d := &net.Dialer{ + Timeout: timeout, + KeepAlive: -1, + } + dctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + conn, err := d.DialContext(dctx, "tcp", addr) + if err != nil { + return nil, err + } + if tc, ok := conn.(*net.TCPConn); ok { + _ = tc.SetNoDelay(true) + _ = tc.SetLinger(0) + } + return conn, nil +} + +func armCancel(ctx context.Context, conn net.Conn) func() { + if ctx.Done() == nil { + return func() {} + } + stop := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = conn.Close() + case <-stop: + } + }() + return func() { close(stop) } +} diff --git a/protocol/hiddify/tooska/scanner/fingerprint.go b/protocol/hiddify/tooska/scanner/fingerprint.go new file mode 100644 index 0000000000..8ca30e3423 --- /dev/null +++ b/protocol/hiddify/tooska/scanner/fingerprint.go @@ -0,0 +1,42 @@ +package scanner + +import ( + "bytes" + "context" + "net" + "time" +) + +type fingerprintVerdict int + +const ( + fpTLS fingerprintVerdict = iota + fpSOCKS5 + fpHTTP + fpAmbiguous +) + +var socks5Greeting = []byte{0x05, 0x01, 0x00} + +func fingerprint(ctx context.Context, conn net.Conn, timeout time.Duration) fingerprintVerdict { + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write(socks5Greeting); err != nil { + return fpAmbiguous + } + buf := make([]byte, 8) + n, _ := conn.Read(buf) + return classifyFingerprint(buf[:n]) +} + +func classifyFingerprint(buf []byte) fingerprintVerdict { + if len(buf) >= 2 && buf[0] == 0x05 && buf[1] == 0x00 { + return fpSOCKS5 + } + if bytes.HasPrefix(buf, []byte("HTTP/")) { + return fpHTTP + } + if len(buf) >= 1 && (buf[0] == 0x15 || buf[0] == 0x16) { + return fpTLS + } + return fpAmbiguous +} diff --git a/protocol/hiddify/tooska/scanner/http.go b/protocol/hiddify/tooska/scanner/http.go new file mode 100644 index 0000000000..b8e20a7ab4 --- /dev/null +++ b/protocol/hiddify/tooska/scanner/http.go @@ -0,0 +1,77 @@ +package scanner + +import ( + "bytes" + "context" + "errors" + "fmt" + "time" +) + +const httpReadLimit = 8192 + +const ( + httpProbeHost = "example.com" + httpProbePath = "/" +) + +var httpSignature = []byte("Example Domain") + +var httpSoftSignatures = [][]byte{ + []byte("cf-ray:"), + []byte("server: cloudflare"), + []byte("x-cache:"), +} + +func probeHTTP(ctx context.Context, cfg Config, ep Endpoint) (latencyMS int64, err error) { + start := time.Now() + + conn, err := dialFast(ctx, ep, cfg.DialTimeout) + if err != nil { + return 0, err + } + defer conn.Close() + release := armCancel(ctx, conn) + defer release() + + _ = conn.SetDeadline(time.Now().Add(cfg.ProbeTimeout)) + req := buildHTTPProxyRequest(cfg.UserAgent) + if _, err := conn.Write(req); err != nil { + return 0, fmt.Errorf("write: %w", err) + } + + buf, err := readBounded(conn, httpReadLimit) + if err != nil && len(buf) == 0 { + return 0, fmt.Errorf("read: %w", err) + } + if len(buf) == 0 { + return 0, errors.New("empty response") + } + + if bytes.Contains(buf, httpSignature) { + return time.Since(start).Milliseconds(), nil + } + + is200 := bytes.HasPrefix(buf, []byte("HTTP/1.1 200")) || bytes.HasPrefix(buf, []byte("HTTP/1.0 200")) + if is200 { + lowerBuf := bytes.ToLower(buf) + for _, sig := range httpSoftSignatures { + if bytes.Contains(lowerBuf, sig) { + return time.Since(start).Milliseconds(), nil + } + } + } + + return 0, fmt.Errorf("response rejected (no signature): %q", firstLineBytes(buf)) +} + +func buildHTTPProxyRequest(ua string) []byte { + return []byte( + "GET http://" + httpProbeHost + httpProbePath + " HTTP/1.1\r\n" + + "Host: " + httpProbeHost + "\r\n" + + "User-Agent: " + ua + "\r\n" + + "Accept: text/html\r\n" + + "Accept-Encoding: identity\r\n" + + "Connection: close\r\n\r\n", + ) +} diff --git a/protocol/hiddify/tooska/scanner/probe.go b/protocol/hiddify/tooska/scanner/probe.go new file mode 100644 index 0000000000..839166400c --- /dev/null +++ b/protocol/hiddify/tooska/scanner/probe.go @@ -0,0 +1,88 @@ +package scanner + +import ( + "context" + "time" +) + +func probeEndpoint(ctx context.Context, cfg Config, ep Endpoint) []Result { + base := Result{ + IP: ep.IP.String(), + Port: ep.Port, + CheckedAt: time.Now().UTC(), + } + + if err := ctx.Err(); err != nil { + base.Error = err.Error() + return []Result{base} + } + + lanes, needFP := lanesForPort(cfg, ep.Port) + if needFP { + fp, err := fingerprintEndpoint(ctx, cfg, ep) + if err != nil { + base.Error = "fingerprint: " + err.Error() + return []Result{base} + } + switch fp { + case fpSOCKS5: + lanes = []Protocol{ProtoSOCKS5, ProtoHTTP} + case fpHTTP: + lanes = []Protocol{ProtoHTTP, ProtoSOCKS5} + case fpAmbiguous: + lanes = []Protocol{ProtoHTTP, ProtoSOCKS5} + default: + base.Error = "fingerprint: drop (tls server)" + return []Result{base} + } + } + + var results []Result + var lastErr string + for _, lane := range lanes { + if ctx.Err() != nil { + base.Error = ctx.Err().Error() + return []Result{base} + } + var ( + lat int64 + err error + ) + switch lane { + case ProtoSOCKS5: + lat, err = probeSOCKS5(ctx, cfg, ep) + case ProtoHTTP: + lat, err = probeHTTP(ctx, cfg, ep) + default: + continue + } + if err == nil { + r := base + r.Protocol = lane + r.LatencyMS = lat + results = append(results, r) + } else { + lastErr = string(lane) + ": " + err.Error() + } + } + + if len(results) > 0 { + return results + } + if lastErr == "" { + lastErr = "no lane validated" + } + base.Error = lastErr + return []Result{base} +} + +func fingerprintEndpoint(ctx context.Context, cfg Config, ep Endpoint) (fingerprintVerdict, error) { + conn, err := dialFast(ctx, ep, cfg.DialTimeout) + if err != nil { + return fpTLS, err + } + defer conn.Close() + release := armCancel(ctx, conn) + defer release() + return fingerprint(ctx, conn, cfg.FingerprintTimeout), nil +} diff --git a/protocol/hiddify/tooska/scanner/scanner.go b/protocol/hiddify/tooska/scanner/scanner.go new file mode 100644 index 0000000000..b0b76e3d1d --- /dev/null +++ b/protocol/hiddify/tooska/scanner/scanner.go @@ -0,0 +1,149 @@ +// Package scanner is a high-throughput, dependency-free TCP proxy scanner +// embedded into the Hiddify sing-box core. It vendors the goBrrrr design: +// a pure-Go TCP connect sweep with a 3-byte Tier-C fingerprint and a deep +// SOCKS5 / HTTP validation step. Results stream back over a channel and +// the worker pool is bounded so it stays safe on FD-constrained devices. +package scanner + +import ( + "context" + "errors" + "net/netip" + "sync" + "time" +) + +type Protocol string + +const ( + ProtoSOCKS5 Protocol = "socks5" + ProtoHTTP Protocol = "http" +) + +type Endpoint struct { + IP netip.Addr + Port int +} + +type Result struct { + IP string `json:"ip"` + Port int `json:"port"` + Protocol Protocol `json:"protocol,omitempty"` + LatencyMS int64 `json:"latency_ms,omitempty"` + CheckedAt time.Time `json:"checked_at"` + Error string `json:"error,omitempty"` +} + +func (r Result) OK() bool { return r.Error == "" && r.Protocol != "" } + +type Config struct { + Endpoints []Endpoint + Concurrency int + DialTimeout time.Duration + FingerprintTimeout time.Duration + ProbeTimeout time.Duration + PortLanes map[int][]Protocol + UserAgent string + EmitFailures bool + OnProgress func(completed, total int) +} + +func (c Config) normalize() Config { + if c.Concurrency <= 0 { + c.Concurrency = 256 + } + if c.DialTimeout <= 0 { + c.DialTimeout = 1200 * time.Millisecond + } + if c.FingerprintTimeout <= 0 { + c.FingerprintTimeout = time.Second + } + if c.ProbeTimeout <= 0 { + c.ProbeTimeout = 6 * time.Second + } + if c.UserAgent == "" { + c.UserAgent = "tooska/1.0" + } + return c +} + +var ErrNoEndpoints = errors.New("scanner: no endpoints to scan") + +func Scan(ctx context.Context, cfg Config) <-chan Result { + cfg = cfg.normalize() + out := make(chan Result, 64) + if len(cfg.Endpoints) == 0 { + close(out) + return out + } + if ctx == nil { + ctx = context.Background() + } + go runScan(ctx, cfg, out) + return out +} + +func runScan(ctx context.Context, cfg Config, out chan<- Result) { + defer close(out) + + total := len(cfg.Endpoints) + jobs := make(chan Endpoint) + var wg sync.WaitGroup + var done int64 + var doneMu sync.Mutex + + emit := func(r Result) { + select { + case out <- r: + case <-ctx.Done(): + } + } + + tickProgress := func() { + if cfg.OnProgress == nil { + return + } + doneMu.Lock() + done++ + n := int(done) + doneMu.Unlock() + cfg.OnProgress(n, total) + } + + workers := cfg.Concurrency + if workers > total { + workers = total + } + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for ep := range jobs { + if ctx.Err() != nil { + tickProgress() + continue + } + for _, res := range probeEndpoint(ctx, cfg, ep) { + if res.OK() || cfg.EmitFailures { + emit(res) + } + } + tickProgress() + } + }() + } + + feed := func() { + defer close(jobs) + for _, ep := range cfg.Endpoints { + select { + case jobs <- ep: + case <-ctx.Done(): + return + } + } + } + go feed() + + wg.Wait() +} diff --git a/protocol/hiddify/tooska/scanner/socks5.go b/protocol/hiddify/tooska/scanner/socks5.go new file mode 100644 index 0000000000..c36d1dbeff --- /dev/null +++ b/protocol/hiddify/tooska/scanner/socks5.go @@ -0,0 +1,161 @@ +package scanner + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "io" + "time" +) + +const ( + socksTLSHost = "www.google.com" + socksTLSPort = 443 +) + +var socksTLSRequest = []byte("GET /generate_204 HTTP/1.1\r\n" + + "Host: www.google.com\r\n" + + "User-Agent: Mozilla/5.0\r\n" + + "Connection: close\r\n\r\n") + +var socksTLSConfig = &tls.Config{ + ServerName: socksTLSHost, + MinVersion: tls.VersionTLS12, + NextProtos: []string{"http/1.1"}, +} + +func probeSOCKS5(ctx context.Context, cfg Config, ep Endpoint) (latencyMS int64, err error) { + start := time.Now() + + tConnect := durationFraction(cfg.ProbeTimeout, 0.40) + tTLS := durationFraction(cfg.ProbeTimeout, 0.40) + tData := durationFraction(cfg.ProbeTimeout, 0.20) + + conn, err := dialFast(ctx, ep, cfg.DialTimeout) + if err != nil { + return 0, err + } + defer conn.Close() + release := armCancel(ctx, conn) + defer release() + + _ = conn.SetDeadline(time.Now().Add(tConnect)) + if _, err := conn.Write(socks5Greeting); err != nil { + return 0, fmt.Errorf("greet: %w", err) + } + authReply := make([]byte, 2) + if _, err := io.ReadFull(conn, authReply); err != nil { + return 0, fmt.Errorf("greet read: %w", err) + } + if authReply[0] != 0x05 || authReply[1] != 0x00 { + return 0, errors.New("socks5 auth method rejected") + } + + host := []byte(socksTLSHost) + req := make([]byte, 0, 7+len(host)) + req = append(req, 0x05, 0x01, 0x00, 0x03, byte(len(host))) + req = append(req, host...) + port := make([]byte, 2) + binary.BigEndian.PutUint16(port, uint16(socksTLSPort)) + req = append(req, port...) + if _, err := conn.Write(req); err != nil { + return 0, fmt.Errorf("connect: %w", err) + } + + head := make([]byte, 4) + if _, err := io.ReadFull(conn, head); err != nil { + return 0, fmt.Errorf("connect head: %w", err) + } + if head[0] != 0x05 { + return 0, fmt.Errorf("bad version 0x%02x", head[0]) + } + if head[1] != 0x00 { + return 0, fmt.Errorf("connect rep=0x%02x", head[1]) + } + + switch head[3] { + case 0x01: + if _, err := io.ReadFull(conn, make([]byte, 4+2)); err != nil { + return 0, fmt.Errorf("bnd v4: %w", err) + } + case 0x03: + l := make([]byte, 1) + if _, err := io.ReadFull(conn, l); err != nil { + return 0, fmt.Errorf("bnd dom-len: %w", err) + } + if _, err := io.ReadFull(conn, make([]byte, int(l[0])+2)); err != nil { + return 0, fmt.Errorf("bnd dom: %w", err) + } + case 0x04: + if _, err := io.ReadFull(conn, make([]byte, 16+2)); err != nil { + return 0, fmt.Errorf("bnd v6: %w", err) + } + default: + return 0, fmt.Errorf("unsupported atyp 0x%02x", head[3]) + } + + _ = conn.SetDeadline(time.Now().Add(tTLS)) + tlsConn := tls.Client(conn, socksTLSConfig) + hsCtx, hsCancel := context.WithTimeout(ctx, tTLS) + if err := tlsConn.HandshakeContext(hsCtx); err != nil { + hsCancel() + return 0, fmt.Errorf("tls handshake: %w", err) + } + hsCancel() + + _ = tlsConn.SetDeadline(time.Now().Add(tData)) + if _, err := tlsConn.Write(socksTLSRequest); err != nil { + return 0, fmt.Errorf("inner write: %w", err) + } + resp, err := readBounded(tlsConn, 256) + if err != nil && len(resp) == 0 { + return 0, fmt.Errorf("inner read: %w", err) + } + if !bytes.HasPrefix(resp, []byte("HTTP/1.1 204")) && + !bytes.HasPrefix(resp, []byte("HTTP/1.0 204")) && + !bytes.HasPrefix(resp, []byte("HTTP/1.1 200")) && + !bytes.HasPrefix(resp, []byte("HTTP/1.0 200")) { + return 0, fmt.Errorf("inner status not 2xx: %q", firstLineBytes(resp)) + } + + return time.Since(start).Milliseconds(), nil +} + +func durationFraction(d time.Duration, frac float64) time.Duration { + if d <= 0 { + return 0 + } + return time.Duration(float64(d) * frac) +} + +func firstLineBytes(b []byte) string { + if i := bytes.IndexByte(b, '\n'); i >= 0 { + return string(bytes.TrimRight(b[:i], "\r")) + } + return string(b) +} + +func readBounded(r io.Reader, limit int) ([]byte, error) { + buf := make([]byte, 0, limit) + chunk := make([]byte, 512) + for len(buf) < limit { + n, err := r.Read(chunk) + if n > 0 { + remaining := limit - len(buf) + if n > remaining { + n = remaining + } + buf = append(buf, chunk[:n]...) + } + if err != nil { + if errors.Is(err, io.EOF) { + return buf, nil + } + return buf, err + } + } + return buf, nil +} diff --git a/protocol/hiddify/tooska/scanner/tiers.go b/protocol/hiddify/tooska/scanner/tiers.go new file mode 100644 index 0000000000..d42298187d --- /dev/null +++ b/protocol/hiddify/tooska/scanner/tiers.go @@ -0,0 +1,21 @@ +package scanner + +var defaultPortLanes = map[int][]Protocol{ + 9050: {ProtoSOCKS5}, + 9051: {ProtoSOCKS5}, + + 8000: {ProtoHTTP}, + 8123: {ProtoHTTP}, +} + +func lanesForPort(cfg Config, port int) (lanes []Protocol, needFingerprint bool) { + if cfg.PortLanes != nil { + if v, ok := cfg.PortLanes[port]; ok && len(v) > 0 { + return v, false + } + } + if v, ok := defaultPortLanes[port]; ok { + return v, false + } + return nil, true +}