Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions constant/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const (
TypeAwg = "awg" //H
TypeBalancer = "balancer" //H
TypeDNSTT = "dnstt" //H
TypeTooska = "tooska" //H
)

const (
Expand Down Expand Up @@ -131,6 +132,8 @@ func ProxyDisplayName(proxyType string) string {
return "Balancer"
case TypeDNSTT:
return "DNSTT"
case TypeTooska:
return "Tooska"
default:
return "Unknown"
}
Expand Down
2 changes: 2 additions & 0 deletions include/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions option/tooska.go
Original file line number Diff line number Diff line change
@@ -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"`
}
180 changes: 180 additions & 0 deletions protocol/hiddify/tooska/dial.go
Original file line number Diff line number Diff line change
@@ -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")
}
136 changes: 136 additions & 0 deletions protocol/hiddify/tooska/outbound.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading