439 lines
14 KiB
Go
439 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
|
|
"dragontcp/internal/cover"
|
|
"dragontcp/internal/wire"
|
|
"dragontcp/internal/xorchunk"
|
|
)
|
|
|
|
// DragonTCP speaks three wires that are not interchangeable:
|
|
//
|
|
// b — compact binary records (29/5-byte headers, SHA-256-masked payloads by default)
|
|
// bp — compatible registration/upload/download/ACK records, SHA-256-masked by default
|
|
// 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. Startup performs a tiny server-local profile probe and caches
|
|
// the first wire/header profile that survives the carrier.
|
|
const (
|
|
WireBinary = "b"
|
|
WireBP = "bp"
|
|
WireXOR = "x"
|
|
WireAuto = "auto"
|
|
)
|
|
|
|
// Header/profile discovery uses one tiny server-local protocol transaction.
|
|
// It does not open an Internet target and therefore measures only whether the
|
|
// candidate DragonTCP framing survives the carrier and is understood by the
|
|
// server. Path chunk calibration runs separately after a header is selected.
|
|
const profileProbeTimeout = 2 * 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
|
|
forceClear bool
|
|
|
|
// Test hooks are nil in production.
|
|
candidateOverride []wireChoice
|
|
probeOverride func(wireChoice) bool
|
|
}
|
|
|
|
type wireChoice struct {
|
|
mode string
|
|
mask byte
|
|
cover cover.Profile
|
|
}
|
|
|
|
// forcedClearChoice builds a clear-payload cover profile with an explicit
|
|
// binary header mask. Clear payload and header masking are independent: a
|
|
// 0x00 header mask can still carry a fully clear payload because the cover
|
|
// preface advertises Clear=true to the server.
|
|
//
|
|
// --force-clear-payload deliberately tries mask 0x00 first, then 0x25. Both
|
|
// profiles keep SHA-256 payload masking disabled. A successful choice is cached
|
|
// for the process lifetime, so all reconnects use the same clear profile.
|
|
func forcedClearChoice(mode string, headerMask byte) wireChoice {
|
|
id := uint16(0x0000)
|
|
if headerMask == 0x25 {
|
|
// Retain the previously deployed clear profile ID for the 0x25 fallback.
|
|
id = 0x0065
|
|
}
|
|
profile := cover.Profile{
|
|
Enabled: true,
|
|
ID: id,
|
|
Padding: 0,
|
|
HeaderMask: headerMask,
|
|
XOR: false,
|
|
Clear: true,
|
|
}
|
|
return wireChoice{mode: mode, mask: profile.HeaderMask, cover: profile}
|
|
}
|
|
|
|
// validBinaryHeaderMask reports whether a direct B/BP header mask is
|
|
// unambiguous to the server's legacy classifier. B/BP encode the request mode
|
|
// in the low three bits (0..4), so those bits in the mask must be zero. This
|
|
// leaves exactly 32 valid masks: 00,08,10,...,F8.
|
|
func validBinaryHeaderMask(mask byte) bool {
|
|
return mask&0x07 == 0
|
|
}
|
|
|
|
// validXORHeaderMask reports whether a direct X mask remains in the X side of
|
|
// the server's first-byte partition. X starts with 'U'^mask and the server
|
|
// recognizes X only when those low three bits are 5, 6, or 7. There are 96
|
|
// such masks.
|
|
func validXORHeaderMask(mask byte) bool {
|
|
return ('U'^mask)&0x07 >= 5
|
|
}
|
|
|
|
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, forceClear bool) *wireSelector {
|
|
s := &wireSelector{
|
|
configured: configured,
|
|
serverAddr: serverAddr,
|
|
token: token,
|
|
binOpts: binOpts,
|
|
xorOpts: xorOpts,
|
|
probeDelay: probeDelay,
|
|
probeThreads: probeThreads,
|
|
forceClear: forceClear,
|
|
}
|
|
return s
|
|
}
|
|
|
|
// resolveOnly validates and locks the wire/header profile without running UP/DW
|
|
// chunk calibration. Port-range discovery uses this to ensure a TCP-open port is
|
|
// actually a DragonTCP endpoint before it is exposed as the WORKING PORT.
|
|
func (s *wireSelector) resolveOnly() (wireChoice, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.hasChoice {
|
|
return s.resolved, nil
|
|
}
|
|
choice, ok := s.detectLocked()
|
|
if !ok {
|
|
return wireChoice{}, fmt.Errorf("no validated DragonTCP wire/header profile")
|
|
}
|
|
s.resolved = choice
|
|
s.hasChoice = true
|
|
return choice, nil
|
|
}
|
|
|
|
// prepare resolves/authenticates the wire and calibrates its carrier limits
|
|
// before latency-sensitive protocols (notably SSH) are allowed to start. The
|
|
// successful wire choice and path profile are cached, so the real SSH OPEN does
|
|
// not repeat discovery/calibration.
|
|
func (s *wireSelector) prepare() (wireChoice, error) {
|
|
fmt.Printf("[D-TCP] phase=AUTH state=starting configured_wire=%s force_clear_payload=%t\n", s.configured, s.forceClear)
|
|
|
|
s.mu.Lock()
|
|
choice := s.resolved
|
|
ok := s.hasChoice
|
|
if !ok {
|
|
choice, ok = s.detectLocked()
|
|
if ok {
|
|
s.resolved = choice
|
|
s.hasChoice = true
|
|
}
|
|
}
|
|
opts := s.binOpts
|
|
s.mu.Unlock()
|
|
|
|
if !ok {
|
|
fmt.Printf("[D-TCP] phase=AUTH state=failed reason=no_validated_wire\n")
|
|
return wireChoice{}, fmt.Errorf("DragonTCP authentication/wire validation failed")
|
|
}
|
|
fmt.Printf("[D-TCP] phase=AUTH state=success wire=%s header_mask=%02x clear_payload=%t cover_id=%04x\n", choice.mode, choice.mask, choice.cover.Clear, choice.cover.ID)
|
|
|
|
if choice.mode == WireXOR {
|
|
xopts := s.xorOpts
|
|
if choice.cover.Enabled {
|
|
xopts = xopts.WithCoverProfile(choice.cover)
|
|
} else {
|
|
xopts = xopts.WithHeaderMask(choice.mask)
|
|
}
|
|
fmt.Printf("[D-TCP] phase=CALIBRATION state=starting wire=x strategy=ascending min=%d max=%d growth=4x fine_resolution=%d up_down=sequential\n", xopts.MinSize(), xopts.MaxSize(), calibrationFineResolution)
|
|
up, down, persistent := xorchunk.Calibrate(s.serverAddr, s.token, xopts, calibrationFineResolution)
|
|
xopts = xopts.WithCalibratedChunks(up, down)
|
|
s.mu.Lock()
|
|
s.xorOpts = xopts
|
|
s.mu.Unlock()
|
|
fmt.Printf("[D-TCP] phase=CALIBRATION state=success wire=x upload=%d download=%d persistent=%t lock_runtime_chunks=true\n", up, down, persistent)
|
|
fmt.Printf("[D-TCP] phase=ACTIVE wire=%s header_mask=%02x clear_payload=%t upload_chunk=%d download_chunk=%d calibrated_locked=true runtime_adaptive=false\n", choice.mode, choice.mask, choice.cover.Clear, up, down)
|
|
return choice, nil
|
|
}
|
|
|
|
opts.headerMask = choice.mask
|
|
opts.coverProfile = choice.cover
|
|
strategy := "ascending"
|
|
if opts.forceMaxStart {
|
|
strategy = "max-first"
|
|
}
|
|
if opts.forceMaxStart {
|
|
fmt.Printf("[D-TCP] phase=CALIBRATION state=starting strategy=%s min=%d max=%d coarse_step=%d fine_resolution=%d\n", strategy, opts.minSize, opts.maxSize, maxFirstCoarseStep, maxFirstFineResolution)
|
|
} else {
|
|
fmt.Printf("[D-TCP] phase=CALIBRATION state=starting strategy=%s min=%d max=%d growth=4x fine_resolution=%d up_down=sequential\n", strategy, opts.minSize, opts.maxSize, calibrationFineResolution)
|
|
}
|
|
profile := getPathProfile(s.serverAddr, s.token, opts)
|
|
fmt.Printf("[D-TCP] phase=CALIBRATION state=success upload=%d download=%d persistent=%t\n", profile.upload, profile.download, profile.persistent)
|
|
fmt.Printf("[D-TCP] phase=ACTIVE wire=%s header_mask=%02x clear_payload=%t upload_chunk=%d download_chunk=%d\n", choice.mode, choice.mask, choice.cover.Clear, profile.upload, profile.download)
|
|
return choice, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if s.forceClear {
|
|
// Hard guarantee: never silently fall back to a legacy SHA-256-masked
|
|
// payload if the user explicitly requested clear payloads.
|
|
if candidates := s.profileCandidates(); len(candidates) > 0 {
|
|
return candidates[0]
|
|
}
|
|
}
|
|
// 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 returns only masks that are mathematically valid for the
|
|
// direct server classifier. Numeric order keeps mask 0x00 first on permissive
|
|
// networks and avoids the old exhaustive covered 0x00..0xFF scan.
|
|
func (s *wireSelector) profileCandidates() []wireChoice {
|
|
// A forced clear payload is retained for CLI compatibility/testing only.
|
|
// Android does not expose it. Clear payload and header mask are independent.
|
|
if s.forceClear {
|
|
switch s.configured {
|
|
case WireBinary:
|
|
return []wireChoice{
|
|
forcedClearChoice(WireBinary, 0x00),
|
|
forcedClearChoice(WireBinary, 0x25),
|
|
}
|
|
case WireBP:
|
|
return []wireChoice{
|
|
forcedClearChoice(WireBP, 0x00),
|
|
forcedClearChoice(WireBP, 0x25),
|
|
}
|
|
case WireAuto:
|
|
return []wireChoice{
|
|
forcedClearChoice(WireBinary, 0x00),
|
|
forcedClearChoice(WireBP, 0x00),
|
|
forcedClearChoice(WireBinary, 0x25),
|
|
forcedClearChoice(WireBP, 0x25),
|
|
}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
wantB := s.configured == WireAuto || s.configured == WireBinary
|
|
wantBP := s.configured == WireAuto || s.configured == WireBP
|
|
wantX := s.configured == WireAuto || s.configured == WireXOR
|
|
|
|
// Normal masked discovery uses only masks that the direct wire classifier
|
|
// can decode without a cover preface. This removes the old 0x00..0xFF x 3
|
|
// covered scan. Auto stays ordered by numeric mask so 0x00 is tested first.
|
|
//
|
|
// B/BP: 32 masks (low 3 bits must be zero).
|
|
// X: 96 masks (('U'^mask)&7 must land in 5..7).
|
|
// Auto: 160 total candidates, rather than ~900 covered/direct probes.
|
|
out := make([]wireChoice, 0, 160)
|
|
for n := 0; n < 256; n++ {
|
|
mask := byte(n)
|
|
if validBinaryHeaderMask(mask) {
|
|
if wantB {
|
|
out = append(out, wireChoice{mode: WireBinary, mask: mask})
|
|
}
|
|
if wantBP {
|
|
out = append(out, wireChoice{mode: WireBP, mask: mask})
|
|
}
|
|
}
|
|
if wantX && validXORHeaderMask(mask) {
|
|
out = append(out, wireChoice{mode: WireXOR, mask: mask})
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// detectLocked validates header/profile candidates with tiny server-local
|
|
// protocol probes. The default is one worker. Users may explicitly allow more
|
|
// workers, while the launch delay still spaces new attempts globally.
|
|
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 = 100 * time.Millisecond
|
|
}
|
|
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 header_mask=%02x completed=%d launched=%d elapsed=%s protocol_probe=true threads=%d fixed_until_restart=true\n", got.choice, got.choice.mask, completed, next, time.Since(started).Round(time.Millisecond), threads)
|
|
return got.choice, true
|
|
}
|
|
if completed%32 == 0 {
|
|
fmt.Printf("wire probe: completed=%d/%d launched=%d elapsed=%s protocol_probe=true no validated profile yet\n", completed, len(candidates), next, time.Since(started).Round(time.Millisecond))
|
|
}
|
|
}
|
|
fmt.Printf("wire probe: no header/profile validated after %d candidates in %s; retrying later\n", completed, time.Since(started).Round(time.Millisecond))
|
|
return wireChoice{}, false
|
|
}
|
|
|
|
// probe performs one small server-local framing transaction. This is purposely
|
|
// separate from UP/DW fake-iperf calibration: header discovery answers "which
|
|
// byte/profile survives?", while calibration answers "what chunk size is safe?".
|
|
func (s *wireSelector) probe(choice wireChoice) bool {
|
|
switch choice.mode {
|
|
case WireXOR:
|
|
opts := s.xorOpts
|
|
if choice.cover.Enabled {
|
|
opts = opts.WithCoverProfile(choice.cover)
|
|
} else {
|
|
opts = opts.WithHeaderMask(choice.mask)
|
|
}
|
|
return xorchunk.ProbeProfile(s.serverAddr, s.token, opts)
|
|
|
|
case WireBP:
|
|
opts := s.binOpts
|
|
opts.headerMask = choice.mask
|
|
opts.coverProfile = choice.cover
|
|
if opts.txnTimeout <= 0 || opts.txnTimeout > profileProbeTimeout {
|
|
opts.txnTimeout = profileProbeTimeout
|
|
}
|
|
return probeBPProfile(s.serverAddr, opts)
|
|
|
|
default:
|
|
opts := s.binOpts
|
|
opts.headerMask = choice.mask
|
|
opts.coverProfile = choice.cover
|
|
opts.skipPathProbe = true
|
|
opts.minSize = 32
|
|
opts.startSize = 32
|
|
opts.maxSize = 32
|
|
if opts.txnTimeout <= 0 || opts.txnTimeout > profileProbeTimeout {
|
|
opts.txnTimeout = profileProbeTimeout
|
|
}
|
|
return probeOne(s.serverAddr, s.token, opts, wire.ProbeKeepalive, 0)
|
|
}
|
|
}
|