Mult Port + TCP Calibration (SSH DEAD)

This commit is contained in:
2026-08-17 17:08:57 -03:00
parent 7ea221a99c
commit b997294607
58 changed files with 6033 additions and 497 deletions
+73 -8
View File
@@ -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
}
+450 -35
View File
@@ -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
}
+417
View File
@@ -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)
}
}
+210 -22
View File
@@ -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)
+349
View File
@@ -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
}
+66
View File
@@ -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)
}
}
+211
View File
@@ -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)
}
}
+222
View File
@@ -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
}
+223 -138
View File
@@ -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/")
}
+193 -46
View File
@@ -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)
+2 -1
View File
@@ -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
+88 -22
View File
@@ -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)
+176
View File
@@ -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)
}
})
}
+854
View File
@@ -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)
}
}
+97 -2
View File
@@ -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 {
+281
View File
@@ -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
}
+66
View File
@@ -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:])
}
}
+42
View File
@@ -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.
+2
View File
@@ -1,3 +1,5 @@
module dragontcp
go 1.22
require golang.org/x/crypto v0.31.0
+2
View File
@@ -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=
+2
View File
@@ -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=
+15 -4
View File
@@ -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 {
+8
View File
@@ -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
View File
@@ -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)
+236
View File
@@ -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())
}
}