354 lines
11 KiB
Go
354 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"dragontcp/internal/cover"
|
|
"dragontcp/internal/xorchunk"
|
|
)
|
|
|
|
// DragonTCP speaks three wires that are not interchangeable:
|
|
//
|
|
// b — compact binary records (29/5-byte headers, clear or SHA-256-compatible payloads)
|
|
// bp — compatible registration/upload/download/ACK records, clear or SHA-256-compatible
|
|
// x — legacy UP/OK framing with XOR 0xAD over ASCII chunk commands
|
|
//
|
|
// Networks differ in which they pass, so the client can be pinned to either or
|
|
// left on auto, which decides by actually fetching a URL through each wire and
|
|
// keeping the first that answers.
|
|
const (
|
|
WireBinary = "b"
|
|
WireBP = "bp"
|
|
WireXOR = "x"
|
|
WireAuto = "auto"
|
|
)
|
|
|
|
// probeTarget is fetched through a candidate wire to decide whether it works.
|
|
// A plain HTTP host is used deliberately: it exercises OPEN, upload and
|
|
// download in one go, and a valid status line proves bytes survived intact.
|
|
const (
|
|
probeHost = "ip.dr2.site"
|
|
probePort = 80
|
|
probeTimeout = 8 * time.Second
|
|
)
|
|
|
|
type wireSelector struct {
|
|
mu sync.Mutex
|
|
configured string // b, x or auto
|
|
resolved wireChoice
|
|
hasChoice bool
|
|
serverAddr string
|
|
token string
|
|
binOpts chunkClientOptions
|
|
xorOpts xorchunk.Options
|
|
probeDelay time.Duration
|
|
probeThreads int
|
|
|
|
// Test hooks are nil in production.
|
|
candidateOverride []wireChoice
|
|
probeOverride func(wireChoice) bool
|
|
}
|
|
|
|
type wireChoice struct {
|
|
mode string
|
|
mask byte
|
|
cover cover.Profile
|
|
}
|
|
|
|
func (c wireChoice) String() string {
|
|
if c.mode == WireBP {
|
|
if c.cover.Enabled {
|
|
return fmt.Sprintf("bp/%s", c.cover)
|
|
}
|
|
return "bp/direct"
|
|
}
|
|
if c.cover.Enabled {
|
|
return fmt.Sprintf("%s/mask-%02x/%s", c.mode, c.mask, c.cover)
|
|
}
|
|
return fmt.Sprintf("%s/mask-%02x/direct", c.mode, c.mask)
|
|
}
|
|
|
|
func newWireSelector(configured, serverAddr, token string, binOpts chunkClientOptions, xorOpts xorchunk.Options, probeDelay time.Duration, probeThreads int) *wireSelector {
|
|
s := &wireSelector{
|
|
configured: configured,
|
|
serverAddr: serverAddr,
|
|
token: token,
|
|
binOpts: binOpts,
|
|
xorOpts: xorOpts,
|
|
probeDelay: probeDelay,
|
|
probeThreads: probeThreads,
|
|
}
|
|
return s
|
|
}
|
|
|
|
// dial opens a tunnel over the active wire, resolving the wire first if needed.
|
|
func (s *wireSelector) dial(host string, port int) (net.Conn, error) {
|
|
choice := s.mode()
|
|
if choice.mode == WireBP {
|
|
opts := s.binOpts
|
|
opts.headerMask = choice.mask
|
|
opts.coverProfile = choice.cover
|
|
return openBPTunnel(s.serverAddr, s.token, host, port, opts)
|
|
}
|
|
if choice.mode == WireXOR {
|
|
if choice.cover.Enabled {
|
|
return xorchunk.Open(s.serverAddr, s.token, host, port, s.xorOpts.WithCoverProfile(choice.cover))
|
|
}
|
|
return xorchunk.Open(s.serverAddr, s.token, host, port, s.xorOpts.WithHeaderMask(choice.mask))
|
|
}
|
|
opts := s.binOpts
|
|
opts.headerMask = choice.mask
|
|
opts.coverProfile = choice.cover
|
|
return openChunkTunnel(s.serverAddr, s.token, host, port, opts)
|
|
}
|
|
|
|
// mode returns the wire to use, running detection once if configured as auto.
|
|
// Detection failure is not cached, so a client that starts before the network
|
|
// is usable retries on the next connection instead of latching a bad guess.
|
|
func (s *wireSelector) mode() wireChoice {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.hasChoice {
|
|
return s.resolved
|
|
}
|
|
if picked, ok := s.detectLocked(); ok {
|
|
s.resolved = picked
|
|
s.hasChoice = true
|
|
return picked
|
|
}
|
|
// Undecided: honor an explicitly pinned family for this attempt without
|
|
// caching it. Auto retains the original B fallback and retries discovery on
|
|
// the next connection.
|
|
switch s.configured {
|
|
case WireBP:
|
|
return wireChoice{mode: WireBP}
|
|
case WireXOR:
|
|
return wireChoice{mode: WireXOR}
|
|
default:
|
|
return wireChoice{mode: WireBinary}
|
|
}
|
|
}
|
|
|
|
// profileCandidates covers all compatible B first-byte bases and all X magic
|
|
// masks that cannot be confused with B. Profile zero for each wire is first so
|
|
// existing permissive networks complete discovery quickly.
|
|
func (s *wireSelector) profileCandidates() []wireChoice {
|
|
var binaryProfiles []wireChoice
|
|
var xorProfiles []wireChoice
|
|
if s.configured == WireAuto || s.configured == WireBinary {
|
|
for n := 0; n < 256; n += 8 {
|
|
binaryProfiles = append(binaryProfiles, wireChoice{mode: WireBinary, mask: byte(n)})
|
|
}
|
|
}
|
|
if s.configured == WireAuto || s.configured == WireXOR {
|
|
for n := 0; n < 256; n++ {
|
|
mask := byte(n)
|
|
if ('U'^mask)&7 >= 5 {
|
|
xorProfiles = append(xorProfiles, wireChoice{mode: WireXOR, mask: mask})
|
|
}
|
|
}
|
|
}
|
|
|
|
paddingRange := []uint16{0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 768, 1024, 1400, 2048, 4096}
|
|
makeCovered := func(n int, xor, clear bool) cover.Profile {
|
|
first := byte(n)
|
|
second := byte(n*197 + 101)
|
|
mask := byte(n*149 + 37)
|
|
return cover.Profile{
|
|
Enabled: true,
|
|
ID: uint16(first)<<8 | uint16(second),
|
|
Padding: paddingRange[n%len(paddingRange)],
|
|
HeaderMask: mask,
|
|
XOR: xor,
|
|
Clear: clear,
|
|
}
|
|
}
|
|
|
|
// New peers try the clear-payload profile first. The next candidates are
|
|
// legacy direct profiles, so an older server falls back immediately instead
|
|
// of screening the complete expanded profile range.
|
|
out := make([]wireChoice, 0, len(binaryProfiles)+len(xorProfiles)+1025)
|
|
if s.configured == WireAuto || s.configured == WireBinary {
|
|
profile := makeCovered(0, false, true)
|
|
out = append(out, wireChoice{mode: WireBinary, mask: profile.HeaderMask, cover: profile})
|
|
}
|
|
if s.configured == WireAuto || s.configured == WireBP {
|
|
profile := makeCovered(0, false, true)
|
|
out = append(out, wireChoice{mode: WireBP, mask: profile.HeaderMask, cover: profile})
|
|
}
|
|
|
|
// Interleave formats so neither family can consume the entire discovery
|
|
// window before the other one gets a chance.
|
|
for i := 0; i < len(binaryProfiles) || i < len(xorProfiles); i++ {
|
|
if i < len(binaryProfiles) {
|
|
out = append(out, binaryProfiles[i])
|
|
}
|
|
if i < len(xorProfiles) {
|
|
out = append(out, xorProfiles[i])
|
|
}
|
|
if i == 0 && s.configured == WireAuto {
|
|
out = append(out, wireChoice{mode: WireBP})
|
|
}
|
|
}
|
|
if s.configured == WireBP {
|
|
out = append(out, wireChoice{mode: WireBP})
|
|
}
|
|
|
|
// Covered profiles expand discovery beyond the one-byte direct formats
|
|
// without taking the Cartesian product (which would create thousands of
|
|
// connections). Across this distributed range each wire still exercises all
|
|
// 256 first bytes, all 256 frame masks, and every padding length repeatedly.
|
|
for n := 0; n < 256; n++ {
|
|
if s.configured == WireAuto || s.configured == WireBinary {
|
|
profile := makeCovered(n, false, false)
|
|
out = append(out, wireChoice{mode: WireBinary, mask: profile.HeaderMask, cover: profile})
|
|
if n != 0 {
|
|
profile = makeCovered(n, false, true)
|
|
out = append(out, wireChoice{mode: WireBinary, mask: profile.HeaderMask, cover: profile})
|
|
}
|
|
}
|
|
if s.configured == WireAuto || s.configured == WireBP {
|
|
if n != 0 {
|
|
profile := makeCovered(n, false, true)
|
|
out = append(out, wireChoice{mode: WireBP, mask: profile.HeaderMask, cover: profile})
|
|
}
|
|
}
|
|
if s.configured == WireAuto || s.configured == WireXOR {
|
|
profile := makeCovered(n, true, false)
|
|
out = append(out, wireChoice{mode: WireXOR, mask: profile.HeaderMask, cover: profile})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// detectLocked validates candidates with real HTTP traffic through ip.dr2.site.
|
|
// The default is one worker. Users may explicitly allow more workers, while the
|
|
// launch delay still spaces new attempts globally to avoid a connection burst.
|
|
func (s *wireSelector) detectLocked() (wireChoice, bool) {
|
|
candidates := s.profileCandidates()
|
|
if s.candidateOverride != nil {
|
|
candidates = s.candidateOverride
|
|
}
|
|
threads := s.probeThreads
|
|
if threads < 1 {
|
|
threads = 1
|
|
}
|
|
if threads > 16 {
|
|
threads = 16
|
|
}
|
|
delay := s.probeDelay
|
|
if delay <= 0 {
|
|
delay = time.Second
|
|
}
|
|
type result struct {
|
|
choice wireChoice
|
|
ok bool
|
|
}
|
|
results := make(chan result, threads)
|
|
next := 0
|
|
inflight := 0
|
|
completed := 0
|
|
started := time.Now()
|
|
var lastLaunch time.Time
|
|
for next < len(candidates) || inflight > 0 {
|
|
canLaunch := next < len(candidates) && inflight < threads
|
|
if canLaunch && (lastLaunch.IsZero() || time.Since(lastLaunch) >= delay) {
|
|
candidate := candidates[next]
|
|
next++
|
|
inflight++
|
|
lastLaunch = time.Now()
|
|
go func(choice wireChoice) {
|
|
validated := false
|
|
if s.probeOverride != nil {
|
|
validated = s.probeOverride(choice)
|
|
} else {
|
|
validated = s.probe(choice)
|
|
}
|
|
results <- result{choice: choice, ok: validated}
|
|
}(candidate)
|
|
continue
|
|
}
|
|
|
|
var got result
|
|
if canLaunch {
|
|
wait := delay - time.Since(lastLaunch)
|
|
timer := time.NewTimer(wait)
|
|
select {
|
|
case got = <-results:
|
|
if !timer.Stop() {
|
|
select {
|
|
case <-timer.C:
|
|
default:
|
|
}
|
|
}
|
|
case <-timer.C:
|
|
continue
|
|
}
|
|
} else {
|
|
got = <-results
|
|
}
|
|
inflight--
|
|
completed++
|
|
if got.ok {
|
|
fmt.Printf("wire probe: selected=%s completed=%d launched=%d elapsed=%s target=http://%s/ validated=true threads=%d fixed_until_restart=true\n", got.choice, completed, next, time.Since(started).Round(time.Millisecond), probeHost, threads)
|
|
return got.choice, true
|
|
}
|
|
if completed%32 == 0 {
|
|
fmt.Printf("wire probe: completed=%d/%d launched=%d elapsed=%s target=http://%s/ no validated profile yet\n", completed, len(candidates), next, time.Since(started).Round(time.Millisecond), probeHost)
|
|
}
|
|
}
|
|
fmt.Printf("wire probe: no profile validated through http://%s/ after %d candidates in %s; retrying later\n", probeHost, completed, time.Since(started).Round(time.Millisecond))
|
|
return wireChoice{}, false
|
|
}
|
|
|
|
// probe fetches probeHost through one wire and reports whether a well-formed
|
|
// HTTP status line came back.
|
|
func (s *wireSelector) probe(choice wireChoice) bool {
|
|
var (
|
|
conn net.Conn
|
|
err error
|
|
)
|
|
if choice.mode == WireXOR {
|
|
if choice.cover.Enabled {
|
|
conn, err = xorchunk.Open(s.serverAddr, s.token, probeHost, probePort, s.xorOpts.WithCoverProfile(choice.cover))
|
|
} else {
|
|
conn, err = xorchunk.Open(s.serverAddr, s.token, probeHost, probePort, s.xorOpts.WithHeaderMask(choice.mask))
|
|
}
|
|
} else if choice.mode == WireBP {
|
|
opts := s.binOpts
|
|
opts.headerMask = choice.mask
|
|
opts.coverProfile = choice.cover
|
|
opts.skipPathProbe = true
|
|
opts.minSize = 32
|
|
opts.startSize = 32
|
|
opts.maxSize = 32
|
|
conn, err = openBPTunnel(s.serverAddr, s.token, probeHost, probePort, opts)
|
|
} else {
|
|
opts := s.binOpts
|
|
opts.headerMask = choice.mask
|
|
opts.coverProfile = choice.cover
|
|
opts.skipPathProbe = true
|
|
opts.minSize = 32
|
|
opts.startSize = 32
|
|
opts.maxSize = 32
|
|
conn, err = openChunkTunnel(s.serverAddr, s.token, probeHost, probePort, opts)
|
|
}
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(time.Now().Add(probeTimeout))
|
|
|
|
request := "GET / HTTP/1.1\r\nHost: " + probeHost + "\r\nUser-Agent: dragontcp\r\nConnection: close\r\n\r\n"
|
|
if _, err := conn.Write([]byte(request)); err != nil {
|
|
return false
|
|
}
|
|
statusLine, err := bufio.NewReader(conn).ReadString('\n')
|
|
return err == nil && strings.HasPrefix(statusLine, "HTTP/")
|
|
}
|