Mult Port + TCP Calibration (SSH DEAD)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -37,6 +38,7 @@ type bpLane struct {
|
||||
tcpBuffer int
|
||||
reconnectEvery int
|
||||
timeout time.Duration
|
||||
headerMask byte
|
||||
coverProfile cover.Profile
|
||||
autoReconnect bool
|
||||
pc *bpPhysicalConn
|
||||
@@ -44,6 +46,10 @@ type bpLane struct {
|
||||
}
|
||||
|
||||
func newBPLane(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Duration, coverProfile cover.Profile) *bpLane {
|
||||
return newBPLaneWithMask(serverAddr, tcpBuffer, reconnectEvery, timeout, coverProfile.HeaderMask, coverProfile)
|
||||
}
|
||||
|
||||
func newBPLaneWithMask(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Duration, headerMask byte, coverProfile cover.Profile) *bpLane {
|
||||
autoReconnect := reconnectEvery == 1
|
||||
if autoReconnect {
|
||||
reconnectEvery = 0
|
||||
@@ -53,6 +59,7 @@ func newBPLane(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Du
|
||||
tcpBuffer: tcpBuffer,
|
||||
reconnectEvery: reconnectEvery,
|
||||
timeout: timeout,
|
||||
headerMask: headerMask,
|
||||
coverProfile: coverProfile,
|
||||
autoReconnect: autoReconnect,
|
||||
}
|
||||
@@ -172,7 +179,7 @@ func (l *bpLane) single(mode byte, sid wire.SessionID, seq uint64, payload []byt
|
||||
}
|
||||
reused := l.pc.requests > 0
|
||||
_ = l.pc.conn.SetDeadline(time.Now().Add(timeout))
|
||||
if err := writeBPRequest(l.pc.conn, mode, sid, seq, payload, downloadHint, l.coverProfile.HeaderMask, l.coverProfile.Clear); err != nil {
|
||||
if err := writeBPRequest(l.pc.conn, mode, sid, seq, payload, downloadHint, l.headerMask, l.coverProfile.Clear); err != nil {
|
||||
l.transportFailureLocked(reused)
|
||||
if isTransportTimeout(err) {
|
||||
return 0, nil, err
|
||||
@@ -180,7 +187,7 @@ func (l *bpLane) single(mode byte, sid wire.SessionID, seq uint64, payload []byt
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
status, body, err := readBPResponse(l.pc.conn, sid, mode, seq, l.coverProfile.HeaderMask, l.coverProfile.Clear)
|
||||
status, body, err := readBPResponse(l.pc.conn, sid, mode, seq, l.headerMask, l.coverProfile.Clear)
|
||||
if err != nil {
|
||||
l.transportFailureLocked(reused)
|
||||
if isTransportTimeout(err) {
|
||||
@@ -197,6 +204,31 @@ func (l *bpLane) single(mode byte, sid wire.SessionID, seq uint64, payload []byt
|
||||
return 0, nil, fmt.Errorf("BP request failed after reconnect: %w", lastErr)
|
||||
}
|
||||
|
||||
// probeBPProfile validates one BP header/cover profile with a single small
|
||||
// BHP1 echo transaction. It deliberately does not register/open a target
|
||||
// session; authenticated DTP2 calibration runs immediately after profile
|
||||
// selection and remains authoritative for the configured server token.
|
||||
func probeBPProfile(serverAddr string, opts chunkClientOptions) bool {
|
||||
sid, err := randomSessionID()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
timeout := opts.txnTimeout
|
||||
if timeout <= 0 || timeout > profileProbeTimeout {
|
||||
timeout = profileProbeTimeout
|
||||
}
|
||||
lane := newBPLaneWithMask(serverAddr, opts.tcpBuffer, 1, timeout, opts.headerMask, opts.coverProfile)
|
||||
defer lane.Close()
|
||||
|
||||
payload := make([]byte, 10)
|
||||
copy(payload[:4], []byte("BHP1"))
|
||||
payload[4] = 1
|
||||
payload[5] = bpModeUpload
|
||||
binary.BigEndian.PutUint32(payload[6:10], 0)
|
||||
status, body, err := lane.single(bpModeProbe, sid, 0, payload, 0)
|
||||
return err == nil && status == wire.StatusOK && bytes.Equal(body, payload)
|
||||
}
|
||||
|
||||
func decodeBPData(body []byte) ([]byte, error) {
|
||||
if len(body) < 4 {
|
||||
return nil, fmt.Errorf("short BP DATA body")
|
||||
@@ -240,7 +272,7 @@ func (l *bpLane) download(sid wire.SessionID, offset uint64, maxChunk, count int
|
||||
}
|
||||
reused := l.pc.requests > 0
|
||||
_ = l.pc.conn.SetDeadline(time.Now().Add(timeout))
|
||||
if err := writeBPRequest(l.pc.conn, mode, sid, offset, payload, hint, l.coverProfile.HeaderMask, l.coverProfile.Clear); err != nil {
|
||||
if err := writeBPRequest(l.pc.conn, mode, sid, offset, payload, hint, l.headerMask, l.coverProfile.Clear); err != nil {
|
||||
l.transportFailureLocked(reused)
|
||||
if isTransportTimeout(err) {
|
||||
return nil, 0, err
|
||||
@@ -252,7 +284,7 @@ func (l *bpLane) download(sid wire.SessionID, offset uint64, maxChunk, count int
|
||||
out := make([][]byte, 0, responses)
|
||||
lastStatus := wire.StatusOK
|
||||
for i := 0; i < responses; i++ {
|
||||
status, body, err := readBPResponse(l.pc.conn, sid, mode, offset, l.coverProfile.HeaderMask, l.coverProfile.Clear)
|
||||
status, body, err := readBPResponse(l.pc.conn, sid, mode, offset, l.headerMask, l.coverProfile.Clear)
|
||||
if err != nil {
|
||||
l.transportFailureLocked(reused)
|
||||
if isTransportTimeout(err) {
|
||||
@@ -327,9 +359,15 @@ func openBPTunnel(serverAddr, token, targetHost string, targetPort int, opts chu
|
||||
if opts.startSize < opts.minSize || opts.startSize > opts.maxSize {
|
||||
opts.startSize = opts.maxSize
|
||||
}
|
||||
if opts.forceMaxStart {
|
||||
opts.startSize = opts.maxSize
|
||||
}
|
||||
if opts.txnTimeout <= 0 {
|
||||
opts.txnTimeout = 5 * time.Second
|
||||
}
|
||||
if opts.shrinkAfter < 1 {
|
||||
opts.shrinkAfter = 1
|
||||
}
|
||||
if opts.maxPipeline < 1 {
|
||||
opts.maxPipeline = 1
|
||||
}
|
||||
@@ -347,11 +385,24 @@ func openBPTunnel(serverAddr, token, targetHost string, targetPort int, opts chu
|
||||
reconnect = 0
|
||||
}
|
||||
|
||||
profile := pathProfile{
|
||||
upload: opts.startSize,
|
||||
download: opts.startSize,
|
||||
persistent: false,
|
||||
at: time.Now(),
|
||||
}
|
||||
if !opts.skipPathProbe {
|
||||
// BP uses the same binary framing sizes and cover preface as the native B
|
||||
// transport. Reuse the authenticated DTP2 probe machinery to calibrate
|
||||
// the carrier before BP starts moving SSH/application data.
|
||||
profile = getPathProfile(serverAddr, token, opts)
|
||||
}
|
||||
|
||||
sid, err := randomSessionID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uploadLane := newBPLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout, opts.coverProfile)
|
||||
uploadLane := newBPLaneWithMask(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout, opts.headerMask, opts.coverProfile)
|
||||
status, body, err := uploadLane.single(bpModeUpload, sid, 0, nil, 0)
|
||||
if err != nil {
|
||||
uploadLane.Close()
|
||||
@@ -389,11 +440,25 @@ func openBPTunnel(serverAddr, token, targetHost string, targetPort int, opts chu
|
||||
sid: sid,
|
||||
opts: opts,
|
||||
uploadLane: uploadLane,
|
||||
downloadLane: newBPLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout, opts.coverProfile),
|
||||
downloadLane: newBPLaneWithMask(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout, opts.headerMask, opts.coverProfile),
|
||||
pipeline: opts.maxPipeline,
|
||||
}
|
||||
c.upSizer = newAdaptiveSizer("BP upload", opts.startSize, opts)
|
||||
c.downSizer = newAdaptiveSizer("BP download", opts.startSize, opts)
|
||||
upStart := minInt(profile.upload, opts.maxSize)
|
||||
downStart := minInt(profile.download, opts.maxSize)
|
||||
if upStart < opts.minSize {
|
||||
upStart = opts.minSize
|
||||
}
|
||||
if downStart < opts.minSize {
|
||||
downStart = opts.minSize
|
||||
}
|
||||
upSizerOpts := opts
|
||||
downSizerOpts := opts
|
||||
if !opts.skipPathProbe {
|
||||
upSizerOpts.maxSize = upStart
|
||||
downSizerOpts.maxSize = downStart
|
||||
}
|
||||
c.upSizer = newAdaptiveSizer("BP upload", upStart, upSizerOpts)
|
||||
c.downSizer = newAdaptiveSizer("BP download", downStart, downSizerOpts)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
@@ -22,6 +23,8 @@ type chunkClientOptions struct {
|
||||
maxSize int
|
||||
adaptive bool
|
||||
adaptSuccesses int
|
||||
shrinkAfter int
|
||||
shrinkStep int
|
||||
adaptLog bool
|
||||
pollers int
|
||||
reconnectEvery int
|
||||
@@ -33,6 +36,7 @@ type chunkClientOptions struct {
|
||||
headerMask byte
|
||||
coverProfile cover.Profile
|
||||
skipPathProbe bool
|
||||
forceMaxStart bool
|
||||
}
|
||||
|
||||
type adaptiveSizer struct {
|
||||
@@ -43,6 +47,9 @@ type adaptiveSizer struct {
|
||||
max int
|
||||
adaptive bool
|
||||
adaptSuccesses int
|
||||
shrinkAfter int
|
||||
shrinkStep int
|
||||
failures int
|
||||
successes int
|
||||
good int
|
||||
bad int
|
||||
@@ -68,6 +75,13 @@ func newAdaptiveSizer(name string, start int, opts chunkClientOptions) *adaptive
|
||||
}
|
||||
return 64
|
||||
}(),
|
||||
shrinkAfter: func() int {
|
||||
if opts.shrinkAfter > 0 {
|
||||
return opts.shrinkAfter
|
||||
}
|
||||
return 1
|
||||
}(),
|
||||
shrinkStep: opts.shrinkStep,
|
||||
logChanges: opts.adaptLog,
|
||||
}
|
||||
}
|
||||
@@ -81,7 +95,14 @@ func (s *adaptiveSizer) Current() int {
|
||||
func (s *adaptiveSizer) Success(attempted int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.adaptive || attempted != s.current || s.current >= s.max {
|
||||
if !s.adaptive || attempted != s.current {
|
||||
return
|
||||
}
|
||||
// A real success proves the current size still works. In max-first mode this
|
||||
// resets the consecutive-failure budget so isolated carrier errors never
|
||||
// cause a downshift.
|
||||
s.failures = 0
|
||||
if s.current >= s.max {
|
||||
return
|
||||
}
|
||||
if attempted > s.good {
|
||||
@@ -135,11 +156,25 @@ func (s *adaptiveSizer) FailureReason(attempted int, cause error) (int, int) {
|
||||
return old, old
|
||||
}
|
||||
s.successes = 0
|
||||
s.failures++
|
||||
if s.failures < s.shrinkAfter {
|
||||
if s.logChanges && s.shrinkAfter > 1 {
|
||||
fmt.Printf("adaptive %s chunk: holding %d after failure %d/%d: %v\n", s.name, old, s.failures, s.shrinkAfter, cause)
|
||||
}
|
||||
return old, old
|
||||
}
|
||||
s.failures = 0
|
||||
if s.bad == 0 || attempted < s.bad {
|
||||
s.bad = attempted
|
||||
}
|
||||
next := attempted / 2
|
||||
if s.good > 0 && s.good < attempted {
|
||||
if s.shrinkStep > 0 {
|
||||
next = attempted - s.shrinkStep
|
||||
// MAX-FIRST linear downgrade intentionally ignores an older known-good
|
||||
// point: the goal is to walk down in small deterministic steps instead
|
||||
// of making a large jump after a transient carrier rejection.
|
||||
s.good = 0
|
||||
} else if s.good > 0 && s.good < attempted {
|
||||
next = s.good
|
||||
} else {
|
||||
s.good = 0
|
||||
@@ -470,6 +505,123 @@ func probeOne(serverAddr, token string, opts chunkClientOptions, kind byte, cand
|
||||
return status == wire.StatusOK
|
||||
}
|
||||
|
||||
type iperfProbeResult struct {
|
||||
ok bool
|
||||
bytes int
|
||||
elapsed time.Duration
|
||||
err error
|
||||
}
|
||||
|
||||
func (r iperfProbeResult) mbps() float64 {
|
||||
if r.elapsed <= 0 || r.bytes <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (float64(r.bytes) * 8) / r.elapsed.Seconds() / 1_000_000
|
||||
}
|
||||
|
||||
func calibrationDeadline(opts chunkClientOptions) time.Duration {
|
||||
t := opts.txnTimeout
|
||||
if t < 8*time.Second {
|
||||
t = 8 * time.Second
|
||||
}
|
||||
if t > 20*time.Second {
|
||||
t = 20 * time.Second
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func dialProbeConn(serverAddr string, opts chunkClientOptions) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: minDuration(calibrationDeadline(opts), 10*time.Second), KeepAlive: 30 * time.Second}
|
||||
conn, err := d.Dial("tcp", serverAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cover.WritePreface(conn, opts.coverProfile); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
protocol.TuneTCP(conn)
|
||||
protocol.TuneTCPBuffer(conn, opts.tcpBuffer)
|
||||
_ = conn.SetDeadline(time.Now().Add(calibrationDeadline(opts)))
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// probeIperfOne validates one UP or DW record at a time against the DragonTCP
|
||||
// server. Calibration is intentionally single-poller and never pipelines
|
||||
// multiple outstanding records: it measures a single lane's safe record size,
|
||||
// not aggregate throughput. Boundary confirmation retries happen sequentially
|
||||
// on fresh connections.
|
||||
func probeIperfOne(serverAddr, token string, opts chunkClientOptions, kind byte, candidate int) iperfProbeResult {
|
||||
count := wire.ProbeBurstCount(candidate)
|
||||
totalBytes := candidate * count
|
||||
conn, err := dialProbeConn(serverAddr, opts)
|
||||
if err != nil {
|
||||
return iperfProbeResult{err: err}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
sid, err := randomSessionID()
|
||||
if err != nil {
|
||||
return iperfProbeResult{err: err}
|
||||
}
|
||||
startSeq := probeSeq.Add(uint64(count)) - uint64(count) + 1
|
||||
started := time.Now()
|
||||
|
||||
switch kind {
|
||||
case wire.ProbeUpload:
|
||||
payload := makeProbePayload(wire.ProbeIperfUpload, candidate, candidate, token)
|
||||
totalBytes = len(payload) * count
|
||||
for i := 0; i < count; i++ {
|
||||
seq := startSeq + uint64(i)
|
||||
if err := wire.WriteRequestProfileEncoding(conn, wire.ModeProbe, sid, seq, payload, opts.headerMask, opts.coverProfile.Clear); err != nil {
|
||||
return iperfProbeResult{bytes: i * len(payload), elapsed: time.Since(started), err: err}
|
||||
}
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
status, body, err := wire.ReadResponseProfile(conn, opts.headerMask)
|
||||
if err != nil {
|
||||
return iperfProbeResult{bytes: totalBytes, elapsed: time.Since(started), err: err}
|
||||
}
|
||||
if status != wire.StatusOK {
|
||||
return iperfProbeResult{bytes: totalBytes, elapsed: time.Since(started), err: fmt.Errorf("upload status=%d body=%s", status, string(body))}
|
||||
}
|
||||
}
|
||||
return iperfProbeResult{ok: true, bytes: totalBytes, elapsed: time.Since(started)}
|
||||
|
||||
case wire.ProbeDownload:
|
||||
payload := makeProbePayload(wire.ProbeIperfDownload, candidate, 0, token)
|
||||
if err := wire.WriteRequestProfileEncoding(conn, wire.ModeProbe, sid, startSeq, payload, opts.headerMask, opts.coverProfile.Clear); err != nil {
|
||||
return iperfProbeResult{elapsed: time.Since(started), err: err}
|
||||
}
|
||||
want := probePattern(candidate)
|
||||
gotBytes := 0
|
||||
for i := 0; i < count; i++ {
|
||||
status, body, err := wire.ReadResponseProfile(conn, opts.headerMask)
|
||||
if err != nil {
|
||||
return iperfProbeResult{bytes: gotBytes, elapsed: time.Since(started), err: err}
|
||||
}
|
||||
seq := startSeq + uint64(i)
|
||||
if status != wire.StatusError && len(body) > 0 && !opts.coverProfile.Clear {
|
||||
body = wire.DecodeMaskedResponse(status, body, sid, wire.ModeProbe, seq)
|
||||
}
|
||||
if status != wire.StatusData || !bytes.Equal(body, want) {
|
||||
return iperfProbeResult{bytes: gotBytes, elapsed: time.Since(started), err: fmt.Errorf("download validation failed status=%d len=%d want=%d", status, len(body), candidate)}
|
||||
}
|
||||
gotBytes += len(body)
|
||||
}
|
||||
return iperfProbeResult{ok: true, bytes: gotBytes, elapsed: time.Since(started)}
|
||||
default:
|
||||
return iperfProbeResult{err: fmt.Errorf("unknown iperf probe kind %d", kind)}
|
||||
}
|
||||
}
|
||||
|
||||
func minDuration(a, b time.Duration) time.Duration {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func probePersistent(serverAddr, token string, opts chunkClientOptions) bool {
|
||||
sid, err := randomSessionID()
|
||||
if err != nil {
|
||||
@@ -512,28 +664,268 @@ func probeCandidates(minSize, maxSize int) []int {
|
||||
return out
|
||||
}
|
||||
|
||||
func probeMaximum(serverAddr, token string, opts chunkClientOptions, kind byte) int {
|
||||
candidates := probeCandidates(opts.minSize, opts.maxSize)
|
||||
lo, hi := 0, len(candidates)-1
|
||||
best := opts.minSize
|
||||
for lo <= hi {
|
||||
mid := lo + (hi-lo)/2
|
||||
candidate := candidates[mid]
|
||||
if probeOne(serverAddr, token, opts, kind, candidate) {
|
||||
best = candidate
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid - 1
|
||||
const calibrationFineResolution = 32
|
||||
|
||||
const calibrationDecisionAttempts = 3
|
||||
|
||||
// confirmIperfResult makes boundary decisions resistant to one transient
|
||||
// carrier hiccup. The caller provides the first observation; this function
|
||||
// opens fresh probe connections until either success or non-timeout failure has
|
||||
// a 2-of-3 majority. Connection-level timeouts are inconclusive and never count
|
||||
// as proof that a chunk size is too large.
|
||||
func confirmIperfResult(serverAddr, token string, opts chunkClientOptions, kind byte, candidate int, first iperfProbeResult, stage string) iperfProbeResult {
|
||||
name := probeKindName(kind)
|
||||
successes, failures := 0, 0
|
||||
var lastSuccess, lastFailure, lastTimeout iperfProbeResult
|
||||
|
||||
observe := func(r iperfProbeResult) {
|
||||
if r.ok {
|
||||
successes++
|
||||
lastSuccess = r
|
||||
return
|
||||
}
|
||||
if isTransportTimeout(r.err) {
|
||||
lastTimeout = r
|
||||
return
|
||||
}
|
||||
failures++
|
||||
lastFailure = r
|
||||
}
|
||||
if best < opts.minSize {
|
||||
best = opts.minSize
|
||||
|
||||
observe(first)
|
||||
for attempt := 2; attempt <= calibrationDecisionAttempts && successes < 2 && failures < 2; attempt++ {
|
||||
r := probeIperfOne(serverAddr, token, opts, kind, candidate)
|
||||
observe(r)
|
||||
result := "failure"
|
||||
if r.ok {
|
||||
result = "success"
|
||||
} else if isTransportTimeout(r.err) {
|
||||
result = "connection_timeout"
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=%s chunk=%d confirmation=%d/%d result=%s\n", name, stage, candidate, attempt, calibrationDecisionAttempts, result)
|
||||
}
|
||||
return best
|
||||
|
||||
if successes >= 2 {
|
||||
return lastSuccess
|
||||
}
|
||||
if failures >= 2 {
|
||||
return lastFailure
|
||||
}
|
||||
// No majority means connection health was too unstable to classify the
|
||||
// candidate. Preserve the old timeout semantics by reporting the timeout and
|
||||
// keeping the last known-good size.
|
||||
if lastTimeout.err != nil {
|
||||
return lastTimeout
|
||||
}
|
||||
if successes > failures && lastSuccess.ok {
|
||||
return lastSuccess
|
||||
}
|
||||
return lastFailure
|
||||
}
|
||||
|
||||
func probeMaximum(serverAddr, token string, opts chunkClientOptions, kind byte) int {
|
||||
name := probeKindName(kind)
|
||||
// Startup calibration precision is independent from the runtime shrink step.
|
||||
// Grow quickly, then resolve only the final good/bad boundary to 32 bytes.
|
||||
fine := calibrationFineResolution
|
||||
|
||||
// Ascending calibration deliberately starts small and grows geometrically in large steps.
|
||||
// This avoids hammering a constrained carrier with 1 MiB records before we
|
||||
// know they are viable, while still reaching the ceiling in O(log N) probes.
|
||||
// Once the first failure is found, binary refinement resolves the highest
|
||||
// known-good size to roughly `fine` bytes (32 B in the Android build).
|
||||
good := 0
|
||||
bad := 0
|
||||
candidate := opts.minSize
|
||||
if candidate < 32 {
|
||||
candidate = 32
|
||||
}
|
||||
if candidate > opts.maxSize {
|
||||
candidate = opts.maxSize
|
||||
}
|
||||
|
||||
for {
|
||||
result := probeIperfOne(serverAddr, token, opts, kind, candidate)
|
||||
if result.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=ascend chunk=%d records=%d bytes=%d mbps=%.2f result=success\n", name, candidate, wire.ProbeBurstCount(candidate), result.bytes, result.mbps())
|
||||
good = candidate
|
||||
if candidate >= opts.maxSize {
|
||||
return opts.maxSize
|
||||
}
|
||||
next := candidate * 4
|
||||
// Do not waste many tiny probes when the configured floor is very
|
||||
// small. After proving the floor, jump to at least 512 B and then
|
||||
// continue growing by 4x.
|
||||
if candidate == opts.minSize && next < 512 && opts.maxSize >= 512 {
|
||||
next = 512
|
||||
}
|
||||
if next > opts.maxSize {
|
||||
next = opts.maxSize
|
||||
}
|
||||
if next <= candidate {
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s stage=ascend chunk_upgrade=%d->%d\n", name, candidate, next)
|
||||
candidate = next
|
||||
continue
|
||||
}
|
||||
|
||||
// A first failure may be transient. Re-test this exact size on fresh
|
||||
// connections before declaring the ascending ceiling.
|
||||
result = confirmIperfResult(serverAddr, token, opts, kind, candidate, result, "ascend-confirm")
|
||||
if result.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=ascend chunk=%d result=recovered_after_retry\n", name, candidate)
|
||||
good = candidate
|
||||
if candidate >= opts.maxSize {
|
||||
return opts.maxSize
|
||||
}
|
||||
next := candidate * 4
|
||||
if candidate == opts.minSize && next < 512 && opts.maxSize >= 512 {
|
||||
next = 512
|
||||
}
|
||||
if next > opts.maxSize {
|
||||
next = opts.maxSize
|
||||
}
|
||||
if next <= candidate {
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s stage=ascend chunk_upgrade=%d->%d\n", name, candidate, next)
|
||||
candidate = next
|
||||
continue
|
||||
}
|
||||
|
||||
if isTransportTimeout(result.err) {
|
||||
// A real I/O timeout means the physical connection is dead. It is not
|
||||
// evidence that this record size is invalid, so stop calibration at
|
||||
// the last proven size instead of walking the size ladder.
|
||||
selected := good
|
||||
if selected == 0 {
|
||||
selected = opts.minSize
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=ascend chunk=%d result=connection_timeout action=keep_known_good known_good=%d err=%v\n", name, candidate, selected, result.err)
|
||||
return selected
|
||||
}
|
||||
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=ascend chunk=%d records=%d bytes=%d mbps=%.2f result=failure err=%v\n", name, candidate, wire.ProbeBurstCount(candidate), result.bytes, result.mbps(), result.err)
|
||||
bad = candidate
|
||||
if good == 0 {
|
||||
return opts.minSize
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
for bad-good > fine {
|
||||
next := good + (bad-good)/2
|
||||
if next <= good || next >= bad {
|
||||
break
|
||||
}
|
||||
result := probeIperfOne(serverAddr, token, opts, kind, next)
|
||||
result = confirmIperfResult(serverAddr, token, opts, kind, next, result, "refine-confirm")
|
||||
if result.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=refine chunk=%d records=%d bytes=%d mbps=%.2f result=success\n", name, next, wire.ProbeBurstCount(next), result.bytes, result.mbps())
|
||||
good = next
|
||||
continue
|
||||
}
|
||||
if isTransportTimeout(result.err) {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=refine chunk=%d result=connection_timeout action=keep_known_good known_good=%d err=%v\n", name, next, good, result.err)
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=refine chunk=%d records=%d bytes=%d mbps=%.2f result=failure err=%v\n", name, next, wire.ProbeBurstCount(next), result.bytes, result.mbps(), result.err)
|
||||
bad = next
|
||||
}
|
||||
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s stage=refine selected=%d failed_above=%d resolution=%d\n", name, good, bad, fine)
|
||||
return good
|
||||
}
|
||||
|
||||
func probeKindName(kind byte) string {
|
||||
if kind == wire.ProbeDownload {
|
||||
return "download"
|
||||
}
|
||||
return "upload"
|
||||
}
|
||||
|
||||
const (
|
||||
// A literal 200-byte descent from 1 MiB can require more than five thousand
|
||||
// carrier probes per direction. MAX-FIRST therefore finds a working region
|
||||
// with a bounded coarse descent, then resolves the final good/bad boundary to
|
||||
// approximately 32 bytes. This preserves fine sizing without probe floods.
|
||||
maxFirstCoarseStep = 128 * 1024
|
||||
maxFirstFineResolution = calibrationFineResolution
|
||||
)
|
||||
|
||||
// probeMaximumMaxFirst always validates the configured ceiling first. On a
|
||||
// recoverable size rejection it descends by 128 KiB until it finds a known-good
|
||||
// size, then binary-refines the interval between that good size and the nearest
|
||||
// failed size to <= 32 bytes. A connection-level timeout is never interpreted
|
||||
// as evidence that the chunk is too large, so it does not trigger a size sweep.
|
||||
func probeMaximumMaxFirst(serverAddr, token string, opts chunkClientOptions, kind byte) int {
|
||||
fine := maxFirstFineResolution
|
||||
candidate := opts.maxSize
|
||||
if candidate < opts.minSize {
|
||||
candidate = opts.minSize
|
||||
}
|
||||
name := probeKindName(kind)
|
||||
failedHigh := 0
|
||||
|
||||
// Coarse descent: at most about eight probes from 1 MiB to the bottom of
|
||||
// the normal range, instead of thousands of 200-byte requests.
|
||||
for {
|
||||
result := probeIperfOne(serverAddr, token, opts, kind, candidate)
|
||||
if result.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=coarse chunk=%d records=%d bytes=%d mbps=%.2f result=success\n", name, candidate, wire.ProbeBurstCount(candidate), result.bytes, result.mbps())
|
||||
if failedHigh == 0 {
|
||||
return candidate
|
||||
}
|
||||
break
|
||||
}
|
||||
if isTransportTimeout(result.err) {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=coarse chunk=%d result=connection_timeout action=keep_size_and_replace_connection err=%v\n", name, candidate, result.err)
|
||||
return candidate
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=coarse chunk=%d records=%d bytes=%d mbps=%.2f result=failure err=%v\n", name, candidate, wire.ProbeBurstCount(candidate), result.bytes, result.mbps(), result.err)
|
||||
failedHigh = candidate
|
||||
if candidate <= opts.minSize {
|
||||
return opts.minSize
|
||||
}
|
||||
next := candidate - maxFirstCoarseStep
|
||||
if next < opts.minSize {
|
||||
next = opts.minSize
|
||||
}
|
||||
if next >= candidate {
|
||||
next = opts.minSize
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s stage=coarse chunk_downgrade=%d->%d step=%d\n", name, candidate, next, maxFirstCoarseStep)
|
||||
candidate = next
|
||||
}
|
||||
|
||||
// Fine boundary search. candidate is known-good and failedHigh is known-bad.
|
||||
good := candidate
|
||||
bad := failedHigh
|
||||
for bad-good > fine {
|
||||
next := good + (bad-good)/2
|
||||
if next <= good {
|
||||
break
|
||||
}
|
||||
result := probeIperfOne(serverAddr, token, opts, kind, next)
|
||||
if result.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=fine chunk=%d records=%d bytes=%d mbps=%.2f result=success\n", name, next, wire.ProbeBurstCount(next), result.bytes, result.mbps())
|
||||
good = next
|
||||
continue
|
||||
}
|
||||
if isTransportTimeout(result.err) {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=fine chunk=%d result=connection_timeout action=keep_known_good known_good=%d err=%v\n", name, next, good, result.err)
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s stage=fine chunk=%d records=%d bytes=%d mbps=%.2f result=failure err=%v\n", name, next, wire.ProbeBurstCount(next), result.bytes, result.mbps(), result.err)
|
||||
bad = next
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s stage=fine selected=%d failed_above=%d resolution=%d\n", name, good, bad, fine)
|
||||
return good
|
||||
}
|
||||
|
||||
func getPathProfile(serverAddr, token string, opts chunkClientOptions) pathProfile {
|
||||
key := fmt.Sprintf("%s|%s|%d|%d|%02x|%t|%04x|%d|%t", serverAddr, token, opts.minSize, opts.maxSize, opts.headerMask, opts.coverProfile.Enabled, opts.coverProfile.ID, opts.coverProfile.Padding, opts.coverProfile.Clear)
|
||||
key := fmt.Sprintf("%s|%s|%d|%d|%02x|%t|%04x|%d|%t|maxfirst=%t|shrink=%d|step=%d", serverAddr, token, opts.minSize, opts.maxSize, opts.headerMask, opts.coverProfile.Enabled, opts.coverProfile.ID, opts.coverProfile.Padding, opts.coverProfile.Clear, opts.forceMaxStart, opts.shrinkAfter, opts.shrinkStep)
|
||||
profileState.Lock()
|
||||
if profileState.key == key && time.Since(profileState.p.at) < 30*time.Minute {
|
||||
p := profileState.p
|
||||
@@ -545,23 +937,27 @@ func getPathProfile(serverAddr, token string, opts chunkClientOptions) pathProfi
|
||||
fallbackUp := minInt(opts.maxSize, maxInt(opts.minSize, 32768))
|
||||
fallbackDown := minInt(opts.maxSize, maxInt(opts.minSize, 1350))
|
||||
|
||||
upCh := make(chan int, 1)
|
||||
downCh := make(chan int, 1)
|
||||
go func() { upCh <- probeMaximum(serverAddr, token, opts, wire.ProbeUpload) }()
|
||||
go func() { downCh <- probeMaximum(serverAddr, token, opts, wire.ProbeDownload) }()
|
||||
|
||||
p := pathProfile{upload: fallbackUp, download: fallbackDown, persistent: false, at: time.Now()}
|
||||
select {
|
||||
case p.upload = <-upCh:
|
||||
case <-time.After(20 * time.Second):
|
||||
}
|
||||
select {
|
||||
case p.download = <-downCh:
|
||||
case <-time.After(20 * time.Second):
|
||||
if opts.forceMaxStart {
|
||||
// Legacy CLI-only mode retained for compatibility. The Android app no
|
||||
// longer exposes MAX FIRST and uses the ascending path below.
|
||||
p.upload = probeMaximumMaxFirst(serverAddr, token, opts, wire.ProbeUpload)
|
||||
p.download = probeMaximumMaxFirst(serverAddr, token, opts, wire.ProbeDownload)
|
||||
} else {
|
||||
// Keep UP and DW calibration sequential. Running both fake-iperf probes
|
||||
// together can look like a traffic burst and distort the carrier limit we
|
||||
// are trying to measure.
|
||||
p.upload = probeMaximum(serverAddr, token, opts, wire.ProbeUpload)
|
||||
p.download = probeMaximum(serverAddr, token, opts, wire.ProbeDownload)
|
||||
}
|
||||
p.persistent = probePersistent(serverAddr, token, opts)
|
||||
|
||||
fmt.Printf("path probe: upload=%d download=%d persistent=%t\n", p.upload, p.download, p.persistent)
|
||||
fmt.Printf("path probe: strategy=%s upload=%d download=%d persistent=%t\n", func() string {
|
||||
if opts.forceMaxStart {
|
||||
return "max-first"
|
||||
}
|
||||
return "ascending"
|
||||
}(), p.upload, p.download, p.persistent)
|
||||
|
||||
profileState.Lock()
|
||||
profileState.key = key
|
||||
@@ -649,6 +1045,12 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
|
||||
if opts.adaptSuccesses < 1 {
|
||||
opts.adaptSuccesses = 64
|
||||
}
|
||||
if opts.shrinkAfter < 1 {
|
||||
opts.shrinkAfter = 1
|
||||
}
|
||||
if opts.forceMaxStart {
|
||||
opts.startSize = opts.maxSize
|
||||
}
|
||||
if opts.reconnectEvery < 0 {
|
||||
opts.reconnectEvery = 0
|
||||
}
|
||||
@@ -665,9 +1067,13 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
|
||||
opts.minPipeline = opts.maxPipeline
|
||||
}
|
||||
|
||||
defaultStart := opts.minSize
|
||||
if opts.forceMaxStart {
|
||||
defaultStart = opts.maxSize
|
||||
}
|
||||
profile := pathProfile{
|
||||
upload: opts.minSize,
|
||||
download: opts.minSize,
|
||||
upload: defaultStart,
|
||||
download: defaultStart,
|
||||
persistent: false,
|
||||
at: time.Now(),
|
||||
}
|
||||
@@ -738,8 +1144,17 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
|
||||
minPipeline: opts.minPipeline,
|
||||
maxPipeline: opts.maxPipeline,
|
||||
}
|
||||
c.upSizer = newAdaptiveSizer("upload", upStart, opts)
|
||||
c.downSizer = newAdaptiveSizer("download", downStart, opts)
|
||||
upSizerOpts := opts
|
||||
downSizerOpts := opts
|
||||
if !opts.skipPathProbe {
|
||||
// Calibration is the path ceiling for this VPN session. Runtime
|
||||
// adaptation may shrink after repeated failures and recover to this
|
||||
// value, but it must not grow above a size the UP/DW test did not prove.
|
||||
upSizerOpts.maxSize = upStart
|
||||
downSizerOpts.maxSize = downStart
|
||||
}
|
||||
c.upSizer = newAdaptiveSizer("upload", upStart, upSizerOpts)
|
||||
c.downSizer = newAdaptiveSizer("download", downStart, downSizerOpts)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -30,6 +31,165 @@ func TestAdaptiveSizerRecoversFromMinimum(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptiveSizerWaitsForFailureBudgetBeforeShrinking(t *testing.T) {
|
||||
opts := chunkClientOptions{
|
||||
startSize: 1024,
|
||||
minSize: 32,
|
||||
maxSize: 1024,
|
||||
adaptive: true,
|
||||
shrinkAfter: 3,
|
||||
}
|
||||
s := newAdaptiveSizer("test", 1024, opts)
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
old, next := s.Failure(1024)
|
||||
if old != 1024 || next != 1024 || s.Current() != 1024 {
|
||||
t.Fatalf("failure %d reduced early: old=%d next=%d current=%d", i, old, next, s.Current())
|
||||
}
|
||||
}
|
||||
|
||||
// Any successful record resets the consecutive-failure budget.
|
||||
s.Success(1024)
|
||||
for i := 1; i <= 2; i++ {
|
||||
_, next := s.Failure(1024)
|
||||
if next != 1024 {
|
||||
t.Fatalf("post-success failure %d reduced early to %d", i, next)
|
||||
}
|
||||
}
|
||||
_, next := s.Failure(1024)
|
||||
if next != 512 || s.Current() != 512 {
|
||||
t.Fatalf("third consecutive failure should reduce 1024 -> 512, next=%d current=%d", next, s.Current())
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceMaxStartCalibratesAtCeiling(t *testing.T) {
|
||||
profileState.Lock()
|
||||
profileState.key = ""
|
||||
profileState.p = pathProfile{}
|
||||
profileState.Unlock()
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(serverErr)
|
||||
for connection := 0; connection < 4; connection++ {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
req, err := wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
|
||||
if req.Mode == wire.ModeOpen {
|
||||
body := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(body, 1024)
|
||||
err = wire.WriteMaskedResponse(conn, wire.StatusOK, body, req.Session, req.Mode, req.Seq)
|
||||
_ = conn.Close()
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if req.Mode != wire.ModeProbe || len(req.Payload) < 11 {
|
||||
_ = conn.Close()
|
||||
serverErr <- &testError{"expected calibration probe"}
|
||||
return
|
||||
}
|
||||
kind := req.Payload[4]
|
||||
candidate := int(binary.BigEndian.Uint32(req.Payload[7:11]))
|
||||
switch kind {
|
||||
case wire.ProbeIperfUpload:
|
||||
count := wire.ProbeBurstCount(candidate)
|
||||
for i := 0; i < count; i++ {
|
||||
if i > 0 {
|
||||
req, err = wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(req.Payload) != candidate {
|
||||
err = &testError{"wrong upload calibration size"}
|
||||
break
|
||||
}
|
||||
if err = wire.WriteResponse(conn, wire.StatusOK, nil); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
case wire.ProbeIperfDownload:
|
||||
for i := 0; i < wire.ProbeBurstCount(candidate); i++ {
|
||||
if err = wire.WriteMaskedResponse(conn, wire.StatusData, probePattern(candidate), req.Session, wire.ModeProbe, req.Seq+uint64(i)); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
case wire.ProbeKeepalive:
|
||||
for i := 0; i < 8; i++ {
|
||||
if i > 0 {
|
||||
req, err = wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err = wire.WriteResponse(conn, wire.StatusOK, nil); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
err = &testError{"unexpected calibration kind"}
|
||||
}
|
||||
_ = conn.Close()
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
opts := chunkClientOptions{
|
||||
startSize: 32,
|
||||
minSize: 32,
|
||||
maxSize: 1024,
|
||||
adaptive: true,
|
||||
shrinkAfter: 3,
|
||||
minPipeline: 1,
|
||||
maxPipeline: 1,
|
||||
txnTimeout: time.Second,
|
||||
forceMaxStart: true,
|
||||
skipPathProbe: false,
|
||||
}
|
||||
conn, err := openChunkTunnel(ln.Addr().String(), "", "example.com", 443, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := conn.(*chunkConn)
|
||||
if got := c.upSizer.Current(); got != 1024 {
|
||||
t.Fatalf("upload start=%d, want calibrated max 1024", got)
|
||||
}
|
||||
if got := c.downSizer.Current(); got != 1024 {
|
||||
t.Fatalf("download start=%d, want calibrated max 1024", got)
|
||||
}
|
||||
c.uploadLane.Close()
|
||||
c.downloadLane.Close()
|
||||
for err := range serverErr {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type testError struct{ message string }
|
||||
|
||||
func (e *testError) Error() string { return e.message }
|
||||
|
||||
func TestReconnectAutoLearnsFromRealReuseFailure(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
@@ -99,3 +259,260 @@ func TestBPAutoStartsPersistent(t *testing.T) {
|
||||
t.Fatalf("BP auto lane started auto=%t reconnectEvery=%d", lane.autoReconnect, lane.reconnectEvery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAscendingIperfFindsBoundaryWithoutFlood(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const threshold = 900000
|
||||
attempts := make(chan int, 64)
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
defer close(attempts)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req, err := wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
candidate := int(binary.BigEndian.Uint32(req.Payload[7:11]))
|
||||
attempts <- candidate
|
||||
if candidate > threshold {
|
||||
_ = wire.WriteResponse(conn, wire.StatusError, []byte("synthetic carrier rejection"))
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
count := wire.ProbeBurstCount(candidate)
|
||||
for i := 0; i < count; i++ {
|
||||
if i > 0 {
|
||||
req, err = wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err = wire.WriteResponse(conn, wire.StatusOK, nil); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
opts := chunkClientOptions{
|
||||
minSize: 32,
|
||||
maxSize: 1024 * 1024,
|
||||
shrinkAfter: 1,
|
||||
shrinkStep: 200,
|
||||
txnTimeout: time.Second,
|
||||
coverProfile: cover.Profile{},
|
||||
}
|
||||
got := probeMaximum(ln.Addr().String(), "", opts, wire.ProbeUpload)
|
||||
_ = ln.Close()
|
||||
<-serverDone
|
||||
|
||||
if got > threshold {
|
||||
t.Fatalf("calibrated size=%d exceeds threshold=%d", got, threshold)
|
||||
}
|
||||
if threshold-got > calibrationFineResolution {
|
||||
t.Fatalf("calibrated size=%d is more than %d bytes below threshold=%d", got, calibrationFineResolution, threshold)
|
||||
}
|
||||
var seen []int
|
||||
for candidate := range attempts {
|
||||
seen = append(seen, candidate)
|
||||
}
|
||||
if len(seen) > 40 {
|
||||
t.Fatalf("ascending calibration used %d probes, want <=40 with boundary confirmation; attempts=%v", len(seen), seen)
|
||||
}
|
||||
if len(seen) == 0 || seen[0] != 32 {
|
||||
t.Fatalf("first ascending probe=%v, want minimum 32", seen)
|
||||
}
|
||||
if len(seen) < 2 || seen[1] != 512 {
|
||||
t.Fatalf("second ascending probe=%v, want fast jump to 512", seen)
|
||||
}
|
||||
for i := 1; i < len(seen); i++ {
|
||||
if seen[i] > threshold {
|
||||
break
|
||||
}
|
||||
if seen[i] < seen[i-1] {
|
||||
t.Fatalf("coarse ascending phase moved backward: %v", seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAscendingIperfRetriesTransientFailureBeforeLoweringCeiling(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
const (
|
||||
threshold = 4096
|
||||
transientChunk = 2048
|
||||
)
|
||||
var transientAttempts int
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req, err := wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
candidate := int(binary.BigEndian.Uint32(req.Payload[7:11]))
|
||||
if candidate == transientChunk && transientAttempts == 0 {
|
||||
transientAttempts++
|
||||
_ = wire.WriteResponse(conn, wire.StatusError, []byte("synthetic transient rejection"))
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
if candidate > threshold {
|
||||
_ = wire.WriteResponse(conn, wire.StatusError, []byte("synthetic carrier rejection"))
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
count := wire.ProbeBurstCount(candidate)
|
||||
for i := 0; i < count; i++ {
|
||||
if i > 0 {
|
||||
req, err = wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err = wire.WriteResponse(conn, wire.StatusOK, nil); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
opts := chunkClientOptions{
|
||||
minSize: 32,
|
||||
maxSize: 16 * 1024,
|
||||
shrinkAfter: 1,
|
||||
shrinkStep: 200,
|
||||
txnTimeout: time.Second,
|
||||
coverProfile: cover.Profile{},
|
||||
}
|
||||
got := probeMaximum(ln.Addr().String(), "", opts, wire.ProbeUpload)
|
||||
_ = ln.Close()
|
||||
<-serverDone
|
||||
|
||||
if transientAttempts != 1 {
|
||||
t.Fatalf("transient failure count=%d, want 1", transientAttempts)
|
||||
}
|
||||
if got < transientChunk {
|
||||
t.Fatalf("calibration collapsed below transiently failed %d-byte probe: got %d", transientChunk, got)
|
||||
}
|
||||
if got > threshold || threshold-got > calibrationFineResolution {
|
||||
t.Fatalf("calibrated size=%d, want within %d bytes below threshold=%d", got, calibrationFineResolution, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxFirstIperfUsesBoundedCoarseFineSearch(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const threshold = 900000
|
||||
attempts := make(chan int, 64)
|
||||
serverDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(serverDone)
|
||||
defer close(attempts)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req, err := wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
candidate := int(binary.BigEndian.Uint32(req.Payload[7:11]))
|
||||
attempts <- candidate
|
||||
if candidate > threshold {
|
||||
_ = wire.WriteResponse(conn, wire.StatusError, []byte("synthetic carrier rejection"))
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
count := wire.ProbeBurstCount(candidate)
|
||||
for i := 0; i < count; i++ {
|
||||
if i > 0 {
|
||||
req, err = wire.ReadRequest(conn)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err = wire.WriteResponse(conn, wire.StatusOK, nil); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
opts := chunkClientOptions{
|
||||
minSize: 32,
|
||||
maxSize: 1024 * 1024,
|
||||
shrinkAfter: 1,
|
||||
shrinkStep: 200,
|
||||
txnTimeout: time.Second,
|
||||
coverProfile: cover.Profile{},
|
||||
}
|
||||
got := probeMaximumMaxFirst(ln.Addr().String(), "", opts, wire.ProbeUpload)
|
||||
_ = ln.Close()
|
||||
<-serverDone
|
||||
|
||||
if got > threshold {
|
||||
t.Fatalf("calibrated size=%d exceeds threshold=%d", got, threshold)
|
||||
}
|
||||
if threshold-got > calibrationFineResolution {
|
||||
t.Fatalf("calibrated size=%d is more than %d bytes below threshold=%d", got, calibrationFineResolution, threshold)
|
||||
}
|
||||
var seen []int
|
||||
for candidate := range attempts {
|
||||
seen = append(seen, candidate)
|
||||
}
|
||||
if len(seen) > 24 {
|
||||
t.Fatalf("calibration used %d probes, want <=24; attempts=%v", len(seen), seen)
|
||||
}
|
||||
if len(seen) == 0 || seen[0] != 1024*1024 {
|
||||
t.Fatalf("first probe=%v, want maximum %d", seen, 1024*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptiveSizerLinearShrinkStep(t *testing.T) {
|
||||
opts := chunkClientOptions{
|
||||
startSize: 1024,
|
||||
minSize: 256,
|
||||
maxSize: 1024,
|
||||
adaptive: true,
|
||||
shrinkAfter: 1,
|
||||
shrinkStep: 200,
|
||||
}
|
||||
s := newAdaptiveSizer("test", 1024, opts)
|
||||
_, next := s.Failure(1024)
|
||||
if next != 824 {
|
||||
t.Fatalf("linear failure should reduce 1024 -> 824, got %d", next)
|
||||
}
|
||||
_, next = s.Failure(824)
|
||||
if next != 624 {
|
||||
t.Fatalf("linear failure should reduce 824 -> 624, got %d", next)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,12 +357,77 @@ func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer i
|
||||
}
|
||||
}
|
||||
|
||||
const serverPortReachabilityTimeout = 900 * time.Millisecond
|
||||
|
||||
func serverPortReachable(host string, port int, tcpBuffer int) bool {
|
||||
d := net.Dialer{Timeout: serverPortReachabilityTimeout, KeepAlive: 30 * time.Second}
|
||||
conn, err := d.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
protocol.TuneTCP(conn)
|
||||
protocol.TuneTCPBuffer(conn, tcpBuffer)
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// selectServerEndpoint walks the user-configured port range in ascending order.
|
||||
// A port is not considered working merely because the TCP handshake succeeds:
|
||||
// the normal DragonTCP wire/header probe must also validate on that endpoint.
|
||||
// Only one port candidate is protocol-tested at a time; UP/DW calibration starts
|
||||
// only after a single endpoint has been locked.
|
||||
func selectServerEndpoint(
|
||||
host string,
|
||||
portStart int,
|
||||
portEnd int,
|
||||
token string,
|
||||
configuredWire string,
|
||||
binOpts chunkClientOptions,
|
||||
xorOpts xorchunk.Options,
|
||||
probeDelay time.Duration,
|
||||
probeThreads int,
|
||||
forceClear bool,
|
||||
tcpBuffer int,
|
||||
) (*wireSelector, string, int, error) {
|
||||
total := portEnd - portStart + 1
|
||||
fmt.Printf("[D-TCP] phase=PORT_SCAN state=starting host=%s start=%d end=%d total=%d protocol_validation=true\n", host, portStart, portEnd, total)
|
||||
|
||||
for port := portStart; port <= portEnd; port++ {
|
||||
index := port - portStart + 1
|
||||
// Keep logs readable for wide ranges: always show the first candidate,
|
||||
// every 16th candidate, and every TCP-reachable candidate.
|
||||
if index == 1 || index%16 == 0 || port == portEnd {
|
||||
fmt.Printf("[D-TCP] phase=PORT_SCAN state=testing candidate=%d progress=%d/%d\n", port, index, total)
|
||||
}
|
||||
if !serverPortReachable(host, port, tcpBuffer) {
|
||||
continue
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
fmt.Printf("[D-TCP] phase=PORT_SCAN state=tcp_reachable candidate=%d progress=%d/%d\n", port, index, total)
|
||||
selector := newWireSelector(configuredWire, addr, token, binOpts, xorOpts, probeDelay, probeThreads, forceClear)
|
||||
choice, err := selector.resolveOnly()
|
||||
if err != nil {
|
||||
fmt.Printf("[D-TCP] phase=PORT_SCAN state=rejected candidate=%d reason=no_validated_wire\n", port)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[D-TCP] phase=PORT_SCAN state=success port=%d wire=%s header_mask=%02x clear_payload=%t\n", port, choice.mode, choice.mask, choice.cover.Clear)
|
||||
return selector, addr, port, nil
|
||||
}
|
||||
|
||||
fmt.Printf("[D-TCP] phase=PORT_SCAN state=failed start=%d end=%d reason=no_working_dragontcp_port\n", portStart, portEnd)
|
||||
return nil, "", 0, fmt.Errorf("no working DragonTCP port found in %d-%d", portStart, portEnd)
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
listenHost = flag.String("listen-host", "127.0.0.1", "local proxy listen host")
|
||||
listenPort = flag.Int("listen-port", 8080, "local proxy listen port")
|
||||
serverHost = flag.String("server-host", "", "remote DragonTCP server host")
|
||||
serverPort = flag.Int("server-port", 53, "remote DragonTCP server port")
|
||||
serverPort = flag.Int("server-port", 53, "remote DragonTCP server port; used when no range is supplied")
|
||||
serverPortStart = flag.Int("server-port-start", 0, "first remote DragonTCP port to scan; 0 uses --server-port")
|
||||
serverPortEnd = flag.Int("server-port-end", 0, "last remote DragonTCP port to scan; 0 uses the resolved start port")
|
||||
token = flag.String("token", "", "optional shared token")
|
||||
maxConnections = flag.Int("max-connections", 20000, "max simultaneous proxy connections")
|
||||
transport = flag.String("transport", "chunk", "transport: chunk (DragonTCP binary adaptive transport)")
|
||||
@@ -372,6 +437,9 @@ func main() {
|
||||
chunkMax = flag.Int("chunk-max", 1048576, "maximum adaptive chunk payload bytes (up to 1 MiB)")
|
||||
chunkAdaptive = flag.Bool("chunk-adaptive", true, "automatically shrink on failures and grow after stable success")
|
||||
chunkSuccesses = flag.Int("chunk-grow-after", 16, "successful data records required before increasing chunk size")
|
||||
chunkShrinkAfter = flag.Int("chunk-shrink-after", 1, "consecutive recoverable transfer failures required before reducing chunk size")
|
||||
chunkShrinkStep = flag.Int("chunk-shrink-step", 200, "bytes to subtract on each recoverable runtime chunk failure; Android uses 200")
|
||||
chunkMaxFirst = flag.Bool("chunk-max-first", false, "legacy CLI-only max-first calibration; Android uses ascending calibration")
|
||||
chunkAdaptLog = flag.Bool("chunk-adapt-log", true, "print adaptive chunk size changes")
|
||||
chunkSizeLegacy = flag.Int("chunk-size", 0, "legacy fixed chunk size; nonzero disables adaptation")
|
||||
chunkPollers = flag.Int("chunk-pollers", 1, "reserved compatibility setting; binary transport uses one download worker")
|
||||
@@ -379,17 +447,48 @@ func main() {
|
||||
chunkConcurrencyMin = flag.Int("chunk-concurrency-min", 1, "minimum download records per request (1-256); equal to --chunk-concurrency pins the depth")
|
||||
chunkReconnect = flag.Int("chunk-reconnect-every", 0, "connection reuse: 0 persistent, 1 auto-learn, N rotate after N requests")
|
||||
chunkPollDelay = flag.Duration("chunk-poll-delay", 2*time.Millisecond, "delay after an empty chunk poll")
|
||||
chunkTimeout = flag.Duration("chunk-timeout", 5*time.Second, "per-record transaction timeout before adaptive shrink")
|
||||
chunkTimeout = flag.Duration("chunk-timeout", 5*time.Second, "per-record transaction timeout; a real timeout terminates the tunnel")
|
||||
wireMode = flag.String("wire", "auto", "wire mode: b, bp, x, or auto (probe and pick)")
|
||||
wireProbeDelay = flag.Duration("wire-probe-delay", time.Second, "minimum delay between wire profile probe starts (200ms-30s)")
|
||||
forceClearPayload = flag.Bool("force-clear-payload", false, "force B/BP clear payloads and disable the SHA-256 payload mask; no masked fallback")
|
||||
wireProbeDelay = flag.Duration("wire-probe-delay", 100*time.Millisecond, "minimum delay between header/profile probe starts (50ms-30s)")
|
||||
wireProbeThreads = flag.Int("wire-probe-threads", 1, "maximum concurrent wire profile probes (1-16)")
|
||||
|
||||
sshUser = flag.String("ssh-user", "", "SSH tunnel username; enables tunnel-only SSH/SOCKS mode")
|
||||
sshPassword = flag.String("ssh-password", "", "SSH tunnel password")
|
||||
sshPasswordEnv = flag.String("ssh-password-env", "", "environment variable containing the SSH tunnel password")
|
||||
sshInternalHost = flag.String("ssh-internal-host", defaultSSHInternalHostClient, "reserved DragonTCP target for internal SSH")
|
||||
sshInternalPort = flag.Int("ssh-internal-port", 2222, "internal fake SSH port on the DragonTCP server")
|
||||
sshHostKeyPin = flag.String("ssh-hostkey-pin-file", "", "TOFU SSH host-key fingerprint file")
|
||||
sshSocksHost = flag.String("ssh-socks-host", "127.0.0.1", "local SOCKS5 listen host when SSH mode is enabled")
|
||||
sshSocksPort = flag.Int("ssh-socks-port", 1080, "local SOCKS5 listen port when SSH mode is enabled")
|
||||
sshUDPGWHost = flag.String("ssh-udpgw-host", "dragontcp-udpgw.internal", "reserved UDPGW target as seen by the SSH server")
|
||||
sshUDPGWPort = flag.Int("ssh-udpgw-port", 7400, "UDPGW port as seen by the SSH server")
|
||||
)
|
||||
flag.Parse()
|
||||
if strings.TrimSpace(*sshPasswordEnv) != "" {
|
||||
*sshPassword = os.Getenv(strings.TrimSpace(*sshPasswordEnv))
|
||||
}
|
||||
|
||||
if *serverHost == "" {
|
||||
fmt.Fprintln(os.Stderr, "--server-host is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
resolvedPortStart := *serverPortStart
|
||||
resolvedPortEnd := *serverPortEnd
|
||||
if resolvedPortStart == 0 {
|
||||
resolvedPortStart = *serverPort
|
||||
}
|
||||
if resolvedPortEnd == 0 {
|
||||
resolvedPortEnd = resolvedPortStart
|
||||
}
|
||||
if resolvedPortStart < 1 || resolvedPortStart > 65535 || resolvedPortEnd < 1 || resolvedPortEnd > 65535 {
|
||||
fmt.Fprintln(os.Stderr, "server ports must be between 1 and 65535")
|
||||
os.Exit(2)
|
||||
}
|
||||
if resolvedPortStart > resolvedPortEnd {
|
||||
fmt.Fprintln(os.Stderr, "--server-port-start must not exceed --server-port-end")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
*transport = strings.ToLower(*transport)
|
||||
if *transport != "chunk" {
|
||||
@@ -406,6 +505,11 @@ func main() {
|
||||
*chunkMax = *chunkSizeLegacy
|
||||
*chunkAdaptive = false
|
||||
}
|
||||
if *chunkMaxFirst {
|
||||
// Retained for CLI compatibility only. The Android build never enables
|
||||
// this flag; its startup calibration is always ascending.
|
||||
*chunkStart = *chunkMax
|
||||
}
|
||||
if *chunkMin < 32 || *chunkMax > protocol.MaxChunkPayload || *chunkMin > *chunkStart || *chunkStart > *chunkMax {
|
||||
fmt.Fprintf(os.Stderr, "require 32 <= --chunk-min <= --chunk-start <= --chunk-max <= %d\n", protocol.MaxChunkPayload)
|
||||
os.Exit(2)
|
||||
@@ -414,6 +518,23 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "--chunk-grow-after must be at least 1")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *chunkShrinkAfter < 1 || *chunkShrinkAfter > 32 {
|
||||
fmt.Fprintln(os.Stderr, "--chunk-shrink-after must be between 1 and 32")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *chunkShrinkStep < 0 || *chunkShrinkStep > protocol.MaxChunkPayload {
|
||||
fmt.Fprintf(os.Stderr, "--chunk-shrink-step must be between 0 and %d bytes\n", protocol.MaxChunkPayload)
|
||||
os.Exit(2)
|
||||
}
|
||||
if *chunkMaxFirst {
|
||||
// MAX-FIRST always uses one recoverable failure as a signal to move
|
||||
// to the next calibration candidate. shrinkStep is the final boundary
|
||||
// resolution and the runtime linear recovery step.
|
||||
if *chunkShrinkStep == 0 {
|
||||
*chunkShrinkStep = maxFirstFineResolution
|
||||
}
|
||||
*chunkShrinkAfter = 1
|
||||
}
|
||||
if *chunkPollers < 1 || *chunkPollers > 128 {
|
||||
fmt.Fprintln(os.Stderr, "--chunk-pollers must be between 1 and 128")
|
||||
os.Exit(2)
|
||||
@@ -443,24 +564,42 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "--wire must be b, bp, x or auto")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *forceClearPayload && *wireMode == WireXOR {
|
||||
fmt.Fprintln(os.Stderr, "--force-clear-payload cannot be used with --wire x; use auto, b, or bp")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *chunkReconnect < 0 {
|
||||
fmt.Fprintln(os.Stderr, "--chunk-reconnect-every must be 0 or greater")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *wireProbeDelay < 200*time.Millisecond || *wireProbeDelay > 30*time.Second {
|
||||
fmt.Fprintln(os.Stderr, "--wire-probe-delay must be between 200ms and 30s")
|
||||
if *wireProbeDelay < 50*time.Millisecond || *wireProbeDelay > 30*time.Second {
|
||||
fmt.Fprintln(os.Stderr, "--wire-probe-delay must be between 50ms and 30s")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *wireProbeThreads < 1 || *wireProbeThreads > 16 {
|
||||
fmt.Fprintln(os.Stderr, "--wire-probe-threads must be between 1 and 16")
|
||||
os.Exit(2)
|
||||
}
|
||||
sshEnabled := strings.TrimSpace(*sshUser) != ""
|
||||
var err error
|
||||
if sshEnabled {
|
||||
if *sshPassword == "" {
|
||||
fmt.Fprintln(os.Stderr, "--ssh-password is required when --ssh-user is set")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *sshInternalPort < 1 || *sshInternalPort > 65535 || *sshSocksPort < 1 || *sshSocksPort > 65535 || *sshUDPGWPort < 1 || *sshUDPGWPort > 65535 {
|
||||
fmt.Fprintln(os.Stderr, "SSH/SOCKS/UDPGW ports must be between 1 and 65535")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
chunkOpts := chunkClientOptions{
|
||||
startSize: *chunkStart,
|
||||
minSize: *chunkMin,
|
||||
maxSize: *chunkMax,
|
||||
adaptive: *chunkAdaptive,
|
||||
adaptSuccesses: *chunkSuccesses,
|
||||
shrinkAfter: *chunkShrinkAfter,
|
||||
shrinkStep: *chunkShrinkStep,
|
||||
adaptLog: *chunkAdaptLog,
|
||||
pollers: *chunkPollers,
|
||||
minPipeline: *chunkConcurrencyMin,
|
||||
@@ -469,25 +608,17 @@ func main() {
|
||||
pollDelay: *chunkPollDelay,
|
||||
txnTimeout: *chunkTimeout,
|
||||
tcpBuffer: *tcpBuffer,
|
||||
forceMaxStart: *chunkMaxFirst,
|
||||
}
|
||||
|
||||
xorOpts := xorchunk.NewOptions(
|
||||
*chunkStart, *chunkMin, *chunkMax, *chunkAdaptive, *chunkSuccesses, *chunkAdaptLog,
|
||||
*chunkStart, *chunkMin, *chunkMax, *chunkAdaptive, *chunkSuccesses, *chunkShrinkAfter, *chunkAdaptLog,
|
||||
*chunkPollers, *chunkReconnect, *chunkPollDelay, *chunkTimeout, *tcpBuffer,
|
||||
)
|
||||
|
||||
listenAddr := net.JoinHostPort(*listenHost, strconv.Itoa(*listenPort))
|
||||
serverAddr := net.JoinHostPort(*serverHost, strconv.Itoa(*serverPort))
|
||||
|
||||
ln, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
fmt.Printf("local Go HTTP proxy listening on %s\n", listenAddr)
|
||||
fmt.Printf("remote DragonTCP endpoint=%s\n", serverAddr)
|
||||
fmt.Printf("remote DragonTCP host=%s port_range=%d-%d\n", *serverHost, resolvedPortStart, resolvedPortEnd)
|
||||
fmt.Printf("max_connections=%d transport=%s tcp_buffer=%d\n", *maxConnections, *transport, *tcpBuffer)
|
||||
if *transport == "chunk" {
|
||||
batchMode := "adaptive"
|
||||
@@ -495,12 +626,15 @@ func main() {
|
||||
batchMode = "pinned"
|
||||
}
|
||||
fmt.Printf(
|
||||
"adaptive_chunk=%v start=%d min=%d max=%d grow_after=%d pollers=%d batch=%d-%d(%s) reconnect_every=%d timeout=%s\n",
|
||||
"adaptive_chunk=%v start=%d min=%d max=%d grow_after=%d shrink_after=%d shrink_step=%d max_first=%v pollers=%d batch=%d-%d(%s) reconnect_every=%d timeout=%s\n",
|
||||
*chunkAdaptive,
|
||||
*chunkStart,
|
||||
*chunkMin,
|
||||
*chunkMax,
|
||||
*chunkSuccesses,
|
||||
*chunkShrinkAfter,
|
||||
*chunkShrinkStep,
|
||||
*chunkMaxFirst,
|
||||
*chunkPollers,
|
||||
*chunkConcurrencyMin,
|
||||
*chunkConcurrency,
|
||||
@@ -510,11 +644,65 @@ func main() {
|
||||
)
|
||||
}
|
||||
|
||||
wires := newWireSelector(*wireMode, serverAddr, *token, chunkOpts, xorOpts, *wireProbeDelay, *wireProbeThreads)
|
||||
fmt.Printf("wire=%s discovering fixed header profile via http://%s/ probe_delay=%s probe_threads=%d\n", *wireMode, probeHost, wireProbeDelay.String(), *wireProbeThreads)
|
||||
// Discover in the background so the local listener starts immediately. A
|
||||
// connection arriving first waits on the same selector lock and result.
|
||||
go wires.mode()
|
||||
if *forceClearPayload {
|
||||
fmt.Printf("wire=%s force_clear_payload=true payload_sha256=false clear_header_order=00,25 protocol_probe=server-local\n", *wireMode)
|
||||
} else {
|
||||
fmt.Printf("wire=%s discovering fixed header profile full_range=00-ff protocol_probe=server-local probe_delay=%s probe_threads=%d force_clear_payload=false\n", *wireMode, wireProbeDelay.String(), *wireProbeThreads)
|
||||
}
|
||||
|
||||
// Port discovery happens before chunk calibration. A TCP-open port must also
|
||||
// pass the DragonTCP wire/header probe before it becomes the WORKING PORT.
|
||||
wires, serverAddr, selectedPort, err := selectServerEndpoint(
|
||||
*serverHost, resolvedPortStart, resolvedPortEnd, *token, *wireMode,
|
||||
chunkOpts, xorOpts, *wireProbeDelay, *wireProbeThreads, *forceClearPayload, *tcpBuffer,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "DragonTCP port discovery failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("remote DragonTCP endpoint=%s working_port=%d\n", serverAddr, selectedPort)
|
||||
|
||||
// Resolve/authenticate the already-selected wire and calibrate UP/DW before
|
||||
// exposing the local proxy. resolveOnly() cached the profile, so prepare()
|
||||
// performs no second header scan.
|
||||
if _, err := wires.prepare(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "DragonTCP startup preflight failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var sshManager *sshTunnelManager
|
||||
var socksListener net.Listener
|
||||
if strings.TrimSpace(*sshUser) != "" {
|
||||
sshManager, err = newSSHTunnelManager(wires, *sshUser, *sshPassword, *sshInternalHost, *sshInternalPort, *sshHostKeyPin, *sshUDPGWHost, *sshUDPGWPort)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
defer sshManager.Close()
|
||||
// Validate the complete DragonTCP -> SSH path before advertising the
|
||||
// local SOCKS endpoint. Previously the Android UI could say "SSH tunnel
|
||||
// ready" even though no SSH handshake had happened yet.
|
||||
if err = sshManager.Warmup(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SSH startup failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
socksAddr := net.JoinHostPort(*sshSocksHost, strconv.Itoa(*sshSocksPort))
|
||||
socksListener, err = startSOCKS5Proxy(socksAddr, sshManager, *maxConnections, *tcpBuffer)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SOCKS5 listen failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer socksListener.Close()
|
||||
fmt.Printf("ssh_mode=true local_socks5=%s internal_ssh=%s:%d udpgw=%s:%d user=%s\n", socksAddr, *sshInternalHost, *sshInternalPort, *sshUDPGWHost, *sshUDPGWPort, *sshUser)
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ln.Close()
|
||||
fmt.Printf("local Go HTTP proxy listening on %s\n", listenAddr)
|
||||
|
||||
slots := make(chan struct{}, *maxConnections)
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dragontcp/internal/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
socksVersion5 = 5
|
||||
socksCmdConnect = 1
|
||||
socksCmdUDPAssociate = 3
|
||||
socksAtypIPv4 = 1
|
||||
socksAtypDomain = 3
|
||||
socksAtypIPv6 = 4
|
||||
)
|
||||
|
||||
func startSOCKS5Proxy(listenAddr string, manager *sshTunnelManager, maxConnections int, tcpBuffer int) (net.Listener, error) {
|
||||
ln, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if maxConnections < 1 {
|
||||
maxConnections = 1
|
||||
}
|
||||
slots := make(chan struct{}, maxConnections)
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
go func(c net.Conn) {
|
||||
defer func() { <-slots; _ = c.Close() }()
|
||||
protocol.TuneTCP(c)
|
||||
protocol.TuneTCPBuffer(c, tcpBuffer)
|
||||
_ = handleSOCKS5(c, manager)
|
||||
}(conn)
|
||||
default:
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
func handleSOCKS5(conn net.Conn, manager *sshTunnelManager) error {
|
||||
br := bufio.NewReaderSize(conn, 4096)
|
||||
if err := socksNegotiate(br, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd, host, port, err := socksReadRequest(br)
|
||||
if err != nil {
|
||||
_ = socksReply(conn, 1, nil)
|
||||
return err
|
||||
}
|
||||
switch cmd {
|
||||
case socksCmdConnect:
|
||||
remote, err := manager.DialTCP(host, port)
|
||||
if err != nil {
|
||||
_ = socksReply(conn, 5, nil)
|
||||
return err
|
||||
}
|
||||
defer remote.Close()
|
||||
if err := socksReply(conn, 0, remote.LocalAddr()); err != nil {
|
||||
return err
|
||||
}
|
||||
protocol.RelayRaw(conn, remote)
|
||||
return nil
|
||||
case socksCmdUDPAssociate:
|
||||
return handleSOCKSUDPAssociate(conn, br, manager)
|
||||
default:
|
||||
_ = socksReply(conn, 7, nil)
|
||||
return fmt.Errorf("SOCKS command %d unsupported", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func socksNegotiate(br *bufio.Reader, w io.Writer) error {
|
||||
header := make([]byte, 2)
|
||||
if _, err := io.ReadFull(br, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if header[0] != socksVersion5 || header[1] == 0 {
|
||||
return errors.New("invalid SOCKS5 greeting")
|
||||
}
|
||||
methods := make([]byte, int(header[1]))
|
||||
if _, err := io.ReadFull(br, methods); err != nil {
|
||||
return err
|
||||
}
|
||||
noAuth := false
|
||||
for _, method := range methods {
|
||||
if method == 0 {
|
||||
noAuth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !noAuth {
|
||||
_, _ = w.Write([]byte{5, 0xff})
|
||||
return errors.New("SOCKS5 client does not support no-auth")
|
||||
}
|
||||
_, err := w.Write([]byte{5, 0})
|
||||
return err
|
||||
}
|
||||
|
||||
func socksReadRequest(br *bufio.Reader) (cmd byte, host string, port int, err error) {
|
||||
header := make([]byte, 4)
|
||||
if _, err = io.ReadFull(br, header); err != nil {
|
||||
return
|
||||
}
|
||||
if header[0] != 5 || header[2] != 0 {
|
||||
err = errors.New("invalid SOCKS5 request")
|
||||
return
|
||||
}
|
||||
cmd = header[1]
|
||||
host, err = socksReadHost(br, header[3])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var portBuf [2]byte
|
||||
if _, err = io.ReadFull(br, portBuf[:]); err != nil {
|
||||
return
|
||||
}
|
||||
port = int(binary.BigEndian.Uint16(portBuf[:]))
|
||||
// CONNECT requires a real destination port. UDP ASSOCIATE commonly uses
|
||||
// 0.0.0.0:0 to ask the proxy to choose the relay endpoint, which is exactly
|
||||
// what the Android VPN adapter sends.
|
||||
if cmd == socksCmdConnect && port < 1 {
|
||||
err = errors.New("invalid SOCKS5 port")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func socksReadHost(r io.Reader, atyp byte) (string, error) {
|
||||
switch atyp {
|
||||
case socksAtypIPv4:
|
||||
b := make([]byte, 4)
|
||||
if _, err := io.ReadFull(r, b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return net.IP(b).String(), nil
|
||||
case socksAtypIPv6:
|
||||
b := make([]byte, 16)
|
||||
if _, err := io.ReadFull(r, b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return net.IP(b).String(), nil
|
||||
case socksAtypDomain:
|
||||
var n [1]byte
|
||||
if _, err := io.ReadFull(r, n[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n[0] == 0 {
|
||||
return "", errors.New("empty SOCKS domain")
|
||||
}
|
||||
b := make([]byte, int(n[0]))
|
||||
if _, err := io.ReadFull(r, b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported SOCKS address type %d", atyp)
|
||||
}
|
||||
}
|
||||
|
||||
func socksReply(w io.Writer, rep byte, addr net.Addr) error {
|
||||
ip := net.IPv4zero
|
||||
port := 0
|
||||
if tcpAddr, ok := addr.(*net.TCPAddr); ok {
|
||||
if v4 := tcpAddr.IP.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
port = tcpAddr.Port
|
||||
} else if udpAddr, ok := addr.(*net.UDPAddr); ok {
|
||||
if v4 := udpAddr.IP.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
port = udpAddr.Port
|
||||
}
|
||||
out := []byte{5, rep, 0, socksAtypIPv4, 0, 0, 0, 0, 0, 0}
|
||||
copy(out[4:8], ip.To4())
|
||||
binary.BigEndian.PutUint16(out[8:10], uint16(port))
|
||||
_, err := w.Write(out)
|
||||
return err
|
||||
}
|
||||
|
||||
func handleSOCKSUDPAssociate(control net.Conn, br *bufio.Reader, manager *sshTunnelManager) error {
|
||||
udp, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer udp.Close()
|
||||
if err := socksReply(control, 0, udp.LocalAddr()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gw, err := manager.DialUDPGW()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gw.Close()
|
||||
_ = gw.SetDeadline(time.Time{})
|
||||
|
||||
done := make(chan struct{})
|
||||
var closeOnce sync.Once
|
||||
closeAll := func() { closeOnce.Do(func() { close(done); _ = udp.Close(); _ = gw.Close() }) }
|
||||
defer closeAll()
|
||||
|
||||
var clientMu sync.RWMutex
|
||||
var clientAddr *net.UDPAddr
|
||||
writeMu := sync.Mutex{}
|
||||
|
||||
go func() {
|
||||
defer closeAll()
|
||||
reader := bufio.NewReaderSize(gw, 32*1024)
|
||||
for {
|
||||
payload, err := readUDPGWFrame(reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(payload) < 9 {
|
||||
continue
|
||||
}
|
||||
srcIP := payload[3:7]
|
||||
srcPort := binary.BigEndian.Uint16(payload[7:9])
|
||||
data := payload[9:]
|
||||
packet := make([]byte, 10+len(data))
|
||||
packet[0], packet[1], packet[2], packet[3] = 0, 0, 0, socksAtypIPv4
|
||||
copy(packet[4:8], srcIP)
|
||||
binary.BigEndian.PutUint16(packet[8:10], srcPort)
|
||||
copy(packet[10:], data)
|
||||
clientMu.RLock()
|
||||
to := clientAddr
|
||||
clientMu.RUnlock()
|
||||
if to != nil {
|
||||
_, _ = udp.WriteToUDP(packet, to)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer closeAll()
|
||||
// The UDP association lifetime is the TCP control connection lifetime.
|
||||
buf := make([]byte, 1)
|
||||
for {
|
||||
if _, err := br.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, from, err := udp.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
clientMu.Lock()
|
||||
clientAddr = from
|
||||
clientMu.Unlock()
|
||||
ip, port, payload, err := parseSOCKSUDPDatagram(buf[:n])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
frame := buildUDPGWRequest(1, 0, ip, uint16(port), payload)
|
||||
writeMu.Lock()
|
||||
_, err = gw.Write(frame)
|
||||
writeMu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseSOCKSUDPDatagram(packet []byte) ([4]byte, int, []byte, error) {
|
||||
var out [4]byte
|
||||
if len(packet) < 10 || packet[0] != 0 || packet[1] != 0 || packet[2] != 0 {
|
||||
return out, 0, nil, errors.New("invalid SOCKS5 UDP packet")
|
||||
}
|
||||
pos := 3
|
||||
atyp := packet[pos]
|
||||
pos++
|
||||
switch atyp {
|
||||
case socksAtypIPv4:
|
||||
if len(packet) < pos+4+2 {
|
||||
return out, 0, nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
copy(out[:], packet[pos:pos+4])
|
||||
pos += 4
|
||||
case socksAtypDomain:
|
||||
if len(packet) <= pos {
|
||||
return out, 0, nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
n := int(packet[pos])
|
||||
pos++
|
||||
if len(packet) < pos+n+2 {
|
||||
return out, 0, nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
return out, 0, nil, errors.New("SOCKS UDP domain destinations are disabled to avoid local DNS leakage; use an IPv4 destination")
|
||||
case socksAtypIPv6:
|
||||
return out, 0, nil, errors.New("UDPGW supports IPv4 only")
|
||||
default:
|
||||
return out, 0, nil, errors.New("unsupported SOCKS UDP address type")
|
||||
}
|
||||
port := int(binary.BigEndian.Uint16(packet[pos : pos+2]))
|
||||
pos += 2
|
||||
return out, port, packet[pos:], nil
|
||||
}
|
||||
|
||||
func buildUDPGWRequest(connID uint16, x byte, ip [4]byte, port uint16, data []byte) []byte {
|
||||
payloadLen := 9 + len(data)
|
||||
frame := make([]byte, 2+payloadLen)
|
||||
binary.LittleEndian.PutUint16(frame[0:2], uint16(payloadLen))
|
||||
binary.BigEndian.PutUint16(frame[2:4], connID)
|
||||
frame[4] = x
|
||||
copy(frame[5:9], ip[:])
|
||||
binary.BigEndian.PutUint16(frame[9:11], port)
|
||||
copy(frame[11:], data)
|
||||
return frame
|
||||
}
|
||||
|
||||
func readUDPGWFrame(r *bufio.Reader) ([]byte, error) {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint16(lenBuf[:]))
|
||||
if n < 9 || n > 65535 {
|
||||
return nil, fmt.Errorf("invalid UDPGW frame %d", n)
|
||||
}
|
||||
payload := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUDPGWFrameRoundTripShape(t *testing.T) {
|
||||
ip := [4]byte{1, 2, 3, 4}
|
||||
data := []byte("dragon")
|
||||
frame := buildUDPGWRequest(7, 0, ip, 5353, data)
|
||||
payload, err := readUDPGWFrame(bufio.NewReader(bytes.NewReader(frame)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := binary.BigEndian.Uint16(payload[0:2]); got != 7 {
|
||||
t.Fatalf("conn id=%d", got)
|
||||
}
|
||||
if !bytes.Equal(payload[3:7], ip[:]) {
|
||||
t.Fatalf("ip=%v", payload[3:7])
|
||||
}
|
||||
if got := binary.BigEndian.Uint16(payload[7:9]); got != 5353 {
|
||||
t.Fatalf("port=%d", got)
|
||||
}
|
||||
if !bytes.Equal(payload[9:], data) {
|
||||
t.Fatalf("data=%q", payload[9:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSOCKSUDPDatagramIPv4(t *testing.T) {
|
||||
packet := []byte{0, 0, 0, socksAtypIPv4, 8, 8, 8, 8, 0, 53, 1, 2, 3}
|
||||
ip, port, data, err := parseSOCKSUDPDatagram(packet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ip != [4]byte{8, 8, 8, 8} {
|
||||
t.Fatalf("ip=%v", ip)
|
||||
}
|
||||
if port != 53 {
|
||||
t.Fatalf("port=%d", port)
|
||||
}
|
||||
if !bytes.Equal(data, []byte{1, 2, 3}) {
|
||||
t.Fatalf("data=%v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSOCKSUDPDatagramRejectsDomainToAvoidDNSLeak(t *testing.T) {
|
||||
packet := []byte{0, 0, 0, socksAtypDomain, 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 0, 53, 1}
|
||||
_, _, _, err := parseSOCKSUDPDatagram(packet)
|
||||
if err == nil {
|
||||
t.Fatal("expected domain-form UDP destination to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCKSReadRequestAllowsZeroPortForUDPAssociate(t *testing.T) {
|
||||
request := []byte{5, socksCmdUDPAssociate, 0, socksAtypIPv4, 0, 0, 0, 0, 0, 0}
|
||||
cmd, host, port, err := socksReadRequest(bufio.NewReader(bytes.NewReader(request)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd != socksCmdUDPAssociate || host != "0.0.0.0" || port != 0 {
|
||||
t.Fatalf("got cmd=%d host=%q port=%d", cmd, host, port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sshCarrierConn absorbs the relatively small encrypted writes produced by the
|
||||
// SSH packet layer and combines them before handing them to DragonTCP.
|
||||
//
|
||||
// This matters because a DragonTCP upload is a request/ack transaction. Without
|
||||
// write combining, one SSH packet can become one full network round trip even
|
||||
// when the discovered DragonTCP path supports much larger chunks. The wrapper
|
||||
// deliberately behaves like a kernel socket send buffer: Write returns after
|
||||
// the bytes have been copied into a bounded queue, while a single ordered
|
||||
// writer drains that queue to the underlying DragonTCP stream.
|
||||
type sshCarrierConn struct {
|
||||
raw net.Conn
|
||||
flushBytes int
|
||||
maxBuffered int
|
||||
flushDelay time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
buf []byte
|
||||
closing bool
|
||||
writeErr error
|
||||
coalesceLogged bool
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func newSSHCarrierConn(raw net.Conn, flushBytes, maxBuffered int, flushDelay time.Duration) net.Conn {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
if flushBytes < 32*1024 {
|
||||
flushBytes = 32 * 1024
|
||||
}
|
||||
if maxBuffered < flushBytes*2 {
|
||||
maxBuffered = flushBytes * 2
|
||||
}
|
||||
if flushDelay <= 0 {
|
||||
flushDelay = time.Millisecond
|
||||
}
|
||||
c := &sshCarrierConn{
|
||||
raw: raw,
|
||||
flushBytes: flushBytes,
|
||||
maxBuffered: maxBuffered,
|
||||
flushDelay: flushDelay,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
c.cond = sync.NewCond(&c.mu)
|
||||
go c.writeLoop()
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *sshCarrierConn) writeLoop() {
|
||||
defer close(c.done)
|
||||
defer c.raw.Close()
|
||||
|
||||
for {
|
||||
c.mu.Lock()
|
||||
for len(c.buf) == 0 && !c.closing && c.writeErr == nil {
|
||||
c.cond.Wait()
|
||||
}
|
||||
if c.writeErr != nil {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if len(c.buf) == 0 && c.closing {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
shouldDelay := len(c.buf) < c.flushBytes && !c.closing
|
||||
c.mu.Unlock()
|
||||
|
||||
// Give consecutive SSH packets a very small window to accumulate. The
|
||||
// delay is tiny compared with a WAN RTT, but it lets 32 KiB SSH packets
|
||||
// become a 256 KiB-1 MiB DragonTCP write on a busy stream.
|
||||
if shouldDelay {
|
||||
time.Sleep(c.flushDelay)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.writeErr != nil {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
n := len(c.buf)
|
||||
if n > c.flushBytes {
|
||||
n = c.flushBytes
|
||||
}
|
||||
batch := make([]byte, n)
|
||||
copy(batch, c.buf[:n])
|
||||
if n == len(c.buf) {
|
||||
c.buf = c.buf[:0]
|
||||
} else {
|
||||
copy(c.buf, c.buf[n:])
|
||||
c.buf = c.buf[:len(c.buf)-n]
|
||||
}
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
|
||||
if !c.coalesceLogged && len(batch) >= 128*1024 {
|
||||
c.coalesceLogged = true
|
||||
fmt.Printf("ssh carrier: packet coalescing active batch=%d\n", len(batch))
|
||||
}
|
||||
|
||||
if err := writeCarrierFull(c.raw, batch); err != nil {
|
||||
c.mu.Lock()
|
||||
if c.writeErr == nil {
|
||||
c.writeErr = err
|
||||
}
|
||||
c.closing = true
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeCarrierFull(w io.Writer, p []byte) error {
|
||||
for len(p) > 0 {
|
||||
n, err := w.Write(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
p = p[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *sshCarrierConn) Read(p []byte) (int, error) {
|
||||
n, err := c.raw.Read(p)
|
||||
if n > 0 || err == nil {
|
||||
return n, err
|
||||
}
|
||||
c.mu.Lock()
|
||||
writeErr := c.writeErr
|
||||
c.mu.Unlock()
|
||||
if writeErr != nil {
|
||||
return 0, writeErr
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *sshCarrierConn) Write(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
written := 0
|
||||
for written < len(p) {
|
||||
c.mu.Lock()
|
||||
for len(c.buf) >= c.maxBuffered && !c.closing && c.writeErr == nil {
|
||||
c.cond.Wait()
|
||||
}
|
||||
if c.writeErr != nil {
|
||||
err := c.writeErr
|
||||
c.mu.Unlock()
|
||||
return written, err
|
||||
}
|
||||
if c.closing {
|
||||
c.mu.Unlock()
|
||||
if written > 0 {
|
||||
return written, net.ErrClosed
|
||||
}
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
room := c.maxBuffered - len(c.buf)
|
||||
if room < 1 {
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
take := len(p) - written
|
||||
if take > room {
|
||||
take = room
|
||||
}
|
||||
c.buf = append(c.buf, p[written:written+take]...)
|
||||
written += take
|
||||
c.cond.Signal()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func (c *sshCarrierConn) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
c.mu.Lock()
|
||||
c.closing = true
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
<-c.done
|
||||
})
|
||||
c.mu.Lock()
|
||||
err := c.writeErr
|
||||
c.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *sshCarrierConn) LocalAddr() net.Addr { return c.raw.LocalAddr() }
|
||||
func (c *sshCarrierConn) RemoteAddr() net.Addr { return c.raw.RemoteAddr() }
|
||||
func (c *sshCarrierConn) SetDeadline(t time.Time) error { return c.raw.SetDeadline(t) }
|
||||
func (c *sshCarrierConn) SetReadDeadline(t time.Time) error { return c.raw.SetReadDeadline(t) }
|
||||
func (c *sshCarrierConn) SetWriteDeadline(t time.Time) error { return c.raw.SetWriteDeadline(t) }
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type carrierTestConn struct {
|
||||
mu sync.Mutex
|
||||
writes int
|
||||
buf bytes.Buffer
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *carrierTestConn) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (c *carrierTestConn) Write(p []byte) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.closed {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
c.writes++
|
||||
return c.buf.Write(p)
|
||||
}
|
||||
func (c *carrierTestConn) Close() error { c.mu.Lock(); c.closed = true; c.mu.Unlock(); return nil }
|
||||
func (c *carrierTestConn) LocalAddr() net.Addr { return dummyAddr("local") }
|
||||
func (c *carrierTestConn) RemoteAddr() net.Addr { return dummyAddr("remote") }
|
||||
func (c *carrierTestConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *carrierTestConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *carrierTestConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func TestSSHCarrierCombinesPacketWrites(t *testing.T) {
|
||||
raw := &carrierTestConn{}
|
||||
conn := newSSHCarrierConn(raw, 128*1024, 512*1024, 5*time.Millisecond)
|
||||
want := make([]byte, 0, 128*1024)
|
||||
for i := 0; i < 4; i++ {
|
||||
part := bytes.Repeat([]byte{byte(i + 1)}, 32*1024)
|
||||
want = append(want, part...)
|
||||
if n, err := conn.Write(part); err != nil || n != len(part) {
|
||||
t.Fatalf("Write %d = %d, %v", i, n, err)
|
||||
}
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw.mu.Lock()
|
||||
got := append([]byte(nil), raw.buf.Bytes()...)
|
||||
writes := raw.writes
|
||||
raw.mu.Unlock()
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatal("carrier changed byte order/content")
|
||||
}
|
||||
if writes >= 4 {
|
||||
t.Fatalf("expected packet writes to be combined, raw writes=%d", writes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type sshTunnelManager struct {
|
||||
wires *wireSelector
|
||||
username string
|
||||
password string
|
||||
internalHost string
|
||||
internalPort int
|
||||
pinFile string
|
||||
udpgwHost string
|
||||
udpgwPort int
|
||||
|
||||
mu sync.Mutex
|
||||
client *ssh.Client
|
||||
closed bool
|
||||
|
||||
firstTCPLogged atomic.Bool
|
||||
udpLogged atomic.Bool
|
||||
}
|
||||
|
||||
func newSSHTunnelManager(wires *wireSelector, username, password, internalHost string, internalPort int, pinFile, udpgwHost string, udpgwPort int) (*sshTunnelManager, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return nil, errors.New("SSH username is required")
|
||||
}
|
||||
if password == "" {
|
||||
return nil, errors.New("SSH password is required")
|
||||
}
|
||||
if internalHost == "" {
|
||||
internalHost = defaultSSHInternalHostClient
|
||||
}
|
||||
if internalPort < 1 || internalPort > 65535 {
|
||||
return nil, errors.New("invalid SSH internal port")
|
||||
}
|
||||
if udpgwHost == "" {
|
||||
udpgwHost = "dragontcp-udpgw.internal"
|
||||
}
|
||||
if udpgwPort < 1 || udpgwPort > 65535 {
|
||||
return nil, errors.New("invalid UDPGW port")
|
||||
}
|
||||
return &sshTunnelManager{
|
||||
wires: wires, username: username, password: password,
|
||||
internalHost: internalHost, internalPort: internalPort,
|
||||
pinFile: pinFile, udpgwHost: udpgwHost, udpgwPort: udpgwPort,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const (
|
||||
defaultSSHInternalHostClient = "dragontcp-ssh.internal"
|
||||
sshCarrierWriteBatch = 1024 * 1024
|
||||
sshCarrierMaxBuffered = 4 * 1024 * 1024
|
||||
sshCarrierFlushDelay = 2 * time.Millisecond
|
||||
)
|
||||
|
||||
func (m *sshTunnelManager) hostKeyCallback() ssh.HostKeyCallback {
|
||||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
fingerprint := ssh.FingerprintSHA256(key)
|
||||
if strings.TrimSpace(m.pinFile) == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(m.pinFile)
|
||||
if err == nil {
|
||||
expected := strings.TrimSpace(string(data))
|
||||
if expected == fingerprint {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("SSH host key changed: expected %s got %s", expected, fingerprint)
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read SSH host key pin: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(m.pinFile, []byte(fingerprint+"\n"), 0600); err != nil {
|
||||
return fmt.Errorf("save SSH host key pin: %w", err)
|
||||
}
|
||||
fmt.Printf("ssh host key pinned: %s\n", fingerprint)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) connectLocked() (*ssh.Client, error) {
|
||||
if m.closed {
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
if m.client != nil {
|
||||
return m.client, nil
|
||||
}
|
||||
|
||||
fmt.Printf("ssh carrier: opening DragonTCP stream to %s:%d\n", m.internalHost, m.internalPort)
|
||||
transport, err := m.wires.dial(m.internalHost, m.internalPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DragonTCP SSH carrier failed: %w", err)
|
||||
}
|
||||
// SSH emits encrypted packets in ~tens-of-KiB writes. Feeding each one
|
||||
// directly into the transactional DragonTCP transport creates a full RTT per
|
||||
// SSH packet. Combine them behind bounded backpressure so a busy SSH stream
|
||||
// reaches DragonTCP's discovered 256 KiB-1 MiB chunk sizes instead.
|
||||
transport = newSSHCarrierConn(transport, sshCarrierWriteBatch, sshCarrierMaxBuffered, sshCarrierFlushDelay)
|
||||
fmt.Printf("ssh carrier: DragonTCP stream connected write_batch=%d max_buffer=%d flush_delay=%s\n", sshCarrierWriteBatch, sshCarrierMaxBuffered, sshCarrierFlushDelay)
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: m.username,
|
||||
Auth: []ssh.AuthMethod{ssh.Password(m.password)},
|
||||
HostKeyCallback: m.hostKeyCallback(),
|
||||
ClientVersion: "SSH-2.0-DragonTCP",
|
||||
}
|
||||
addr := net.JoinHostPort(m.internalHost, fmt.Sprintf("%d", m.internalPort))
|
||||
cc, chans, reqs, err := ssh.NewClientConn(transport, addr, cfg)
|
||||
if err != nil {
|
||||
_ = transport.Close()
|
||||
return nil, fmt.Errorf("SSH handshake/auth failed: %w", err)
|
||||
}
|
||||
client := ssh.NewClient(cc, chans, reqs)
|
||||
m.client = client
|
||||
fmt.Printf("ssh authenticated: user=%s transport=DragonTCP mode=tunnel-only\n", m.username)
|
||||
go m.keepalive(client)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) getClient() (*ssh.Client, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.connectLocked()
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) invalidate(client *ssh.Client) {
|
||||
m.mu.Lock()
|
||||
if m.client == client {
|
||||
m.client = nil
|
||||
_ = client.Close()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) keepalive(client *ssh.Client) {
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
_, _, err := client.SendRequest("keepalive@dragontcp", true, nil)
|
||||
if err != nil {
|
||||
m.invalidate(client)
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
same := m.client == client && !m.closed
|
||||
m.mu.Unlock()
|
||||
if !same {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) Warmup() error {
|
||||
_, err := m.getClient()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) DialTCP(host string, port int) (net.Conn, error) {
|
||||
target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
client, err := m.getClient()
|
||||
if err != nil {
|
||||
// Authentication, handshake, or physical-carrier failures are hard
|
||||
// boundaries. Retrying them immediately would duplicate expensive
|
||||
// DragonTCP/SSH connection attempts and can overload the server.
|
||||
return nil, err
|
||||
}
|
||||
conn, err := client.Dial("tcp", target)
|
||||
if err == nil {
|
||||
if m.firstTCPLogged.CompareAndSwap(false, true) {
|
||||
fmt.Printf("ssh traffic: direct-tcpip active\n")
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
// A direct-tcpip channel rejection means the SSH transport is healthy
|
||||
// and only this destination failed (for example ECONNREFUSED or a
|
||||
// server-side target-policy rejection). Do not tear down the persistent
|
||||
// SSH carrier or redial the destination in that case.
|
||||
var openErr *ssh.OpenChannelError
|
||||
if errors.As(err, &openErr) {
|
||||
return nil, err
|
||||
}
|
||||
m.invalidate(client)
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("SSH target dial failed")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) DialUDPGW() (net.Conn, error) {
|
||||
conn, err := m.DialTCP(m.udpgwHost, m.udpgwPort)
|
||||
if err == nil && m.udpLogged.CompareAndSwap(false, true) {
|
||||
fmt.Printf("ssh traffic: UDPGW active target=%s:%d\n", m.udpgwHost, m.udpgwPort)
|
||||
}
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func (m *sshTunnelManager) Close() error {
|
||||
m.mu.Lock()
|
||||
m.closed = true
|
||||
client := m.client
|
||||
m.client = nil
|
||||
m.mu.Unlock()
|
||||
if client != nil {
|
||||
return client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,26 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"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, clear or SHA-256-compatible payloads)
|
||||
// bp — compatible registration/upload/download/ACK records, clear or SHA-256-compatible
|
||||
// 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, which decides by actually fetching a URL through each wire and
|
||||
// keeping the first that answers.
|
||||
// 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"
|
||||
@@ -28,14 +27,11 @@ const (
|
||||
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
|
||||
)
|
||||
// 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
|
||||
@@ -48,6 +44,7 @@ type wireSelector struct {
|
||||
xorOpts xorchunk.Options
|
||||
probeDelay time.Duration
|
||||
probeThreads int
|
||||
forceClear bool
|
||||
|
||||
// Test hooks are nil in production.
|
||||
candidateOverride []wireChoice
|
||||
@@ -60,6 +57,47 @@ type wireChoice struct {
|
||||
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 {
|
||||
@@ -73,7 +111,7 @@ func (c wireChoice) String() string {
|
||||
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 {
|
||||
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,
|
||||
@@ -82,10 +120,90 @@ func newWireSelector(configured, serverAddr, token string, binOpts chunkClientOp
|
||||
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()
|
||||
@@ -121,6 +239,13 @@ func (s *wireSelector) mode() wireChoice {
|
||||
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.
|
||||
@@ -134,101 +259,69 @@ func (s *wireSelector) mode() wireChoice {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
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})
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
wantB := s.configured == WireAuto || s.configured == WireBinary
|
||||
wantBP := s.configured == WireAuto || s.configured == WireBP
|
||||
wantX := s.configured == WireAuto || s.configured == WireXOR
|
||||
|
||||
// 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.
|
||||
// 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++ {
|
||||
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})
|
||||
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 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})
|
||||
if wantX && validXORHeaderMask(mask) {
|
||||
out = append(out, wireChoice{mode: WireXOR, mask: mask})
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// 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 {
|
||||
@@ -243,7 +336,7 @@ func (s *wireSelector) detectLocked() (wireChoice, bool) {
|
||||
}
|
||||
delay := s.probeDelay
|
||||
if delay <= 0 {
|
||||
delay = time.Second
|
||||
delay = 100 * time.Millisecond
|
||||
}
|
||||
type result struct {
|
||||
choice wireChoice
|
||||
@@ -295,59 +388,51 @@ func (s *wireSelector) detectLocked() (wireChoice, bool) {
|
||||
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)
|
||||
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 target=http://%s/ no validated profile yet\n", completed, len(candidates), next, time.Since(started).Round(time.Millisecond), probeHost)
|
||||
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 profile validated through http://%s/ after %d candidates in %s; retrying later\n", probeHost, completed, 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 fetches probeHost through one wire and reports whether a well-formed
|
||||
// HTTP status line came back.
|
||||
// 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 {
|
||||
var (
|
||||
conn net.Conn
|
||||
err error
|
||||
)
|
||||
if choice.mode == WireXOR {
|
||||
switch choice.mode {
|
||||
case WireXOR:
|
||||
opts := s.xorOpts
|
||||
if choice.cover.Enabled {
|
||||
conn, err = xorchunk.Open(s.serverAddr, s.token, probeHost, probePort, s.xorOpts.WithCoverProfile(choice.cover))
|
||||
opts = opts.WithCoverProfile(choice.cover)
|
||||
} else {
|
||||
conn, err = xorchunk.Open(s.serverAddr, s.token, probeHost, probePort, s.xorOpts.WithHeaderMask(choice.mask))
|
||||
opts = opts.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))
|
||||
return xorchunk.ProbeProfile(s.serverAddr, s.token, opts)
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
statusLine, err := bufio.NewReader(conn).ReadString('\n')
|
||||
return err == nil && strings.HasPrefix(statusLine, "HTTP/")
|
||||
}
|
||||
|
||||
@@ -6,79 +6,141 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProfileCandidatesCoverBothWireFamilies(t *testing.T) {
|
||||
func expectedBinaryMasks() []byte {
|
||||
out := make([]byte, 0, 32)
|
||||
for n := 0; n < 256; n++ {
|
||||
mask := byte(n)
|
||||
if validBinaryHeaderMask(mask) {
|
||||
out = append(out, mask)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func expectedXORMasks() []byte {
|
||||
out := make([]byte, 0, 96)
|
||||
for n := 0; n < 256; n++ {
|
||||
mask := byte(n)
|
||||
if validXORHeaderMask(mask) {
|
||||
out = append(out, mask)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestProfileCandidatesUseOnlyMathematicallyValidDirectMasks(t *testing.T) {
|
||||
selector := &wireSelector{configured: WireAuto}
|
||||
candidates := selector.profileCandidates()
|
||||
binaryCount, bpCount, xorCount := 0, 0, 0
|
||||
seen := make(map[wireChoice]bool, len(candidates))
|
||||
firstBytes := make(map[byte]bool, 256)
|
||||
coveredMasks := map[string]map[byte]bool{WireBinary: {}, WireBP: {}, WireXOR: {}}
|
||||
coveredPadding := map[string]map[uint16]bool{WireBinary: {}, WireBP: {}, WireXOR: {}}
|
||||
for _, candidate := range candidates {
|
||||
if seen[candidate] {
|
||||
t.Fatalf("duplicate candidate: %s", candidate)
|
||||
}
|
||||
seen[candidate] = true
|
||||
if candidate.cover.Enabled {
|
||||
coveredMasks[candidate.mode][candidate.mask] = true
|
||||
coveredPadding[candidate.mode][candidate.cover.Padding] = true
|
||||
t.Fatalf("normal discovery must not use a cover profile: %s", candidate)
|
||||
}
|
||||
switch candidate.mode {
|
||||
case WireBinary:
|
||||
binaryCount++
|
||||
if !candidate.cover.Enabled && candidate.mask&7 != 0 {
|
||||
t.Fatalf("ambiguous binary mask: %02x", candidate.mask)
|
||||
}
|
||||
if candidate.cover.Enabled {
|
||||
firstBytes[byte(candidate.cover.ID>>8)] = true
|
||||
} else {
|
||||
for mode := byte(0); mode <= 4; mode++ {
|
||||
firstBytes[mode^candidate.mask] = true
|
||||
}
|
||||
}
|
||||
case WireXOR:
|
||||
xorCount++
|
||||
if !candidate.cover.Enabled && ('U'^candidate.mask)&7 < 5 {
|
||||
t.Fatalf("ambiguous XOR mask: %02x", candidate.mask)
|
||||
}
|
||||
if candidate.cover.Enabled {
|
||||
firstBytes[byte(candidate.cover.ID>>8)] = true
|
||||
} else {
|
||||
firstBytes['U'^candidate.mask] = true
|
||||
if !validBinaryHeaderMask(candidate.mask) {
|
||||
t.Fatalf("invalid B mask: %02x", candidate.mask)
|
||||
}
|
||||
case WireBP:
|
||||
bpCount++
|
||||
if candidate.cover.Enabled && !candidate.cover.Clear {
|
||||
t.Fatalf("covered BP profile must use clear payloads: %s", candidate)
|
||||
if !validBinaryHeaderMask(candidate.mask) {
|
||||
t.Fatalf("invalid BP mask: %02x", candidate.mask)
|
||||
}
|
||||
if !candidate.cover.Enabled && candidate.mask != 0 {
|
||||
t.Fatalf("direct BP profile must keep a clear header: %s", candidate)
|
||||
case WireXOR:
|
||||
xorCount++
|
||||
if !validXORHeaderMask(candidate.mask) {
|
||||
t.Fatalf("invalid X mask: %02x", candidate.mask)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unknown candidate: %s", candidate)
|
||||
}
|
||||
}
|
||||
if binaryCount != 544 || bpCount != 257 || xorCount != 352 {
|
||||
t.Fatalf("profiles B=%d BP=%d X=%d, want B=544 BP=257 X=352", binaryCount, bpCount, xorCount)
|
||||
if binaryCount != 32 || bpCount != 32 || xorCount != 96 {
|
||||
t.Fatalf("profiles B=%d BP=%d X=%d, want B=32 BP=32 X=96", binaryCount, bpCount, xorCount)
|
||||
}
|
||||
if len(firstBytes) != 256 {
|
||||
t.Fatalf("profiles cover %d first-byte values, want 256", len(firstBytes))
|
||||
if len(candidates) != 160 {
|
||||
t.Fatalf("auto candidates=%d, want 160", len(candidates))
|
||||
}
|
||||
for _, mode := range []string{WireBinary, WireBP, WireXOR} {
|
||||
if len(coveredMasks[mode]) != 256 {
|
||||
t.Fatalf("mode %s covers %d masks, want 256", mode, len(coveredMasks[mode]))
|
||||
}
|
||||
|
||||
func TestBinaryMasksAreExactly00ThroughF8InStepsOf08(t *testing.T) {
|
||||
want := expectedBinaryMasks()
|
||||
if len(want) != 32 {
|
||||
t.Fatalf("binary mask count=%d, want 32", len(want))
|
||||
}
|
||||
for i, mask := range want {
|
||||
if mask != byte(i*8) {
|
||||
t.Fatalf("binary mask[%d]=%02x, want %02x", i, mask, byte(i*8))
|
||||
}
|
||||
if len(coveredPadding[mode]) != 16 {
|
||||
t.Fatalf("mode %s covers %d padding lengths, want 16", mode, len(coveredPadding[mode]))
|
||||
}
|
||||
for _, mode := range []string{WireBinary, WireBP} {
|
||||
candidates := (&wireSelector{configured: mode}).profileCandidates()
|
||||
if len(candidates) != len(want) {
|
||||
t.Fatalf("mode %s candidates=%d, want %d", mode, len(candidates), len(want))
|
||||
}
|
||||
for i, candidate := range candidates {
|
||||
if candidate.mode != mode || candidate.mask != want[i] || candidate.cover.Enabled {
|
||||
t.Fatalf("mode %s candidate[%d]=%s mask=%02x, want direct/%02x", mode, i, candidate, candidate.mask, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualWireStillDiscoversAllProfilesForThatFamily(t *testing.T) {
|
||||
func TestXORMasksAreExactlyDirectClassifierValidSet(t *testing.T) {
|
||||
want := expectedXORMasks()
|
||||
if len(want) != 96 {
|
||||
t.Fatalf("X mask count=%d, want 96", len(want))
|
||||
}
|
||||
candidates := (&wireSelector{configured: WireXOR}).profileCandidates()
|
||||
if len(candidates) != len(want) {
|
||||
t.Fatalf("X candidates=%d, want %d", len(candidates), len(want))
|
||||
}
|
||||
for i, candidate := range candidates {
|
||||
if candidate.mode != WireXOR || candidate.mask != want[i] || candidate.cover.Enabled {
|
||||
t.Fatalf("X candidate[%d]=%s mask=%02x, want direct/%02x", i, candidate, candidate.mask, want[i])
|
||||
}
|
||||
if ('U'^candidate.mask)&7 < 5 {
|
||||
t.Fatalf("X candidate[%d] is classifier-invalid: %02x", i, candidate.mask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoInterleavesOnlyValidMasksInNumericOrder(t *testing.T) {
|
||||
candidates := (&wireSelector{configured: WireAuto}).profileCandidates()
|
||||
want := make([]wireChoice, 0, 160)
|
||||
for n := 0; n < 256; n++ {
|
||||
mask := byte(n)
|
||||
if validBinaryHeaderMask(mask) {
|
||||
want = append(want,
|
||||
wireChoice{mode: WireBinary, mask: mask},
|
||||
wireChoice{mode: WireBP, mask: mask},
|
||||
)
|
||||
}
|
||||
if validXORHeaderMask(mask) {
|
||||
want = append(want, wireChoice{mode: WireXOR, mask: mask})
|
||||
}
|
||||
}
|
||||
if len(candidates) != len(want) {
|
||||
t.Fatalf("auto candidates=%d, want %d", len(candidates), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if candidates[i] != want[i] {
|
||||
t.Fatalf("auto candidate[%d]=%s/%02x, want %s/%02x", i, candidates[i].mode, candidates[i].mask, want[i].mode, want[i].mask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualWireUsesOnlyValidProfilesForThatFamily(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
mode string
|
||||
want int
|
||||
}{{WireBinary, 544}, {WireBP, 257}, {WireXOR, 352}} {
|
||||
}{{WireBinary, 32}, {WireBP, 32}, {WireXOR, 96}} {
|
||||
selector := &wireSelector{configured: tc.mode}
|
||||
candidates := selector.profileCandidates()
|
||||
if len(candidates) != tc.want {
|
||||
@@ -88,22 +150,107 @@ func TestManualWireStillDiscoversAllProfilesForThatFamily(t *testing.T) {
|
||||
if candidate.mode != tc.mode {
|
||||
t.Fatalf("mode %s included %s", tc.mode, candidate)
|
||||
}
|
||||
if candidate.cover.Enabled {
|
||||
t.Fatalf("mode %s normal discovery included cover profile %s", tc.mode, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearProfilesAreTriedBeforeLegacyFallbacks(t *testing.T) {
|
||||
for _, mode := range []string{WireBinary, WireBP} {
|
||||
func TestNormalProfilesNeverUseClearPayload(t *testing.T) {
|
||||
for _, mode := range []string{WireAuto, WireBinary, WireBP, WireXOR} {
|
||||
candidates := (&wireSelector{configured: mode}).profileCandidates()
|
||||
if len(candidates) < 2 || !candidates[0].cover.Clear {
|
||||
t.Fatalf("mode %s does not prefer a clear profile", mode)
|
||||
if len(candidates) == 0 {
|
||||
t.Fatalf("mode %s has no candidates", mode)
|
||||
}
|
||||
if candidates[1].cover.Enabled {
|
||||
t.Fatalf("mode %s does not fall back immediately to a legacy direct profile", mode)
|
||||
for _, candidate := range candidates {
|
||||
if candidate.cover.Clear {
|
||||
t.Fatalf("mode %s normal discovery included clear payload profile: %s", mode, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceClearProfilesTry00Before25WithoutMaskedFallbacks(t *testing.T) {
|
||||
selector := &wireSelector{configured: WireAuto, forceClear: true}
|
||||
candidates := selector.profileCandidates()
|
||||
if len(candidates) != 4 {
|
||||
t.Fatalf("force-clear auto profiles=%d, want B00/BP00/B25/BP25", len(candidates))
|
||||
}
|
||||
wantModes := []string{WireBinary, WireBP, WireBinary, WireBP}
|
||||
wantMasks := []byte{0x00, 0x00, 0x25, 0x25}
|
||||
for i, candidate := range candidates {
|
||||
if candidate.mode != wantModes[i] || candidate.mask != wantMasks[i] {
|
||||
t.Fatalf("candidate %d=%s mask=%02x, want mode=%s mask=%02x", i, candidate.mode, candidate.mask, wantModes[i], wantMasks[i])
|
||||
}
|
||||
if candidate.mode == WireXOR {
|
||||
t.Fatalf("force-clear mode included X candidate: %s", candidate)
|
||||
}
|
||||
if !candidate.cover.Enabled || !candidate.cover.Clear {
|
||||
t.Fatalf("force-clear mode included masked/direct candidate: %s", candidate)
|
||||
}
|
||||
if candidate.cover.HeaderMask != candidate.mask {
|
||||
t.Fatalf("candidate %d cover/header mismatch: %+v", i, candidate.cover)
|
||||
}
|
||||
}
|
||||
if candidates[0].cover.ID != 0x0000 || candidates[2].cover.ID != 0x0065 {
|
||||
t.Fatalf("unexpected clear profile IDs: 00=%04x 25=%04x", candidates[0].cover.ID, candidates[2].cover.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceClearPinnedFamilyTries00Then25(t *testing.T) {
|
||||
for _, mode := range []string{WireBinary, WireBP} {
|
||||
candidates := (&wireSelector{configured: mode, forceClear: true}).profileCandidates()
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("mode %s force-clear candidates=%d, want 2", mode, len(candidates))
|
||||
}
|
||||
if candidates[0].mode != mode || candidates[0].mask != 0x00 || !candidates[0].cover.Clear {
|
||||
t.Fatalf("mode %s first forced clear profile is not clear/00: %+v", mode, candidates[0])
|
||||
}
|
||||
if candidates[1].mode != mode || candidates[1].mask != 0x25 || !candidates[1].cover.Clear {
|
||||
t.Fatalf("mode %s second forced clear profile is not clear/25: %+v", mode, candidates[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceClearDiscoveryFallsBackFrom00To25(t *testing.T) {
|
||||
var seen []byte
|
||||
selector := &wireSelector{
|
||||
configured: WireBinary,
|
||||
forceClear: true,
|
||||
probeDelay: time.Nanosecond,
|
||||
probeThreads: 1,
|
||||
probeOverride: func(choice wireChoice) bool {
|
||||
seen = append(seen, choice.mask)
|
||||
return choice.mask == 0x25
|
||||
},
|
||||
}
|
||||
choice := selector.mode()
|
||||
if choice.mask != 0x25 || !choice.cover.Clear {
|
||||
t.Fatalf("selected=%s, want clear mask 25 fallback", choice)
|
||||
}
|
||||
if len(seen) != 2 || seen[0] != 0x00 || seen[1] != 0x25 {
|
||||
t.Fatalf("probe order=%v, want [0 37]", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceClearFailedDiscoveryStillFallsBackToClear00(t *testing.T) {
|
||||
selector := &wireSelector{
|
||||
configured: WireAuto,
|
||||
forceClear: true,
|
||||
candidateOverride: []wireChoice{{mode: WireBinary}},
|
||||
probeDelay: time.Nanosecond,
|
||||
probeOverride: func(wireChoice) bool { return false },
|
||||
}
|
||||
choice := selector.mode()
|
||||
if !choice.cover.Enabled || !choice.cover.Clear || choice.mode == WireXOR {
|
||||
t.Fatalf("force-clear discovery failure fell back to non-clear wire: %s", choice)
|
||||
}
|
||||
if choice.mask != 0x00 {
|
||||
t.Fatalf("force-clear discovery failure did not keep first clear mask 00: %02x", choice.mask)
|
||||
}
|
||||
}
|
||||
|
||||
func measureDiscoveryConcurrency(t *testing.T, threads int) int32 {
|
||||
t.Helper()
|
||||
candidates := make([]wireChoice, 24)
|
||||
|
||||
@@ -369,7 +369,8 @@ func processBHTTPRequest(conn net.Conn, req bhttpRequest, ctx *bhttpServerContex
|
||||
if err != nil {
|
||||
return writeBHTTPError(conn, err.Error())
|
||||
}
|
||||
stream := newStreamSession(req.session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, ctx.maxBuffer, ctx.debug)
|
||||
_, internalCarrier := lookupInternalTarget(host, port)
|
||||
stream := newStreamSession(req.session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, ctx.maxBuffer, internalCarrier, ctx.debug)
|
||||
session.mu.Lock()
|
||||
if session.stream == nil {
|
||||
session.stream = stream
|
||||
|
||||
@@ -14,12 +14,13 @@ import (
|
||||
)
|
||||
|
||||
type streamSession struct {
|
||||
sid wire.SessionID
|
||||
target net.Conn
|
||||
targetName string
|
||||
maxChunk int
|
||||
maxBuffer int
|
||||
debug *serverDebug
|
||||
sid wire.SessionID
|
||||
target net.Conn
|
||||
targetName string
|
||||
maxChunk int
|
||||
maxBuffer int
|
||||
debug *serverDebug
|
||||
bulkCoalesce bool
|
||||
|
||||
mu sync.Mutex
|
||||
notify chan struct{}
|
||||
@@ -33,16 +34,17 @@ type streamSession struct {
|
||||
expectedUp uint64
|
||||
}
|
||||
|
||||
func newStreamSession(sid wire.SessionID, target net.Conn, targetName string, maxChunk, maxBuffer int, debug *serverDebug) *streamSession {
|
||||
func newStreamSession(sid wire.SessionID, target net.Conn, targetName string, maxChunk, maxBuffer int, bulkCoalesce bool, debug *serverDebug) *streamSession {
|
||||
s := &streamSession{
|
||||
sid: sid,
|
||||
target: target,
|
||||
targetName: targetName,
|
||||
maxChunk: maxChunk,
|
||||
maxBuffer: maxBuffer,
|
||||
debug: debug,
|
||||
notify: make(chan struct{}),
|
||||
lastSeen: time.Now(),
|
||||
sid: sid,
|
||||
target: target,
|
||||
targetName: targetName,
|
||||
maxChunk: maxChunk,
|
||||
maxBuffer: maxBuffer,
|
||||
debug: debug,
|
||||
bulkCoalesce: bulkCoalesce,
|
||||
notify: make(chan struct{}),
|
||||
lastSeen: time.Now(),
|
||||
}
|
||||
go s.readTarget()
|
||||
return s
|
||||
@@ -155,14 +157,34 @@ func (s *streamSession) readAt(offset uint64, limit int, wait time.Duration) ([]
|
||||
if firstDataAt.IsZero() {
|
||||
firstDataAt = time.Now()
|
||||
}
|
||||
// Coalesce tiny target reads briefly. This prevents a 1-2 byte
|
||||
// producer read from becoming a permanent tiny tunnel record.
|
||||
if available < limit && !s.eof && wait > 0 && time.Since(firstDataAt) < 2*time.Millisecond {
|
||||
// SSH packetization naturally feeds this stream in ~tens-of-KiB
|
||||
// bursts. Returning the first burst turns a DragonTCP download into
|
||||
// one SSH packet per WAN RTT. Internal carrier sessions therefore
|
||||
// get a slightly wider coalescing window and can accumulate at least
|
||||
// 512 KiB before the pull response is emitted. Ordinary destinations
|
||||
// retain the original 2 ms latency-oriented behavior.
|
||||
coalesceDelay := 2 * time.Millisecond
|
||||
coalesceGoal := limit
|
||||
if s.bulkCoalesce {
|
||||
coalesceDelay = 25 * time.Millisecond
|
||||
coalesceGoal = 512 * 1024
|
||||
if coalesceGoal > limit {
|
||||
coalesceGoal = limit
|
||||
}
|
||||
}
|
||||
elapsed := time.Since(firstDataAt)
|
||||
if available < limit && available < coalesceGoal && !s.eof && wait > 0 && elapsed < coalesceDelay {
|
||||
ch := s.notify
|
||||
remaining := coalesceDelay - elapsed
|
||||
if untilDeadline := time.Until(deadline); untilDeadline < remaining {
|
||||
remaining = untilDeadline
|
||||
}
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(2 * time.Millisecond):
|
||||
if remaining > 0 {
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(remaining):
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -339,6 +361,23 @@ func probePattern(n int) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
func validateIperfUploadPayload(payload []byte, token string, candidate int) bool {
|
||||
base := 11 + len(token)
|
||||
wantLen := candidate
|
||||
if wantLen < base {
|
||||
wantLen = base
|
||||
}
|
||||
if len(payload) != wantLen {
|
||||
return false
|
||||
}
|
||||
for i := base; i < len(payload); i++ {
|
||||
if payload[i] != byte((i*31+17)&0xff) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseOpen(payload []byte) (token, host string, port int, err error) {
|
||||
if len(payload) < 6 {
|
||||
return "", "", 0, fmt.Errorf("bad OPEN payload")
|
||||
@@ -395,6 +434,32 @@ func processWireRequest(conn net.Conn, req wire.Request, token string, allowPriv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case wire.ProbeIperfUpload:
|
||||
if value < 1 || value > maxChunk || len(req.Payload) > maxChunk {
|
||||
return wire.WriteResponse(conn, wire.StatusError, []byte("iperf upload chunk too large"))
|
||||
}
|
||||
if !validateIperfUploadPayload(req.Payload, supplied, value) {
|
||||
return wire.WriteResponse(conn, wire.StatusError, []byte("iperf upload validation failed"))
|
||||
}
|
||||
if debug != nil && debug.enabled {
|
||||
debug.logf("CALIBRATION fake_iperf=upload peer=%s chunk=%d bytes=%d seq=%d pollers=1 outstanding=1", conn.RemoteAddr(), value, len(req.Payload), req.Seq)
|
||||
}
|
||||
return wire.WriteResponse(conn, wire.StatusOK, nil)
|
||||
case wire.ProbeIperfDownload:
|
||||
if value < 1 || value > maxChunk {
|
||||
return wire.WriteResponse(conn, wire.StatusError, []byte("iperf download chunk too large"))
|
||||
}
|
||||
count := wire.ProbeBurstCount(value)
|
||||
if debug != nil && debug.enabled {
|
||||
debug.logf("CALIBRATION fake_iperf=download peer=%s chunk=%d records=%d bytes=%d pollers=1 outstanding=1", conn.RemoteAddr(), value, count, value*count)
|
||||
}
|
||||
data := probePattern(value)
|
||||
for i := 0; i < count; i++ {
|
||||
if err := wire.WriteMaskedResponse(conn, wire.StatusData, data, req.Session, wire.ModeProbe, req.Seq+uint64(i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return wire.WriteResponse(conn, wire.StatusError, []byte("unknown probe kind"))
|
||||
}
|
||||
@@ -418,7 +483,8 @@ func processWireRequest(conn net.Conn, req wire.Request, token string, allowPriv
|
||||
if err != nil {
|
||||
return wire.WriteResponse(conn, wire.StatusError, []byte(err.Error()))
|
||||
}
|
||||
session := newStreamSession(req.Session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, maxBuffer, debug)
|
||||
_, internalCarrier := lookupInternalTarget(host, port)
|
||||
session := newStreamSession(req.Session, target, fmt.Sprintf("%s:%d", host, port), maxChunk, maxBuffer, internalCarrier, debug)
|
||||
_, created := manager.addOrGet(req.Session, session)
|
||||
if created && debug != nil && debug.enabled {
|
||||
debug.sessionsOpened.Add(1)
|
||||
|
||||
@@ -103,6 +103,77 @@ func TestXORProfileProbeEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoveredProfilesSupportEveryHeaderMask(t *testing.T) {
|
||||
for n := 0; n < 256; n++ {
|
||||
mask := byte(n)
|
||||
for _, xor := range []bool{false, true} {
|
||||
profile := cover.Profile{
|
||||
Enabled: true,
|
||||
ID: uint16(mask)<<8 | uint16(mask^0xa5),
|
||||
Padding: 0,
|
||||
HeaderMask: mask,
|
||||
XOR: xor,
|
||||
Clear: false,
|
||||
}
|
||||
server, client := net.Pipe()
|
||||
clientResult := make(chan error, 1)
|
||||
go func() {
|
||||
defer client.Close()
|
||||
if err := cover.WritePreface(client, profile); err != nil {
|
||||
clientResult <- err
|
||||
return
|
||||
}
|
||||
if xor {
|
||||
if err := protocol.WriteRequestFrameProfile(client, 17, []byte("CPROBE -"), mask); err != nil {
|
||||
clientResult <- err
|
||||
return
|
||||
}
|
||||
id, payload, err := protocol.ReadResponseFrameProfile(client, mask)
|
||||
if err == nil && (id != 17 || string(payload) != "PROBEOK") {
|
||||
err = fmt.Errorf("id=%d payload=%q", id, payload)
|
||||
}
|
||||
clientResult <- err
|
||||
return
|
||||
}
|
||||
|
||||
var sid wire.SessionID
|
||||
payload := make([]byte, 11)
|
||||
copy(payload[:4], wire.ProbeMagic[:])
|
||||
payload[4] = wire.ProbeKeepalive
|
||||
if err := wire.WriteRequestProfile(client, wire.ModeProbe, sid, 17, payload, mask); err != nil {
|
||||
clientResult <- err
|
||||
return
|
||||
}
|
||||
status, _, err := wire.ReadResponseProfile(client, mask)
|
||||
if err == nil && status != wire.StatusOK {
|
||||
err = fmt.Errorf("status=%d", status)
|
||||
}
|
||||
clientResult <- err
|
||||
}()
|
||||
|
||||
profiled, gotXOR, gotMask, err := sniffWire(server)
|
||||
if err != nil || gotXOR != xor || gotMask != mask {
|
||||
t.Fatalf("mask=%02x xor=%t sniff got xor=%t mask=%02x err=%v", mask, xor, gotXOR, gotMask, err)
|
||||
}
|
||||
if xor {
|
||||
handleXOR(profiled, gotMask, "", false, nil, 0, nil, 1024, 8, time.Millisecond, nil)
|
||||
} else {
|
||||
req, readErr := wire.ReadRequestProfile(profiled, gotMask)
|
||||
if readErr == nil {
|
||||
readErr = processWireRequest(profiled, req, "", false, nil, 0, nil, 1024, 0, 0, nil)
|
||||
}
|
||||
if readErr != nil {
|
||||
t.Fatalf("mask=%02x binary server: %v", mask, readErr)
|
||||
}
|
||||
}
|
||||
if err := <-clientResult; err != nil {
|
||||
t.Fatalf("mask=%02x xor=%t client: %v", mask, xor, err)
|
||||
}
|
||||
_ = server.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoveredProfilesProbeEndToEnd(t *testing.T) {
|
||||
for _, padding := range []uint16{0, 64, cover.MaxPadding} {
|
||||
for _, xor := range []bool{false, true} {
|
||||
@@ -201,3 +272,108 @@ func TestSniffWireRecognizesAllHeaderProfiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSessionBulkCoalescesSSHLikeBursts(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
var sid wire.SessionID
|
||||
s := newStreamSession(sid, server, "dragontcp-ssh.internal:2222", 1024*1024, 4*1024*1024, true, nil)
|
||||
defer s.close()
|
||||
|
||||
const packet = 32 * 1024
|
||||
const packets = 16 // 512 KiB, matching the bulk coalescing goal.
|
||||
go func() {
|
||||
buf := make([]byte, packet)
|
||||
for i := 0; i < packets; i++ {
|
||||
for j := range buf {
|
||||
buf[j] = byte(i)
|
||||
}
|
||||
if _, err := client.Write(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
data, status, err := s.readAt(0, 1024*1024, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != wire.StatusData {
|
||||
t.Fatalf("status=%d", status)
|
||||
}
|
||||
if len(data) < 512*1024 {
|
||||
t.Fatalf("bulk carrier returned only %d bytes; want at least 512 KiB", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeIperfProbeUploadAndDownload(t *testing.T) {
|
||||
const token = "test-token"
|
||||
const candidate = 512
|
||||
var sid wire.SessionID
|
||||
copy(sid[:], []byte("iperf-test-sid!!"))
|
||||
|
||||
makePayload := func(kind byte, total int) []byte {
|
||||
base := 11 + len(token)
|
||||
if total < base {
|
||||
total = base
|
||||
}
|
||||
p := make([]byte, total)
|
||||
copy(p[:4], wire.ProbeMagic[:])
|
||||
p[4] = kind
|
||||
binary.BigEndian.PutUint16(p[5:7], uint16(len(token)))
|
||||
binary.BigEndian.PutUint32(p[7:11], candidate)
|
||||
copy(p[11:base], token)
|
||||
for i := base; i < len(p); i++ {
|
||||
p[i] = byte((i*31 + 17) & 0xff)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
t.Run("upload", func(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
req := wire.Request{Mode: wire.ModeProbe, Session: sid, Seq: 10, Payload: makePayload(wire.ProbeIperfUpload, candidate)}
|
||||
errCh <- processWireRequest(server, req, token, false, nil, 0, nil, 1024, 0, 0, nil)
|
||||
}()
|
||||
status, body, err := wire.ReadResponse(client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != wire.StatusOK || len(body) != 0 {
|
||||
t.Fatalf("upload status=%d body=%q", status, body)
|
||||
}
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download", func(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
req := wire.Request{Mode: wire.ModeProbe, Session: sid, Seq: 20, Payload: makePayload(wire.ProbeIperfDownload, 0)}
|
||||
errCh <- processWireRequest(server, req, token, false, nil, 0, nil, 1024, 0, 0, nil)
|
||||
}()
|
||||
want := probePattern(candidate)
|
||||
for i := 0; i < wire.ProbeBurstCount(candidate); i++ {
|
||||
status, body, err := wire.ReadResponse(client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = wire.DecodeMaskedResponse(status, body, sid, wire.ModeProbe, 20+uint64(i))
|
||||
if status != wire.StatusData || !bytes.Equal(body, want) {
|
||||
t.Fatalf("download record=%d status=%d len=%d", i, status, len(body))
|
||||
}
|
||||
}
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,854 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dragontcp/internal/protocol"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSSHInternalHost = "dragontcp-ssh.internal"
|
||||
defaultSSHListen = "127.0.0.1:2222"
|
||||
)
|
||||
|
||||
type sshUserRecord struct {
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
MaxConnections int `json:"max_connections,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
type sshUserFile struct {
|
||||
Version int `json:"version"`
|
||||
Users []sshUserRecord `json:"users"`
|
||||
}
|
||||
|
||||
type sshUserStore struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
users map[string]sshUserRecord
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
func newSSHUserStore(path string) *sshUserStore {
|
||||
return &sshUserStore{path: path, users: make(map[string]sshUserRecord)}
|
||||
}
|
||||
|
||||
func normalizeSSHUsername(v string) string {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func validateSSHUsername(v string) error {
|
||||
v = normalizeSSHUsername(v)
|
||||
if v == "" {
|
||||
return errors.New("SSH username is required")
|
||||
}
|
||||
if len(v) > 64 {
|
||||
return errors.New("SSH username is too long")
|
||||
}
|
||||
for _, r := range v {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("SSH username contains unsupported character %q", r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sshUserStore) loadLocked() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
s.users = make(map[string]sshUserRecord)
|
||||
s.modTime = time.Time{}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var file sshUserFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", s.path, err)
|
||||
}
|
||||
users := make(map[string]sshUserRecord, len(file.Users))
|
||||
for _, u := range file.Users {
|
||||
u.Username = normalizeSSHUsername(u.Username)
|
||||
if u.Username == "" || u.PasswordHash == "" {
|
||||
continue
|
||||
}
|
||||
users[u.Username] = u
|
||||
}
|
||||
s.users = users
|
||||
if st, err := os.Stat(s.path); err == nil {
|
||||
s.modTime = st.ModTime()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sshUserStore) Load() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.loadLocked()
|
||||
}
|
||||
|
||||
func (s *sshUserStore) reloadIfChanged() error {
|
||||
st, err := os.Stat(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
s.mu.RLock()
|
||||
alreadyEmpty := len(s.users) == 0 && s.modTime.IsZero()
|
||||
s.mu.RUnlock()
|
||||
if alreadyEmpty {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.users = make(map[string]sshUserRecord)
|
||||
s.modTime = time.Time{}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.mu.RLock()
|
||||
unchanged := st.ModTime().Equal(s.modTime)
|
||||
s.mu.RUnlock()
|
||||
if unchanged {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if st2, err := os.Stat(s.path); err == nil && st2.ModTime().Equal(s.modTime) {
|
||||
return nil
|
||||
}
|
||||
return s.loadLocked()
|
||||
}
|
||||
|
||||
func (s *sshUserStore) snapshot() ([]sshUserRecord, error) {
|
||||
if err := s.reloadIfChanged(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
out := make([]sshUserRecord, 0, len(s.users))
|
||||
for _, u := range s.users {
|
||||
out = append(out, u)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *sshUserStore) get(username string) (sshUserRecord, bool) {
|
||||
_ = s.reloadIfChanged()
|
||||
s.mu.RLock()
|
||||
u, ok := s.users[normalizeSSHUsername(username)]
|
||||
s.mu.RUnlock()
|
||||
return u, ok
|
||||
}
|
||||
|
||||
func (s *sshUserStore) authenticate(username string, password []byte) (sshUserRecord, error) {
|
||||
if err := s.reloadIfChanged(); err != nil {
|
||||
return sshUserRecord{}, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
u, ok := s.users[normalizeSSHUsername(username)]
|
||||
s.mu.RUnlock()
|
||||
if !ok || u.Disabled {
|
||||
return sshUserRecord{}, errors.New("authentication failed")
|
||||
}
|
||||
if !u.ExpiresAt.IsZero() && time.Now().After(u.ExpiresAt) {
|
||||
return sshUserRecord{}, errors.New("account expired")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), password) != nil {
|
||||
return sshUserRecord{}, errors.New("authentication failed")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *sshUserStore) writeRecords(records []sshUserRecord) error {
|
||||
sort.Slice(records, func(i, j int) bool { return records[i].Username < records[j].Username })
|
||||
file := sshUserFile{Version: 1, Users: records}
|
||||
data, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
dir := filepath.Dir(s.path)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return s.Load()
|
||||
}
|
||||
|
||||
func (s *sshUserStore) upsert(username, password string, days, maxConnections int) error {
|
||||
if err := validateSSHUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if password == "" {
|
||||
return errors.New("SSH password is required")
|
||||
}
|
||||
if days < 0 {
|
||||
return errors.New("account lifetime days cannot be negative")
|
||||
}
|
||||
if maxConnections < 0 {
|
||||
return errors.New("max connections cannot be negative")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var expires time.Time
|
||||
if days > 0 {
|
||||
expires = time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
|
||||
}
|
||||
records, err := s.snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated := false
|
||||
for i := range records {
|
||||
if records[i].Username == normalizeSSHUsername(username) {
|
||||
records[i].PasswordHash = string(hash)
|
||||
records[i].ExpiresAt = expires
|
||||
records[i].MaxConnections = maxConnections
|
||||
records[i].Disabled = false
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !updated {
|
||||
records = append(records, sshUserRecord{
|
||||
Username: normalizeSSHUsername(username),
|
||||
PasswordHash: string(hash),
|
||||
ExpiresAt: expires,
|
||||
MaxConnections: maxConnections,
|
||||
})
|
||||
}
|
||||
return s.writeRecords(records)
|
||||
}
|
||||
|
||||
func (s *sshUserStore) delete(username string) error {
|
||||
username = normalizeSSHUsername(username)
|
||||
records, err := s.snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := records[:0]
|
||||
found := false
|
||||
for _, u := range records {
|
||||
if u.Username == username {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("SSH user %q not found", username)
|
||||
}
|
||||
return s.writeRecords(out)
|
||||
}
|
||||
|
||||
func (s *sshUserStore) setPassword(username, password string) error {
|
||||
username = normalizeSSHUsername(username)
|
||||
if err := validateSSHUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if password == "" {
|
||||
return errors.New("SSH password is required")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
records, err := s.snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range records {
|
||||
if records[i].Username == username {
|
||||
records[i].PasswordHash = string(hash)
|
||||
return s.writeRecords(records)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("SSH user %q not found", username)
|
||||
}
|
||||
|
||||
func (s *sshUserStore) updateSettings(username string, days, maxConnections int) error {
|
||||
username = normalizeSSHUsername(username)
|
||||
if err := validateSSHUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
if days < 0 {
|
||||
return errors.New("account lifetime days cannot be negative")
|
||||
}
|
||||
if maxConnections < 0 {
|
||||
return errors.New("max connections cannot be negative")
|
||||
}
|
||||
var expires time.Time
|
||||
if days > 0 {
|
||||
expires = time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
|
||||
}
|
||||
records, err := s.snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range records {
|
||||
if records[i].Username == username {
|
||||
records[i].ExpiresAt = expires
|
||||
records[i].MaxConnections = maxConnections
|
||||
return s.writeRecords(records)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("SSH user %q not found", username)
|
||||
}
|
||||
|
||||
func generateSSHPassword() (string, error) {
|
||||
buf := make([]byte, 18)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
type sshRuntime struct {
|
||||
store *sshUserStore
|
||||
mu sync.Mutex
|
||||
conns map[string]int
|
||||
}
|
||||
|
||||
func newSSHRuntime(store *sshUserStore) *sshRuntime {
|
||||
return &sshRuntime{store: store, conns: make(map[string]int)}
|
||||
}
|
||||
|
||||
func (r *sshRuntime) acquire(username string) (sshUserRecord, error) {
|
||||
u, ok := r.store.get(username)
|
||||
if !ok || u.Disabled || (!u.ExpiresAt.IsZero() && time.Now().After(u.ExpiresAt)) {
|
||||
return sshUserRecord{}, errors.New("account unavailable")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if u.MaxConnections > 0 && r.conns[username] >= u.MaxConnections {
|
||||
return sshUserRecord{}, fmt.Errorf("max connections reached (%d)", u.MaxConnections)
|
||||
}
|
||||
r.conns[username]++
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *sshRuntime) release(username string) {
|
||||
r.mu.Lock()
|
||||
if r.conns[username] <= 1 {
|
||||
delete(r.conns, username)
|
||||
} else {
|
||||
r.conns[username]--
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func ensureSSHHostSigner(path string) (ssh.Signer, error) {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
return ssh.ParsePrivateKey(data)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block := &pem.Block{Type: "PRIVATE KEY", Bytes: der}
|
||||
data := pem.EncodeToMemory(block)
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ssh.ParsePrivateKey(data)
|
||||
}
|
||||
|
||||
type sshDirectTCPIPRequest struct {
|
||||
Host string
|
||||
Port uint32
|
||||
OriginHost string
|
||||
OriginPort uint32
|
||||
}
|
||||
|
||||
func handleSSHDirectTCPIP(newChan ssh.NewChannel, allowPrivate bool, cache *dnsCache, tcpBuffer int) {
|
||||
var req sshDirectTCPIPRequest
|
||||
if err := ssh.Unmarshal(newChan.ExtraData(), &req); err != nil || req.Host == "" || req.Port == 0 || req.Port > 65535 {
|
||||
_ = newChan.Reject(ssh.Prohibited, "bad direct-tcpip request")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
var backend net.Conn
|
||||
var err error
|
||||
if internalAddr, ok := lookupSSHOnlyInternalTarget(req.Host, int(req.Port)); ok {
|
||||
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
backend, err = d.DialContext(ctx, "tcp", internalAddr)
|
||||
if err == nil {
|
||||
protocol.TuneTCP(backend)
|
||||
protocol.TuneTCPBuffer(backend, tcpBuffer)
|
||||
}
|
||||
} else {
|
||||
backend, err = dialTarget(ctx, req.Host, int(req.Port), allowPrivate, cache, tcpBuffer)
|
||||
}
|
||||
if err != nil {
|
||||
_ = newChan.Reject(ssh.ConnectionFailed, "connect failed")
|
||||
return
|
||||
}
|
||||
ch, reqs, err := newChan.Accept()
|
||||
if err != nil {
|
||||
_ = backend.Close()
|
||||
return
|
||||
}
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
// Preserve TCP half-close semantics. A client may finish uploading while the
|
||||
// destination is still sending a large response, so do not close both sides
|
||||
// merely because one copy direction reached EOF.
|
||||
var relayWG sync.WaitGroup
|
||||
relayWG.Add(2)
|
||||
go func() {
|
||||
defer relayWG.Done()
|
||||
_, _ = io.Copy(backend, ch)
|
||||
if cw, ok := backend.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer relayWG.Done()
|
||||
_, _ = io.Copy(ch, backend)
|
||||
if cw, ok := ch.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
}()
|
||||
relayWG.Wait()
|
||||
_ = backend.Close()
|
||||
_ = ch.Close()
|
||||
}
|
||||
|
||||
func handleSSHDummySession(newChan ssh.NewChannel) {
|
||||
ch, reqs, err := newChan.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer ch.Close()
|
||||
for req := range reqs {
|
||||
if req.WantReply {
|
||||
_ = req.Reply(false, nil)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func serveSSHConn(conn net.Conn, cfg *ssh.ServerConfig, runtime *sshRuntime, allowPrivate bool, cache *dnsCache, tcpBuffer int) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(20 * time.Second))
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
username := sshConn.User()
|
||||
user, err := runtime.acquire(username)
|
||||
if err != nil {
|
||||
log.Printf("fake-ssh rejected user=%q remote=%s: %v", username, sshConn.RemoteAddr(), err)
|
||||
_ = sshConn.Close()
|
||||
return
|
||||
}
|
||||
log.Printf("fake-ssh connected user=%q remote=%s mode=tunnel-only", username, sshConn.RemoteAddr())
|
||||
defer func() {
|
||||
runtime.release(username)
|
||||
log.Printf("fake-ssh disconnected user=%q remote=%s", username, sshConn.RemoteAddr())
|
||||
}()
|
||||
defer sshConn.Close()
|
||||
if !user.ExpiresAt.IsZero() {
|
||||
remaining := time.Until(user.ExpiresAt)
|
||||
if remaining <= 0 {
|
||||
return
|
||||
}
|
||||
expiryTimer := time.AfterFunc(remaining, func() { _ = sshConn.Close() })
|
||||
defer expiryTimer.Stop()
|
||||
}
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
for newChan := range chans {
|
||||
switch newChan.ChannelType() {
|
||||
case "direct-tcpip":
|
||||
go handleSSHDirectTCPIP(newChan, allowPrivate, cache, tcpBuffer)
|
||||
case "session":
|
||||
go handleSSHDummySession(newChan)
|
||||
default:
|
||||
_ = newChan.Reject(ssh.UnknownChannelType, "unsupported channel type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startFakeSSH(listenAddr, hostKeyPath string, store *sshUserStore, allowPrivate bool, cache *dnsCache, tcpBuffer int) (net.Listener, string, error) {
|
||||
if err := store.Load(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
signer, err := ensureSSHHostSigner(hostKeyPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
runtime := newSSHRuntime(store)
|
||||
cfg := &ssh.ServerConfig{
|
||||
NoClientAuth: false,
|
||||
PasswordCallback: func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
if _, err := store.authenticate(meta.User(), password); err != nil {
|
||||
log.Printf("fake-ssh auth failed user=%q remote=%s", meta.User(), meta.RemoteAddr())
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
cfg.AddHostKey(signer)
|
||||
|
||||
ln, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
log.Printf("fake-ssh accept: %v", err)
|
||||
continue
|
||||
}
|
||||
go serveSSHConn(conn, cfg, runtime, allowPrivate, cache, tcpBuffer)
|
||||
}
|
||||
}()
|
||||
return ln, ssh.FingerprintSHA256(signer.PublicKey()), nil
|
||||
}
|
||||
|
||||
type sshCLIFlags struct {
|
||||
usersPath *string
|
||||
addUser *string
|
||||
deleteUser *string
|
||||
password *string
|
||||
passwordEnv *string
|
||||
days *int
|
||||
maxConnections *int
|
||||
listUsers *bool
|
||||
menu *bool
|
||||
}
|
||||
|
||||
func registerSSHCLIFlags() sshCLIFlags {
|
||||
return sshCLIFlags{
|
||||
usersPath: flag.String("ssh-users", "dragontcp-users.json", "fake SSH user database JSON path"),
|
||||
addUser: flag.String("ssh-user-add", "", "create or update an SSH tunnel user, then exit"),
|
||||
deleteUser: flag.String("ssh-user-delete", "", "delete an SSH tunnel user, then exit"),
|
||||
password: flag.String("ssh-user-password", "", "password used with --ssh-user-add"),
|
||||
passwordEnv: flag.String("ssh-user-password-env", "", "environment variable containing password for --ssh-user-add"),
|
||||
days: flag.Int("ssh-user-days", 0, "account lifetime in days; 0 means no expiry"),
|
||||
maxConnections: flag.Int("ssh-user-max-connections", 1, "maximum simultaneous SSH connections for the account; 0 means unlimited"),
|
||||
listUsers: flag.Bool("ssh-user-list", false, "list SSH tunnel users, then exit"),
|
||||
menu: flag.Bool("ssh-menu", false, "interactive SSH tunnel user management menu, then exit"),
|
||||
}
|
||||
}
|
||||
|
||||
func menuReadLine(reader *bufio.Reader, prompt string) (string, error) {
|
||||
fmt.Print(prompt)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(line), nil
|
||||
}
|
||||
|
||||
func menuReadInt(reader *bufio.Reader, prompt string, defaultValue, minValue int) (int, error) {
|
||||
for {
|
||||
line, err := menuReadLine(reader, prompt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if line == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
v, err := strconv.Atoi(line)
|
||||
if err != nil || v < minValue {
|
||||
fmt.Printf("Enter a number >= %d.\n", minValue)
|
||||
continue
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
func printSSHUserList(store *sshUserStore) error {
|
||||
records, err := store.snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
fmt.Println("No SSH tunnel users.")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%-22s %-26s %-16s %s\n", "USERNAME", "EXPIRES", "MAX CONNECTIONS", "STATUS")
|
||||
fmt.Printf("%-22s %-26s %-16s %s\n", strings.Repeat("-", 8), strings.Repeat("-", 7), strings.Repeat("-", 15), strings.Repeat("-", 6))
|
||||
now := time.Now()
|
||||
for _, u := range records {
|
||||
expiry := "never"
|
||||
status := "active"
|
||||
if !u.ExpiresAt.IsZero() {
|
||||
expiry = u.ExpiresAt.Local().Format("2006-01-02 15:04 MST")
|
||||
if now.After(u.ExpiresAt) {
|
||||
status = "expired"
|
||||
}
|
||||
}
|
||||
if u.Disabled {
|
||||
status = "disabled"
|
||||
}
|
||||
max := "unlimited"
|
||||
if u.MaxConnections > 0 {
|
||||
max = strconv.Itoa(u.MaxConnections)
|
||||
}
|
||||
fmt.Printf("%-22s %-26s %-16s %s\n", u.Username, expiry, max, status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSSHUserMenu(store *sshUserStore) error {
|
||||
if err := store.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
fmt.Println()
|
||||
fmt.Println("========================================")
|
||||
fmt.Println(" DragonTCP SSH Tunnel User Manager")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("User database: %s\n\n", store.path)
|
||||
fmt.Println(" 1) Create user (automatic password)")
|
||||
fmt.Println(" 2) Delete user")
|
||||
fmt.Println(" 3) List users")
|
||||
fmt.Println(" 4) Reset user password (automatic)")
|
||||
fmt.Println(" 5) Renew/edit expiry and connection limit")
|
||||
fmt.Println(" 0) Exit")
|
||||
choice, err := menuReadLine(reader, "\nSelect: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch choice {
|
||||
case "0", "q", "quit", "exit":
|
||||
return nil
|
||||
case "1":
|
||||
username, err := menuReadLine(reader, "Username: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSSHUsername(username); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if _, exists := store.get(username); exists {
|
||||
fmt.Printf("User %q already exists. Use option 4 or 5 to change it.\n", username)
|
||||
continue
|
||||
}
|
||||
days, err := menuReadInt(reader, "Days [30, 0 = never expires]: ", 30, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxConnections, err := menuReadInt(reader, "Max connections [1, 0 = unlimited]: ", 1, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
password, err := generateSSHPassword()
|
||||
if err != nil {
|
||||
fmt.Printf("Error generating password: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if err := store.upsert(username, password, days, maxConnections); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
u, _ := store.get(username)
|
||||
expiry := "never"
|
||||
if !u.ExpiresAt.IsZero() {
|
||||
expiry = u.ExpiresAt.Local().Format("2006-01-02 15:04 MST")
|
||||
}
|
||||
fmt.Println("\nUser created successfully.")
|
||||
fmt.Printf("Username: %s\n", u.Username)
|
||||
fmt.Printf("Password: %s\n", password)
|
||||
fmt.Printf("Expires: %s\n", expiry)
|
||||
fmt.Printf("Max connections: %d\n", u.MaxConnections)
|
||||
fmt.Println("Save the password now. DragonTCP stores only its bcrypt hash and cannot display it later.")
|
||||
case "2":
|
||||
username, err := menuReadLine(reader, "Username to delete: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := store.get(username); !exists {
|
||||
fmt.Printf("User %q not found.\n", normalizeSSHUsername(username))
|
||||
continue
|
||||
}
|
||||
confirm, err := menuReadLine(reader, fmt.Sprintf("Delete %q? [y/N]: ", normalizeSSHUsername(username)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(confirm, "y") && !strings.EqualFold(confirm, "yes") {
|
||||
fmt.Println("Delete cancelled.")
|
||||
continue
|
||||
}
|
||||
if err := store.delete(username); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("User %q deleted.\n", normalizeSSHUsername(username))
|
||||
case "3":
|
||||
if err := printSSHUserList(store); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
}
|
||||
case "4":
|
||||
username, err := menuReadLine(reader, "Username: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := store.get(username); !exists {
|
||||
fmt.Printf("User %q not found.\n", normalizeSSHUsername(username))
|
||||
continue
|
||||
}
|
||||
password, err := generateSSHPassword()
|
||||
if err != nil {
|
||||
fmt.Printf("Error generating password: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if err := store.setPassword(username, password); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("New password for %s: %s\n", normalizeSSHUsername(username), password)
|
||||
fmt.Println("Save it now; only the bcrypt hash is stored.")
|
||||
case "5":
|
||||
username, err := menuReadLine(reader, "Username: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, exists := store.get(username)
|
||||
if !exists {
|
||||
fmt.Printf("User %q not found.\n", normalizeSSHUsername(username))
|
||||
continue
|
||||
}
|
||||
days, err := menuReadInt(reader, "New lifetime from now in days [30, 0 = never expires]: ", 30, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxDefault := u.MaxConnections
|
||||
maxConnections, err := menuReadInt(reader, fmt.Sprintf("Max connections [%d, 0 = unlimited]: ", maxDefault), maxDefault, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.updateSettings(username, days, maxConnections); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("User %q updated. Password was not changed.\n", normalizeSSHUsername(username))
|
||||
default:
|
||||
fmt.Println("Invalid selection.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSSHCLI(flags sshCLIFlags) (bool, error) {
|
||||
store := newSSHUserStore(*flags.usersPath)
|
||||
actions := 0
|
||||
if strings.TrimSpace(*flags.addUser) != "" {
|
||||
actions++
|
||||
}
|
||||
if strings.TrimSpace(*flags.deleteUser) != "" {
|
||||
actions++
|
||||
}
|
||||
if *flags.listUsers {
|
||||
actions++
|
||||
}
|
||||
if *flags.menu {
|
||||
actions++
|
||||
}
|
||||
if actions == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if actions > 1 {
|
||||
return true, errors.New("choose only one of --ssh-menu, --ssh-user-add, --ssh-user-delete, or --ssh-user-list")
|
||||
}
|
||||
if *flags.menu {
|
||||
return true, runSSHUserMenu(store)
|
||||
}
|
||||
if strings.TrimSpace(*flags.addUser) != "" {
|
||||
password := *flags.password
|
||||
if *flags.passwordEnv != "" {
|
||||
password = os.Getenv(*flags.passwordEnv)
|
||||
}
|
||||
if err := store.upsert(*flags.addUser, password, *flags.days, *flags.maxConnections); err != nil {
|
||||
return true, err
|
||||
}
|
||||
u, _ := store.get(*flags.addUser)
|
||||
expiry := "never"
|
||||
if !u.ExpiresAt.IsZero() {
|
||||
expiry = u.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
fmt.Printf("SSH user %s saved (expires=%s max_connections=%d)\n", u.Username, expiry, u.MaxConnections)
|
||||
return true, nil
|
||||
}
|
||||
if strings.TrimSpace(*flags.deleteUser) != "" {
|
||||
if err := store.delete(*flags.deleteUser); err != nil {
|
||||
return true, err
|
||||
}
|
||||
fmt.Printf("SSH user %s deleted\n", normalizeSSHUsername(*flags.deleteUser))
|
||||
return true, nil
|
||||
}
|
||||
records, err := store.snapshot()
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
for _, u := range records {
|
||||
expiry := "never"
|
||||
if !u.ExpiresAt.IsZero() {
|
||||
expiry = u.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
fmt.Printf("%s expires=%s max_connections=%d disabled=%t\n", u.Username, expiry, u.MaxConnections, u.Disabled)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGeneratedSSHPassword(t *testing.T) {
|
||||
p1, err := generateSSHPassword()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p2, err := generateSSHPassword()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p1) != 24 || len(p2) != 24 {
|
||||
t.Fatalf("generated password lengths = %d, %d; want 24", len(p1), len(p2))
|
||||
}
|
||||
if p1 == p2 {
|
||||
t.Fatal("two generated passwords were identical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHUserMenuOperationsPreservePasswordWhenEditingSettings(t *testing.T) {
|
||||
store := newSSHUserStore(filepath.Join(t.TempDir(), "users.json"))
|
||||
if err := store.upsert("alice", "initial-password", 7, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, ok := store.get("alice")
|
||||
if !ok {
|
||||
t.Fatal("user missing after create")
|
||||
}
|
||||
|
||||
if err := store.updateSettings("alice", 30, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, ok := store.get("alice")
|
||||
if !ok {
|
||||
t.Fatal("user missing after settings update")
|
||||
}
|
||||
if after.PasswordHash != before.PasswordHash {
|
||||
t.Fatal("editing expiry/connection limit changed password hash")
|
||||
}
|
||||
if after.MaxConnections != 5 {
|
||||
t.Fatalf("max connections = %d; want 5", after.MaxConnections)
|
||||
}
|
||||
if after.ExpiresAt.Before(time.Now().UTC().Add(29 * 24 * time.Hour)) {
|
||||
t.Fatalf("expiry was not renewed: %v", after.ExpiresAt)
|
||||
}
|
||||
|
||||
if err := store.setPassword("alice", "replacement-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reset, ok := store.get("alice")
|
||||
if !ok {
|
||||
t.Fatal("user missing after password reset")
|
||||
}
|
||||
if reset.PasswordHash == after.PasswordHash {
|
||||
t.Fatal("password reset did not change password hash")
|
||||
}
|
||||
if reset.MaxConnections != after.MaxConnections || !reset.ExpiresAt.Equal(after.ExpiresAt) {
|
||||
t.Fatal("password reset changed account limits")
|
||||
}
|
||||
|
||||
if err := store.delete("alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := store.get("alice"); ok {
|
||||
t.Fatal("user still present after delete")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type internalTargetRegistry struct {
|
||||
sync.RWMutex
|
||||
m map[string]string
|
||||
}
|
||||
|
||||
var dragonTCPInternalTargets = internalTargetRegistry{m: make(map[string]string)}
|
||||
var sshOnlyInternalTargets = internalTargetRegistry{m: make(map[string]string)}
|
||||
|
||||
func internalTargetKey(host string, port int) string {
|
||||
return strings.ToLower(strings.TrimSpace(host)) + ":" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
func registerInternalTarget(host string, port int, dialAddr string) {
|
||||
registerTarget(&dragonTCPInternalTargets, host, port, dialAddr)
|
||||
}
|
||||
|
||||
func lookupInternalTarget(host string, port int) (string, bool) {
|
||||
return lookupTarget(&dragonTCPInternalTargets, host, port)
|
||||
}
|
||||
|
||||
// registerSSHOnlyInternalTarget creates a destination that is reachable only
|
||||
// after SSH authentication. It is deliberately not exposed to raw DragonTCP
|
||||
// clients, which prevents direct access to services such as the UDP gateway.
|
||||
func registerSSHOnlyInternalTarget(host string, port int, dialAddr string) {
|
||||
registerTarget(&sshOnlyInternalTargets, host, port, dialAddr)
|
||||
}
|
||||
|
||||
func lookupSSHOnlyInternalTarget(host string, port int) (string, bool) {
|
||||
return lookupTarget(&sshOnlyInternalTargets, host, port)
|
||||
}
|
||||
|
||||
func registerTarget(registry *internalTargetRegistry, host string, port int, dialAddr string) {
|
||||
if strings.TrimSpace(host) == "" || port < 1 || port > 65535 || strings.TrimSpace(dialAddr) == "" {
|
||||
return
|
||||
}
|
||||
registry.Lock()
|
||||
registry.m[internalTargetKey(host, port)] = dialAddr
|
||||
registry.Unlock()
|
||||
}
|
||||
|
||||
func lookupTarget(registry *internalTargetRegistry, host string, port int) (string, bool) {
|
||||
registry.RLock()
|
||||
addr, ok := registry.m[internalTargetKey(host, port)]
|
||||
registry.RUnlock()
|
||||
return addr, ok
|
||||
}
|
||||
|
||||
func dialInternalTarget(dialer *net.Dialer, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSSHOnlyInternalTargetIsNotPublicDragonTCPTarget(t *testing.T) {
|
||||
const host = "test-udpgw.internal"
|
||||
const port = 17400
|
||||
registerSSHOnlyInternalTarget(host, port, "127.0.0.1:17400")
|
||||
|
||||
if _, ok := lookupInternalTarget(host, port); ok {
|
||||
t.Fatal("SSH-only target leaked into raw DragonTCP internal target registry")
|
||||
}
|
||||
if got, ok := lookupSSHOnlyInternalTarget(host, port); !ok || got != "127.0.0.1:17400" {
|
||||
t.Fatalf("SSH-only target lookup = %q, %v", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"dragontcp/internal/cover"
|
||||
"dragontcp/internal/protocol"
|
||||
)
|
||||
|
||||
@@ -135,6 +136,17 @@ func addressAllowed(addr netip.Addr, allowPrivate bool) bool {
|
||||
}
|
||||
|
||||
func dialTarget(ctx context.Context, host string, port int, allowPrivate bool, cache *dnsCache, tcpBuffer int) (net.Conn, error) {
|
||||
if internalAddr, ok := lookupInternalTarget(host, port); ok {
|
||||
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", internalAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
protocol.TuneTCP(conn)
|
||||
protocol.TuneTCPBuffer(conn, tcpBuffer)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
ips, err := cache.resolve(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -215,11 +227,24 @@ func handle(
|
||||
return
|
||||
}
|
||||
clearPayload := false
|
||||
coverID := uint16(0)
|
||||
covered := false
|
||||
if profiled, ok := conn.(interface{ ClearPayload() bool }); ok {
|
||||
clearPayload = profiled.ClearPayload()
|
||||
}
|
||||
if profiled, ok := conn.(interface{ CoverProfile() cover.Profile }); ok {
|
||||
profile := profiled.CoverProfile()
|
||||
if profile.Enabled {
|
||||
covered = true
|
||||
coverID = profile.ID
|
||||
}
|
||||
}
|
||||
if debug != nil && debug.enabled {
|
||||
debug.logf("WIRE peer=%v mode=binary header_mask=%02x clear_payload=%t", conn.RemoteAddr(), headerMask, clearPayload)
|
||||
if covered {
|
||||
debug.logf("WIRE peer=%v mode=binary header_mask=%02x clear_payload=%t cover_id=%04x", conn.RemoteAddr(), headerMask, clearPayload, coverID)
|
||||
} else {
|
||||
debug.logf("WIRE peer=%v mode=binary header_mask=%02x clear_payload=%t cover_id=direct", conn.RemoteAddr(), headerMask, clearPayload)
|
||||
}
|
||||
}
|
||||
|
||||
handleBinary(conn, headerMask, clearPayload, token, allowPrivate, cache, tcpBuffer, manager,
|
||||
@@ -268,6 +293,7 @@ func acceptLoop(
|
||||
}
|
||||
|
||||
func main() {
|
||||
sshCLI := registerSSHCLIFlags()
|
||||
var (
|
||||
host = flag.String("host", "0.0.0.0", "listen host")
|
||||
port = flag.Int("port", 53, "listen port")
|
||||
@@ -285,9 +311,27 @@ func main() {
|
||||
debugEnabled = flag.Bool("debug", false, "log session/connect/errors and periodic statistics")
|
||||
debugChunks = flag.Bool("debug-chunks", false, "log every chunk protocol record; very verbose")
|
||||
debugStats = flag.Duration("debug-stats-interval", 5*time.Second, "periodic debug statistics interval; 0 disables")
|
||||
|
||||
sshEnable = flag.Bool("ssh-enable", true, "enable the internal tunnel-only SSH service")
|
||||
sshListen = flag.String("ssh-listen", defaultSSHListen, "internal fake SSH listen address")
|
||||
sshInternalHost = flag.String("ssh-internal-host", defaultSSHInternalHost, "reserved DragonTCP target name used by clients for SSH")
|
||||
sshHostKey = flag.String("ssh-host-key", "dragontcp_ssh_host_key", "SSH host private-key path; generated automatically if missing")
|
||||
udpgwEnable = flag.Bool("udpgw-enable", true, "enable integrated BadVPN-compatible UDPGW")
|
||||
udpgwListen = flag.String("udpgw-listen", "127.0.0.1:7400", "UDPGW listen address; loopback is recommended")
|
||||
udpgwInternalHost = flag.String("udpgw-internal-host", "dragontcp-udpgw.internal", "reserved SSH direct-tcpip target name for UDPGW")
|
||||
udpgwMaxClients = flag.Int("udpgw-max-clients", 10000, "maximum concurrent UDPGW TCP clients")
|
||||
udpgwDebug = flag.Bool("udpgw-debug", false, "verbose UDPGW errors")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if handled, err := handleSSHCLI(sshCLI); handled {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if *chunkMax < 32 || *chunkMax > protocol.MaxChunkPayload {
|
||||
fmt.Fprintf(os.Stderr, "--chunk-max must be between 32 and %d\n", protocol.MaxChunkPayload)
|
||||
os.Exit(2)
|
||||
@@ -297,6 +341,58 @@ func main() {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cache := newDNSCache(*dnsCacheTTL, *dnsCacheSize)
|
||||
|
||||
var udpServer *udpgwServer
|
||||
if *udpgwEnable {
|
||||
var err error
|
||||
udpServer, err = startUDPGWServer(udpgwServerConfig{
|
||||
Listen: *udpgwListen, MaxClients: *udpgwMaxClients, Debug: *udpgwDebug,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "UDPGW start failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer udpServer.Close()
|
||||
_, udpPortText, err := net.SplitHostPort(udpServer.ln.Addr().String())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid UDPGW listener: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
udpPort, err := strconv.Atoi(udpPortText)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid UDPGW port: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
registerSSHOnlyInternalTarget(*udpgwInternalHost, udpPort, udpServer.ln.Addr().String())
|
||||
fmt.Printf("udpgw=true listen=%s internal_target=%s:%d max_clients=%d\n", udpServer.ln.Addr(), *udpgwInternalHost, udpPort, *udpgwMaxClients)
|
||||
}
|
||||
|
||||
var sshListener net.Listener
|
||||
if *sshEnable {
|
||||
sshStore := newSSHUserStore(*sshCLI.usersPath)
|
||||
listener, fingerprint, err := startFakeSSH(*sshListen, *sshHostKey, sshStore, *allowPrivate, cache, *tcpBuffer)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fake SSH start failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
sshListener = listener
|
||||
defer sshListener.Close()
|
||||
sshBoundAddr := sshListener.Addr().String()
|
||||
_, sshPortText, err := net.SplitHostPort(sshBoundAddr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid fake SSH listener: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
sshPort, err := strconv.Atoi(sshPortText)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid fake SSH listener port: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
registerInternalTarget(*sshInternalHost, sshPort, sshBoundAddr)
|
||||
fmt.Printf("fake_ssh=true listen=%s internal_target=%s:%d hostkey=%s users=%s\n", sshBoundAddr, *sshInternalHost, sshPort, fingerprint, *sshCLI.usersPath)
|
||||
}
|
||||
|
||||
listenAddr := net.JoinHostPort(*host, strconv.Itoa(*port))
|
||||
ln, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
@@ -324,7 +420,6 @@ func main() {
|
||||
fmt.Printf("max_connections=%d tcp_buffer=%d\n", *maxConnections, *tcpBuffer)
|
||||
|
||||
slots := make(chan struct{}, *maxConnections)
|
||||
cache := newDNSCache(*dnsCacheTTL, *dnsCacheSize)
|
||||
debug := newServerDebug(*debugEnabled, *debugChunks, *debugStats)
|
||||
bufferBytes := *chunkBuffered * 65536
|
||||
if bufferBytes < 1024*1024 {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type udpgwServerConfig struct {
|
||||
Listen string
|
||||
MaxFrame int
|
||||
MaxClients int
|
||||
MaxClientConns int
|
||||
MaxMapEntries int
|
||||
MapTTL time.Duration
|
||||
IdleTimeout time.Duration
|
||||
Debug bool
|
||||
}
|
||||
|
||||
type udpgwServer struct {
|
||||
cfg udpgwServerConfig
|
||||
ln net.Listener
|
||||
slots chan struct{}
|
||||
closeMu sync.Once
|
||||
}
|
||||
|
||||
type udpDestKey struct {
|
||||
ip [4]byte
|
||||
port uint16
|
||||
}
|
||||
|
||||
type udpMapVal struct {
|
||||
connID uint16
|
||||
x byte
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
func startUDPGWServer(cfg udpgwServerConfig) (*udpgwServer, error) {
|
||||
if cfg.Listen == "" {
|
||||
cfg.Listen = "127.0.0.1:7400"
|
||||
}
|
||||
if cfg.MaxFrame <= 0 || cfg.MaxFrame > 65535 {
|
||||
cfg.MaxFrame = 65535
|
||||
}
|
||||
if cfg.MaxClients <= 0 {
|
||||
cfg.MaxClients = 10000
|
||||
}
|
||||
if cfg.MaxClientConns <= 0 {
|
||||
cfg.MaxClientConns = 64
|
||||
}
|
||||
if cfg.MaxMapEntries <= 0 {
|
||||
cfg.MaxMapEntries = 32768
|
||||
}
|
||||
if cfg.MapTTL <= 0 {
|
||||
cfg.MapTTL = 90 * time.Second
|
||||
}
|
||||
if cfg.IdleTimeout <= 0 {
|
||||
cfg.IdleTimeout = 2 * time.Minute
|
||||
}
|
||||
ln, err := net.Listen("tcp", cfg.Listen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &udpgwServer{cfg: cfg, ln: ln, slots: make(chan struct{}, cfg.MaxClients)}
|
||||
go s.acceptLoop()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *udpgwServer) Close() error {
|
||||
var err error
|
||||
s.closeMu.Do(func() { err = s.ln.Close() })
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *udpgwServer) acceptLoop() {
|
||||
for {
|
||||
conn, err := s.ln.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
log.Printf("udpgw accept: %v", err)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case s.slots <- struct{}{}:
|
||||
go func() {
|
||||
defer func() { <-s.slots }()
|
||||
s.handleClient(conn)
|
||||
}()
|
||||
default:
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *udpgwServer) handleClient(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
if tcp, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tcp.SetNoDelay(true)
|
||||
}
|
||||
udpConn, err := net.ListenUDP("udp4", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer udpConn.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
writeCh := make(chan []byte, 256)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case frame := <-writeCh:
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||
if _, err := conn.Write(frame); err != nil {
|
||||
cancel()
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var mu sync.Mutex
|
||||
mappings := make(map[udpDestKey]udpMapVal)
|
||||
connSeen := make(map[uint16]time.Time)
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, from, err := udpConn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ip4 := from.IP.To4()
|
||||
if ip4 == nil || n <= 0 {
|
||||
continue
|
||||
}
|
||||
var ip [4]byte
|
||||
copy(ip[:], ip4)
|
||||
key := udpDestKey{ip: ip, port: uint16(from.Port)}
|
||||
mu.Lock()
|
||||
v, ok := mappings[key]
|
||||
mu.Unlock()
|
||||
if !ok || time.Now().After(v.exp) {
|
||||
continue
|
||||
}
|
||||
frame := udpgwBuildFrame(v.connID, v.x, ip, uint16(from.Port), buf[:n])
|
||||
select {
|
||||
case writeCh <- frame:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
reap := time.NewTicker(10 * time.Second)
|
||||
defer reap.Stop()
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-reap.C:
|
||||
mu.Lock()
|
||||
for k, v := range mappings {
|
||||
if now.After(v.exp) {
|
||||
delete(mappings, k)
|
||||
}
|
||||
}
|
||||
for id, seen := range connSeen {
|
||||
if now.Sub(seen) > s.cfg.MapTTL {
|
||||
delete(connSeen, id)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
br := bufio.NewReaderSize(conn, 32*1024)
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(s.cfg.IdleTimeout))
|
||||
payload, err := udpgwReadPayload(br, s.cfg.MaxFrame)
|
||||
if err != nil {
|
||||
cancel()
|
||||
_ = conn.Close()
|
||||
<-done
|
||||
return
|
||||
}
|
||||
if len(payload) < 9 {
|
||||
continue
|
||||
}
|
||||
connID := binary.BigEndian.Uint16(payload[0:2])
|
||||
x := payload[2]
|
||||
var dstIP [4]byte
|
||||
copy(dstIP[:], payload[3:7])
|
||||
dstPort := binary.BigEndian.Uint16(payload[7:9])
|
||||
data := payload[9:]
|
||||
now := time.Now()
|
||||
key := udpDestKey{ip: dstIP, port: dstPort}
|
||||
|
||||
mu.Lock()
|
||||
for id, seen := range connSeen {
|
||||
if now.Sub(seen) > s.cfg.MapTTL {
|
||||
delete(connSeen, id)
|
||||
}
|
||||
}
|
||||
if _, ok := connSeen[connID]; !ok && len(connSeen) >= s.cfg.MaxClientConns {
|
||||
var oldestID uint16
|
||||
var oldestTime time.Time
|
||||
first := true
|
||||
for id, seen := range connSeen {
|
||||
if first || seen.Before(oldestTime) {
|
||||
oldestID, oldestTime, first = id, seen, false
|
||||
}
|
||||
}
|
||||
delete(connSeen, oldestID)
|
||||
}
|
||||
connSeen[connID] = now
|
||||
if len(mappings) >= s.cfg.MaxMapEntries {
|
||||
for k, v := range mappings {
|
||||
if now.After(v.exp) {
|
||||
delete(mappings, k)
|
||||
}
|
||||
}
|
||||
if len(mappings) >= s.cfg.MaxMapEntries {
|
||||
for k := range mappings {
|
||||
delete(mappings, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
mappings[key] = udpMapVal{connID: connID, x: x, exp: now.Add(s.cfg.MapTTL)}
|
||||
mu.Unlock()
|
||||
|
||||
addr := &net.UDPAddr{IP: net.IPv4(dstIP[0], dstIP[1], dstIP[2], dstIP[3]), Port: int(dstPort)}
|
||||
if _, err := udpConn.WriteToUDP(data, addr); err != nil && s.cfg.Debug {
|
||||
log.Printf("udpgw write %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func udpgwReadPayload(r *bufio.Reader, max int) ([]byte, error) {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint16(lenBuf[:]))
|
||||
if n <= 0 || n > max {
|
||||
return nil, fmt.Errorf("udpgw invalid frame length %d", n)
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func udpgwBuildFrame(connID uint16, x byte, ip [4]byte, port uint16, data []byte) []byte {
|
||||
payloadLen := 9 + len(data)
|
||||
out := make([]byte, 2+payloadLen)
|
||||
binary.LittleEndian.PutUint16(out[0:2], uint16(payloadLen))
|
||||
binary.BigEndian.PutUint16(out[2:4], connID)
|
||||
out[4] = x
|
||||
copy(out[5:9], ip[:])
|
||||
binary.BigEndian.PutUint16(out[9:11], port)
|
||||
copy(out[11:], data)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUDPGWRelaysIPv4Datagram(t *testing.T) {
|
||||
echo, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer echo.Close()
|
||||
go func() {
|
||||
b := make([]byte, 2048)
|
||||
n, addr, e := echo.ReadFromUDP(b)
|
||||
if e == nil {
|
||||
_, _ = echo.WriteToUDP(b[:n], addr)
|
||||
}
|
||||
}()
|
||||
|
||||
srv, err := startUDPGWServer(udpgwServerConfig{Listen: "127.0.0.1:0", MaxClients: 4})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
c, err := net.DialTimeout("tcp", srv.ln.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
port := echo.LocalAddr().(*net.UDPAddr).Port
|
||||
data := []byte("udpgw-ok")
|
||||
payloadLen := 9 + len(data)
|
||||
frame := make([]byte, 2+payloadLen)
|
||||
binary.LittleEndian.PutUint16(frame[:2], uint16(payloadLen))
|
||||
binary.BigEndian.PutUint16(frame[2:4], 1)
|
||||
frame[4] = 0
|
||||
copy(frame[5:9], []byte{127, 0, 0, 1})
|
||||
binary.BigEndian.PutUint16(frame[9:11], uint16(port))
|
||||
copy(frame[11:], data)
|
||||
if _, err := c.Write(frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := bufio.NewReader(c)
|
||||
var lb [2]byte
|
||||
if _, err := io.ReadFull(r, lb[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint16(lb[:]))
|
||||
reply := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, reply); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n < 9 || string(reply[9:]) != string(data) {
|
||||
t.Fatalf("bad reply n=%d data=%q", n, reply[9:])
|
||||
}
|
||||
}
|
||||
@@ -357,6 +357,8 @@ func decodeWireToken(token string) string {
|
||||
|
||||
func isChunkCommand(payload []byte) bool {
|
||||
return bytes.HasPrefix(payload, []byte("CPROBE ")) ||
|
||||
bytes.HasPrefix(payload, []byte("CIPERFUP ")) ||
|
||||
bytes.HasPrefix(payload, []byte("CIPERFDW ")) ||
|
||||
bytes.HasPrefix(payload, []byte("COPEN ")) ||
|
||||
bytes.HasPrefix(payload, []byte("CPUSH ")) ||
|
||||
bytes.HasPrefix(payload, []byte("CPULL ")) ||
|
||||
@@ -388,6 +390,46 @@ func processChunkCommand(
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("PROBEOK"))
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(payload, []byte("CIPERFUP ")) {
|
||||
parts := bytes.SplitN(payload, []byte(" "), 4)
|
||||
if len(parts) != 4 {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CIPERFUP"))
|
||||
}
|
||||
if !tokenEqual(decodeWireToken(string(parts[1])), token) {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
|
||||
}
|
||||
size, err := strconv.Atoi(string(parts[2]))
|
||||
if err != nil || size < 1 || size > maxChunk {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR iperf upload chunk too large"))
|
||||
}
|
||||
data := parts[3]
|
||||
if len(data) != size || !bytes.Equal(data, probePattern(size)) {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR iperf upload validation failed"))
|
||||
}
|
||||
if debug != nil && debug.enabled {
|
||||
debug.logf("CALIBRATION fake_iperf=upload wire=x peer=%s chunk=%d bytes=%d pollers=1 outstanding=1", conn.RemoteAddr(), size, len(data))
|
||||
}
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("IPERFOK"))
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(payload, []byte("CIPERFDW ")) {
|
||||
parts := strings.Fields(string(payload))
|
||||
if len(parts) != 3 {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CIPERFDW"))
|
||||
}
|
||||
if !tokenEqual(decodeWireToken(parts[1]), token) {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed"))
|
||||
}
|
||||
size, err := strconv.Atoi(parts[2])
|
||||
if err != nil || size < 1 || size > maxChunk {
|
||||
return protocol.WriteResponseFrame(conn, requestID, []byte("ERR iperf download chunk too large"))
|
||||
}
|
||||
if debug != nil && debug.enabled {
|
||||
debug.logf("CALIBRATION fake_iperf=download wire=x peer=%s chunk=%d bytes=%d pollers=1 outstanding=1", conn.RemoteAddr(), size, size)
|
||||
}
|
||||
return protocol.WriteResponseFrame(conn, requestID, probePattern(size))
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(payload, []byte("COPEN ")) {
|
||||
parts := strings.Fields(string(payload))
|
||||
if len(parts) != 5 {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,5 @@
|
||||
module dragontcp
|
||||
|
||||
go 1.22
|
||||
|
||||
require golang.org/x/crypto v0.31.0
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
@@ -0,0 +1,2 @@
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
@@ -26,14 +26,25 @@ const (
|
||||
StatusWait byte = 3
|
||||
StatusEOF byte = 4
|
||||
|
||||
ProbeUpload byte = 1
|
||||
ProbeDownload byte = 2
|
||||
ProbeKeepalive byte = 3
|
||||
ProbeBatch byte = 4
|
||||
ProbeUpload byte = 1
|
||||
ProbeDownload byte = 2
|
||||
ProbeKeepalive byte = 3
|
||||
ProbeBatch byte = 4
|
||||
ProbeIperfUpload byte = 5
|
||||
ProbeIperfDownload byte = 6
|
||||
)
|
||||
|
||||
var ProbeMagic = [4]byte{'D', 'T', 'P', '2'}
|
||||
|
||||
// ProbeBurstCount is deliberately fixed at one. Startup calibration measures
|
||||
// the safe record size of a single DragonTCP lane, not aggregate throughput.
|
||||
// Multiple outstanding calibration records can make a constrained carrier look
|
||||
// artificially better or worse and can produce a false ceiling. Confirmation
|
||||
// retries are performed sequentially on fresh connections by the client.
|
||||
func ProbeBurstCount(chunk int) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
type SessionID [16]byte
|
||||
|
||||
type Request struct {
|
||||
|
||||
@@ -103,3 +103,11 @@ func BenchmarkWriteRequest1MiB(b *testing.B) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeBurstCountSinglePoller(t *testing.T) {
|
||||
for _, chunk := range []int{0, 1, 1024, 128 * 1024, 512 * 1024, 1024 * 1024} {
|
||||
if got := ProbeBurstCount(chunk); got != 1 {
|
||||
t.Fatalf("ProbeBurstCount(%d)=%d, want 1", chunk, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+382
-26
@@ -7,6 +7,7 @@
|
||||
package xorchunk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
@@ -29,7 +30,7 @@ var requestCounter atomic.Uint32
|
||||
|
||||
// NewOptions builds the transport options from the values the CLI already
|
||||
// parses, keeping the struct fields unexported as in the original.
|
||||
func NewOptions(startSize, minSize, maxSize int, adaptive bool, adaptSuccesses int, adaptLog bool,
|
||||
func NewOptions(startSize, minSize, maxSize int, adaptive bool, adaptSuccesses, shrinkAfter int, adaptLog bool,
|
||||
pollers, reconnectEvery int, pollDelay, txnTimeout time.Duration, tcpBuffer int) Options {
|
||||
return Options{
|
||||
startSize: startSize,
|
||||
@@ -37,6 +38,7 @@ func NewOptions(startSize, minSize, maxSize int, adaptive bool, adaptSuccesses i
|
||||
maxSize: maxSize,
|
||||
adaptive: adaptive,
|
||||
adaptSuccesses: adaptSuccesses,
|
||||
shrinkAfter: shrinkAfter,
|
||||
adaptLog: adaptLog,
|
||||
pollers: pollers,
|
||||
reconnectEvery: reconnectEvery,
|
||||
@@ -47,19 +49,24 @@ func NewOptions(startSize, minSize, maxSize int, adaptive bool, adaptSuccesses i
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
startSize int
|
||||
minSize int
|
||||
maxSize int
|
||||
adaptive bool
|
||||
adaptSuccesses int
|
||||
adaptLog bool
|
||||
pollers int
|
||||
reconnectEvery int
|
||||
pollDelay time.Duration
|
||||
txnTimeout time.Duration
|
||||
tcpBuffer int
|
||||
headerMask byte
|
||||
coverProfile cover.Profile
|
||||
startSize int
|
||||
minSize int
|
||||
maxSize int
|
||||
uploadStartSize int
|
||||
downloadStartSize int
|
||||
uploadMaxSize int
|
||||
downloadMaxSize int
|
||||
adaptive bool
|
||||
adaptSuccesses int
|
||||
shrinkAfter int
|
||||
adaptLog bool
|
||||
pollers int
|
||||
reconnectEvery int
|
||||
pollDelay time.Duration
|
||||
txnTimeout time.Duration
|
||||
tcpBuffer int
|
||||
headerMask byte
|
||||
coverProfile cover.Profile
|
||||
}
|
||||
|
||||
// WithHeaderMask returns a copy using one fixed frame-magic profile. The mask
|
||||
@@ -78,6 +85,38 @@ func (o Options) WithCoverProfile(profile cover.Profile) Options {
|
||||
return o
|
||||
}
|
||||
|
||||
// MinSize and MaxSize expose the configured X carrier calibration bounds.
|
||||
func (o Options) MinSize() int { return o.minSize }
|
||||
func (o Options) MaxSize() int { return o.maxSize }
|
||||
|
||||
// WithCalibratedChunks locks X to the UP/DW sizes proven by the pre-tunnel
|
||||
// fake-iperf calibration. Once calibration succeeds, runtime adaptive sizing is
|
||||
// disabled for X: transport successes cannot grow the chunk and transport
|
||||
// failures cannot shrink it. Failed physical transactions reconnect/retry using
|
||||
// the same calibrated size.
|
||||
func (o Options) WithCalibratedChunks(upload, download int) Options {
|
||||
if upload < o.minSize {
|
||||
upload = o.minSize
|
||||
}
|
||||
if download < o.minSize {
|
||||
download = o.minSize
|
||||
}
|
||||
if upload > o.maxSize {
|
||||
upload = o.maxSize
|
||||
}
|
||||
if download > o.maxSize {
|
||||
download = o.maxSize
|
||||
}
|
||||
o.uploadStartSize = upload
|
||||
o.downloadStartSize = download
|
||||
o.uploadMaxSize = upload
|
||||
o.downloadMaxSize = download
|
||||
// Calibration replaces runtime X chunk adaptation. The calibrated values are
|
||||
// the operating sizes for this session, not merely adaptive ceilings.
|
||||
o.adaptive = false
|
||||
return o
|
||||
}
|
||||
|
||||
func wireToken(token string) string {
|
||||
if token == "" {
|
||||
return "-"
|
||||
@@ -93,28 +132,44 @@ type adaptiveSizer struct {
|
||||
max int
|
||||
adaptive bool
|
||||
adaptSuccesses int
|
||||
shrinkAfter int
|
||||
failures int
|
||||
successes int
|
||||
good int
|
||||
bad int
|
||||
logChanges bool
|
||||
}
|
||||
|
||||
func newAdaptiveSizer(name string, opts Options) *adaptiveSizer {
|
||||
start := opts.startSize
|
||||
func newAdaptiveSizer(name string, start, ceiling int, opts Options) *adaptiveSizer {
|
||||
if ceiling <= 0 || ceiling > opts.maxSize {
|
||||
ceiling = opts.maxSize
|
||||
}
|
||||
if ceiling < opts.minSize {
|
||||
ceiling = opts.minSize
|
||||
}
|
||||
if start <= 0 {
|
||||
start = opts.startSize
|
||||
}
|
||||
if start < opts.minSize {
|
||||
start = opts.minSize
|
||||
}
|
||||
if start > opts.maxSize {
|
||||
start = opts.maxSize
|
||||
if start > ceiling {
|
||||
start = ceiling
|
||||
}
|
||||
return &adaptiveSizer{
|
||||
name: name,
|
||||
current: start,
|
||||
min: opts.minSize,
|
||||
max: opts.maxSize,
|
||||
max: ceiling,
|
||||
adaptive: opts.adaptive,
|
||||
adaptSuccesses: opts.adaptSuccesses,
|
||||
logChanges: opts.adaptLog,
|
||||
shrinkAfter: func() int {
|
||||
if opts.shrinkAfter > 0 {
|
||||
return opts.shrinkAfter
|
||||
}
|
||||
return 1
|
||||
}(),
|
||||
logChanges: opts.adaptLog,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +184,7 @@ func (s *adaptiveSizer) Success(attempted int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.adaptive || s.current >= s.max {
|
||||
if !s.adaptive {
|
||||
return
|
||||
}
|
||||
// Ignore stale successes from records that were already in flight when
|
||||
@@ -137,6 +192,10 @@ func (s *adaptiveSizer) Success(attempted int) {
|
||||
if attempted != s.current {
|
||||
return
|
||||
}
|
||||
s.failures = 0
|
||||
if s.current >= s.max {
|
||||
return
|
||||
}
|
||||
|
||||
if attempted > s.good {
|
||||
s.good = attempted
|
||||
@@ -201,6 +260,14 @@ func (s *adaptiveSizer) Failure(attempted int) (old, next int) {
|
||||
return old, old
|
||||
}
|
||||
s.successes = 0
|
||||
s.failures++
|
||||
if s.failures < s.shrinkAfter {
|
||||
if s.logChanges && s.shrinkAfter > 1 {
|
||||
fmt.Printf("adaptive %s chunk: holding %d after failure %d/%d\n", s.name, old, s.failures, s.shrinkAfter)
|
||||
}
|
||||
return old, old
|
||||
}
|
||||
s.failures = 0
|
||||
|
||||
if s.bad == 0 || attempted < s.bad {
|
||||
s.bad = attempted
|
||||
@@ -373,6 +440,288 @@ func ProbeProfile(serverAddr, token string, opts Options) bool {
|
||||
return opts.headerMask == 0 && strings.HasPrefix(string(resp), "ERR expected TUNNEL")
|
||||
}
|
||||
|
||||
type calibrationProbeResult struct {
|
||||
ok bool
|
||||
bytes int
|
||||
elapsed time.Duration
|
||||
err error
|
||||
}
|
||||
|
||||
func (r calibrationProbeResult) mbps() float64 {
|
||||
if r.bytes <= 0 || r.elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.bytes*8) / r.elapsed.Seconds() / 1_000_000
|
||||
}
|
||||
|
||||
func calibrationPattern(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = byte((i*31 + 17) & 0xff)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func calibrationBurstCount(chunk int) int {
|
||||
// Calibration is strictly single-poller/single-outstanding-request. Runtime
|
||||
// X traffic may use its normal concurrency after calibration completes.
|
||||
return 1
|
||||
}
|
||||
|
||||
func calibrationTimeout(opts Options) time.Duration {
|
||||
t := opts.txnTimeout
|
||||
if t < 8*time.Second {
|
||||
t = 8 * time.Second
|
||||
}
|
||||
if t > 20*time.Second {
|
||||
t = 20 * time.Second
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func isCalibrationTimeout(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "i/o timeout") || strings.Contains(strings.ToLower(err.Error()), "timeout")
|
||||
}
|
||||
|
||||
const calibrationDecisionAttempts = 3
|
||||
|
||||
// confirmCalibrationResult uses a 2-of-3 decision near the carrier boundary.
|
||||
// Each retry gets a fresh X connection. Timeouts are treated as inconclusive
|
||||
// connection failures, not as evidence that the candidate chunk is too large.
|
||||
func confirmCalibrationResult(serverAddr, token string, opts Options, download bool, candidate int, first calibrationProbeResult, stage string) calibrationProbeResult {
|
||||
name := "upload"
|
||||
if download {
|
||||
name = "download"
|
||||
}
|
||||
successes, failures := 0, 0
|
||||
var lastSuccess, lastFailure, lastTimeout calibrationProbeResult
|
||||
|
||||
observe := func(r calibrationProbeResult) {
|
||||
if r.ok {
|
||||
successes++
|
||||
lastSuccess = r
|
||||
return
|
||||
}
|
||||
if isCalibrationTimeout(r.err) {
|
||||
lastTimeout = r
|
||||
return
|
||||
}
|
||||
failures++
|
||||
lastFailure = r
|
||||
}
|
||||
|
||||
observe(first)
|
||||
for attempt := 2; attempt <= calibrationDecisionAttempts && successes < 2 && failures < 2; attempt++ {
|
||||
r := probeCalibrationSize(serverAddr, token, opts, download, candidate)
|
||||
observe(r)
|
||||
result := "failure"
|
||||
if r.ok {
|
||||
result = "success"
|
||||
} else if isCalibrationTimeout(r.err) {
|
||||
result = "connection_timeout"
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=%s chunk=%d confirmation=%d/%d result=%s\n", name, stage, candidate, attempt, calibrationDecisionAttempts, result)
|
||||
}
|
||||
|
||||
if successes >= 2 {
|
||||
return lastSuccess
|
||||
}
|
||||
if failures >= 2 {
|
||||
return lastFailure
|
||||
}
|
||||
if lastTimeout.err != nil {
|
||||
return lastTimeout
|
||||
}
|
||||
if successes > failures && lastSuccess.ok {
|
||||
return lastSuccess
|
||||
}
|
||||
return lastFailure
|
||||
}
|
||||
|
||||
// probeCalibrationSize runs a short repeated UP or DW transfer over one X
|
||||
// physical connection. It exercises the same UP/OK framing and XOR payload path
|
||||
// as real X traffic without opening a destination tunnel.
|
||||
func probeCalibrationSize(serverAddr, token string, opts Options, download bool, candidate int) calibrationProbeResult {
|
||||
timeout := calibrationTimeout(opts)
|
||||
lane := newTxnLane(serverAddr, opts.tcpBuffer, 0, timeout, opts.headerMask, opts.coverProfile)
|
||||
defer lane.Close()
|
||||
count := calibrationBurstCount(candidate)
|
||||
started := time.Now()
|
||||
total := 0
|
||||
want := calibrationPattern(candidate)
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
if download {
|
||||
resp, err := lane.Do([]byte(fmt.Sprintf("CIPERFDW %s %d", wireToken(token), candidate)))
|
||||
if err != nil {
|
||||
return calibrationProbeResult{bytes: total, elapsed: time.Since(started), err: err}
|
||||
}
|
||||
if !bytes.Equal(resp, want) {
|
||||
return calibrationProbeResult{bytes: total, elapsed: time.Since(started), err: fmt.Errorf("X download validation failed len=%d want=%d", len(resp), candidate)}
|
||||
}
|
||||
total += len(resp)
|
||||
continue
|
||||
}
|
||||
|
||||
prefix := []byte(fmt.Sprintf("CIPERFUP %s %d ", wireToken(token), candidate))
|
||||
payload := make([]byte, len(prefix)+len(want))
|
||||
copy(payload, prefix)
|
||||
copy(payload[len(prefix):], want)
|
||||
resp, err := lane.Do(payload)
|
||||
if err != nil {
|
||||
return calibrationProbeResult{bytes: total, elapsed: time.Since(started), err: err}
|
||||
}
|
||||
if string(resp) != "IPERFOK" {
|
||||
return calibrationProbeResult{bytes: total, elapsed: time.Since(started), err: fmt.Errorf("X upload rejected: %s", string(resp))}
|
||||
}
|
||||
total += candidate
|
||||
}
|
||||
return calibrationProbeResult{ok: true, bytes: total, elapsed: time.Since(started)}
|
||||
}
|
||||
|
||||
func calibrateMaximum(serverAddr, token string, opts Options, download bool, fine int) int {
|
||||
if fine < 1 {
|
||||
fine = 32
|
||||
}
|
||||
name := "upload"
|
||||
if download {
|
||||
name = "download"
|
||||
}
|
||||
candidate := opts.minSize
|
||||
if candidate < 32 {
|
||||
candidate = 32
|
||||
}
|
||||
if candidate > opts.maxSize {
|
||||
candidate = opts.maxSize
|
||||
}
|
||||
good, bad := 0, 0
|
||||
|
||||
for {
|
||||
r := probeCalibrationSize(serverAddr, token, opts, download, candidate)
|
||||
if r.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=ascend chunk=%d records=%d bytes=%d mbps=%.2f result=success\n", name, candidate, calibrationBurstCount(candidate), r.bytes, r.mbps())
|
||||
good = candidate
|
||||
if candidate >= opts.maxSize {
|
||||
return opts.maxSize
|
||||
}
|
||||
next := candidate * 4
|
||||
if candidate == opts.minSize && next < 512 && opts.maxSize >= 512 {
|
||||
next = 512
|
||||
}
|
||||
if next > opts.maxSize {
|
||||
next = opts.maxSize
|
||||
}
|
||||
if next <= candidate {
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s wire=x stage=ascend chunk_upgrade=%d->%d\n", name, candidate, next)
|
||||
candidate = next
|
||||
continue
|
||||
}
|
||||
r = confirmCalibrationResult(serverAddr, token, opts, download, candidate, r, "ascend-confirm")
|
||||
if r.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=ascend chunk=%d result=recovered_after_retry\n", name, candidate)
|
||||
good = candidate
|
||||
if candidate >= opts.maxSize {
|
||||
return opts.maxSize
|
||||
}
|
||||
next := candidate * 4
|
||||
if candidate == opts.minSize && next < 512 && opts.maxSize >= 512 {
|
||||
next = 512
|
||||
}
|
||||
if next > opts.maxSize {
|
||||
next = opts.maxSize
|
||||
}
|
||||
if next <= candidate {
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s wire=x stage=ascend chunk_upgrade=%d->%d\n", name, candidate, next)
|
||||
candidate = next
|
||||
continue
|
||||
}
|
||||
if isCalibrationTimeout(r.err) {
|
||||
selected := good
|
||||
if selected == 0 {
|
||||
selected = opts.minSize
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=ascend chunk=%d result=connection_timeout action=keep_known_good known_good=%d err=%v\n", name, candidate, selected, r.err)
|
||||
return selected
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=ascend chunk=%d records=%d bytes=%d mbps=%.2f result=failure err=%v\n", name, candidate, calibrationBurstCount(candidate), r.bytes, r.mbps(), r.err)
|
||||
bad = candidate
|
||||
if good == 0 {
|
||||
return opts.minSize
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
for bad-good > fine {
|
||||
next := good + (bad-good)/2
|
||||
if next <= good || next >= bad {
|
||||
break
|
||||
}
|
||||
r := probeCalibrationSize(serverAddr, token, opts, download, next)
|
||||
r = confirmCalibrationResult(serverAddr, token, opts, download, next, r, "refine-confirm")
|
||||
if r.ok {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=refine chunk=%d records=%d bytes=%d mbps=%.2f result=success\n", name, next, calibrationBurstCount(next), r.bytes, r.mbps())
|
||||
good = next
|
||||
continue
|
||||
}
|
||||
if isCalibrationTimeout(r.err) {
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=refine chunk=%d result=connection_timeout action=keep_known_good known_good=%d err=%v\n", name, next, good, r.err)
|
||||
return good
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION fake_iperf=%s wire=x stage=refine chunk=%d records=%d bytes=%d mbps=%.2f result=failure err=%v\n", name, next, calibrationBurstCount(next), r.bytes, r.mbps(), r.err)
|
||||
bad = next
|
||||
}
|
||||
fmt.Printf("[D-TCP] phase=CALIBRATION probe=%s wire=x stage=refine selected=%d failed_above=%d resolution=%d\n", name, good, bad, fine)
|
||||
return good
|
||||
}
|
||||
|
||||
func probePersistent(serverAddr, token string, opts Options) bool {
|
||||
lane := newTxnLane(serverAddr, opts.tcpBuffer, 0, minDurationX(calibrationTimeout(opts), 2500*time.Millisecond), opts.headerMask, opts.coverProfile)
|
||||
defer lane.Close()
|
||||
for i := 0; i < 8; i++ {
|
||||
resp, err := lane.Do([]byte("CPROBE " + wireToken(token)))
|
||||
if err != nil || string(resp) != "PROBEOK" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func minDurationX(a, b time.Duration) time.Duration {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Calibrate performs the X wire's pre-tunnel UP/DW fake-iperf calibration.
|
||||
// It ascends by 4x and only spends extra probes around the first failure, where
|
||||
// it resolves the highest stable boundary to the requested byte precision.
|
||||
func Calibrate(serverAddr, token string, opts Options, fine int) (upload, download int, persistent bool) {
|
||||
if opts.minSize < 32 {
|
||||
opts.minSize = 32
|
||||
}
|
||||
if opts.maxSize < opts.minSize {
|
||||
opts.maxSize = opts.minSize
|
||||
}
|
||||
if opts.maxSize > protocol.MaxChunkPayload {
|
||||
opts.maxSize = protocol.MaxChunkPayload
|
||||
}
|
||||
upload = calibrateMaximum(serverAddr, token, opts, false, fine)
|
||||
download = calibrateMaximum(serverAddr, token, opts, true, fine)
|
||||
persistent = probePersistent(serverAddr, token, opts)
|
||||
return
|
||||
}
|
||||
|
||||
type chunkResult struct {
|
||||
seq uint64
|
||||
data []byte
|
||||
@@ -471,8 +820,8 @@ func Open(serverAddr, token, targetHost string, targetPort int, opts Options) (n
|
||||
pending: make(map[uint64][]byte, opts.pollers*2),
|
||||
}
|
||||
c.ack.Store(-1)
|
||||
c.upSizer = newAdaptiveSizer("upload", opts)
|
||||
c.downSizer = newAdaptiveSizer("download", opts)
|
||||
c.upSizer = newAdaptiveSizer("upload", opts.uploadStartSize, opts.uploadMaxSize, opts)
|
||||
c.downSizer = newAdaptiveSizer("download", opts.downloadStartSize, opts.downloadMaxSize, opts)
|
||||
|
||||
c.pushLane = newTxnLane(serverAddr, opts.tcpBuffer, opts.reconnectEvery, opts.txnTimeout, opts.headerMask, opts.coverProfile)
|
||||
|
||||
@@ -589,14 +938,18 @@ func (c *chunkConn) pullWorker(lane *txnLane) {
|
||||
resp, err := lane.Do(payload)
|
||||
if err != nil {
|
||||
old, next := c.downSizer.Failure(limit)
|
||||
if next == old && next == c.opts.minSize {
|
||||
if !c.opts.adaptive || (next == old && next == c.opts.minSize) {
|
||||
consecutiveMinFailures++
|
||||
} else {
|
||||
consecutiveMinFailures = 0
|
||||
}
|
||||
if consecutiveMinFailures >= 8 {
|
||||
detail := fmt.Sprintf("download failed at minimum chunk %d", next)
|
||||
if !c.opts.adaptive {
|
||||
detail = fmt.Sprintf("download failed repeatedly at fixed calibrated chunk %d", next)
|
||||
}
|
||||
select {
|
||||
case c.results <- chunkResult{seq: seq, err: fmt.Errorf("download failed at minimum chunk %d: %w", next, err)}:
|
||||
case c.results <- chunkResult{seq: seq, err: fmt.Errorf("%s: %w", detail, err)}:
|
||||
case <-c.ctx.Done():
|
||||
}
|
||||
return
|
||||
@@ -773,12 +1126,15 @@ func (c *chunkConn) Write(p []byte) (int, error) {
|
||||
resp, err := c.pushLane.Do(payload)
|
||||
if err != nil {
|
||||
old, next := c.upSizer.Failure(size)
|
||||
if next == old && next == c.opts.minSize {
|
||||
if !c.opts.adaptive || (next == old && next == c.opts.minSize) {
|
||||
consecutiveMinFailures++
|
||||
} else {
|
||||
consecutiveMinFailures = 0
|
||||
}
|
||||
if consecutiveMinFailures >= 8 {
|
||||
if !c.opts.adaptive {
|
||||
return total, fmt.Errorf("upload failed repeatedly at fixed calibrated chunk %d: %w", next, err)
|
||||
}
|
||||
return total, fmt.Errorf("upload failed at minimum chunk %d: %w", next, err)
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package xorchunk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dragontcp/internal/protocol"
|
||||
)
|
||||
|
||||
func TestAdaptiveSizerShrinkBudget(t *testing.T) {
|
||||
opts := Options{
|
||||
startSize: 1024,
|
||||
minSize: 32,
|
||||
maxSize: 1024,
|
||||
adaptive: true,
|
||||
shrinkAfter: 3,
|
||||
}
|
||||
s := newAdaptiveSizer("test", opts.startSize, opts.maxSize, opts)
|
||||
for i := 1; i <= 2; i++ {
|
||||
_, next := s.Failure(1024)
|
||||
if next != 1024 {
|
||||
t.Fatalf("failure %d reduced early to %d", i, next)
|
||||
}
|
||||
}
|
||||
s.Success(1024)
|
||||
for i := 1; i <= 2; i++ {
|
||||
_, next := s.Failure(1024)
|
||||
if next != 1024 {
|
||||
t.Fatalf("post-success failure %d reduced early to %d", i, next)
|
||||
}
|
||||
}
|
||||
_, next := s.Failure(1024)
|
||||
if next != 512 {
|
||||
t.Fatalf("third consecutive failure reduced to %d, want 512", next)
|
||||
}
|
||||
}
|
||||
|
||||
func startCalibrationTestServer(t *testing.T, threshold int) (string, func()) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
for {
|
||||
id, _, payload, err := protocol.ReadRequestFrameProfile(c, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if bytes.HasPrefix(payload, []byte("CIPERFUP ")) {
|
||||
parts := bytes.SplitN(payload, []byte(" "), 4)
|
||||
if len(parts) != 4 {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR bad upload"))
|
||||
continue
|
||||
}
|
||||
size, _ := strconv.Atoi(string(parts[2]))
|
||||
if size > threshold || len(parts[3]) != size || !bytes.Equal(parts[3], calibrationPattern(size)) {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR too large"))
|
||||
continue
|
||||
}
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("IPERFOK"))
|
||||
continue
|
||||
}
|
||||
if bytes.HasPrefix(payload, []byte("CIPERFDW ")) {
|
||||
parts := strings.Fields(string(payload))
|
||||
if len(parts) != 3 {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR bad download"))
|
||||
continue
|
||||
}
|
||||
size, _ := strconv.Atoi(parts[2])
|
||||
if size > threshold {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR too large"))
|
||||
continue
|
||||
}
|
||||
_ = protocol.WriteResponseFrame(c, id, calibrationPattern(size))
|
||||
continue
|
||||
}
|
||||
if bytes.HasPrefix(payload, []byte("CPROBE ")) {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("PROBEOK"))
|
||||
continue
|
||||
}
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR unsupported"))
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String(), func() {
|
||||
close(stop)
|
||||
_ = ln.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestXCalibrationRefinesUploadAndDownloadTo32Bytes(t *testing.T) {
|
||||
const threshold = 731237
|
||||
addr, closeServer := startCalibrationTestServer(t, threshold)
|
||||
defer closeServer()
|
||||
opts := Options{
|
||||
startSize: 1024 * 1024,
|
||||
minSize: 32,
|
||||
maxSize: 1024 * 1024,
|
||||
txnTimeout: time.Second,
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
download bool
|
||||
}{
|
||||
{"upload", false},
|
||||
{"download", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := calibrateMaximum(addr, "", opts, tc.download, 32)
|
||||
if got > threshold {
|
||||
t.Fatalf("calibrated size=%d exceeds threshold=%d", got, threshold)
|
||||
}
|
||||
if threshold-got > 32 {
|
||||
t.Fatalf("calibrated size=%d is more than 32 bytes below threshold=%d", got, threshold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestXCalibrationRetriesTransientFailure(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
const (
|
||||
threshold = 4096
|
||||
transientChunk = 2048
|
||||
)
|
||||
var transient atomic.Int32
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
for {
|
||||
id, _, payload, err := protocol.ReadRequestFrameProfile(c, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !bytes.HasPrefix(payload, []byte("CIPERFUP ")) {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR unsupported"))
|
||||
continue
|
||||
}
|
||||
parts := bytes.SplitN(payload, []byte(" "), 4)
|
||||
if len(parts) != 4 {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR bad upload"))
|
||||
continue
|
||||
}
|
||||
size, _ := strconv.Atoi(string(parts[2]))
|
||||
if size == transientChunk && transient.CompareAndSwap(0, 1) {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR transient"))
|
||||
return
|
||||
}
|
||||
if size > threshold || len(parts[3]) != size || !bytes.Equal(parts[3], calibrationPattern(size)) {
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("ERR too large"))
|
||||
continue
|
||||
}
|
||||
_ = protocol.WriteResponseFrame(c, id, []byte("IPERFOK"))
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
opts := Options{
|
||||
startSize: 16 * 1024,
|
||||
minSize: 32,
|
||||
maxSize: 16 * 1024,
|
||||
txnTimeout: time.Second,
|
||||
}
|
||||
got := calibrateMaximum(ln.Addr().String(), "", opts, false, 32)
|
||||
if transient.Load() != 1 {
|
||||
t.Fatalf("transient failure count=%d, want 1", transient.Load())
|
||||
}
|
||||
if got < transientChunk {
|
||||
t.Fatalf("X calibration collapsed below transiently failed %d-byte probe: got %d", transientChunk, got)
|
||||
}
|
||||
if got > threshold || threshold-got > 32 {
|
||||
t.Fatalf("X calibrated size=%d, want within 32 bytes below threshold=%d", got, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCalibratedChunksLocksIndependentXSizes(t *testing.T) {
|
||||
opts := Options{minSize: 32, maxSize: 1024 * 1024, startSize: 1024 * 1024, adaptive: true}
|
||||
opts = opts.WithCalibratedChunks(900000, 500000)
|
||||
if opts.adaptive {
|
||||
t.Fatal("X runtime adaptation remained enabled after calibration")
|
||||
}
|
||||
|
||||
up := newAdaptiveSizer("upload", opts.uploadStartSize, opts.uploadMaxSize, opts)
|
||||
down := newAdaptiveSizer("download", opts.downloadStartSize, opts.downloadMaxSize, opts)
|
||||
if up.Current() != 900000 || up.max != 900000 {
|
||||
t.Fatalf("upload current/max=%d/%d, want 900000", up.Current(), up.max)
|
||||
}
|
||||
if down.Current() != 500000 || down.max != 500000 {
|
||||
t.Fatalf("download current/max=%d/%d, want 500000", down.Current(), down.max)
|
||||
}
|
||||
|
||||
// The calibrated sizes are immutable during the X session. Neither a
|
||||
// transport failure nor a long run of successes may move them.
|
||||
if old, next := up.Failure(900000); old != 900000 || next != 900000 {
|
||||
t.Fatalf("upload failure changed calibrated chunk: %d -> %d", old, next)
|
||||
}
|
||||
for i := 0; i < 1000; i++ {
|
||||
up.Success(900000)
|
||||
down.Success(500000)
|
||||
}
|
||||
if up.Current() != 900000 {
|
||||
t.Fatalf("upload success changed calibrated chunk to %d", up.Current())
|
||||
}
|
||||
if old, next := down.Failure(500000); old != 500000 || next != 500000 {
|
||||
t.Fatalf("download failure changed calibrated chunk: %d -> %d", old, next)
|
||||
}
|
||||
if down.Current() != 500000 {
|
||||
t.Fatalf("download calibrated chunk changed to %d", down.Current())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user