// Package xorchunk is the LiteVPN v4 XOR chunk transport, carried over verbatim // so DragonTCP can speak the legacy UP/OK + XOR 0xAD wire on networks that pass // it but reject the newer binary records. // // It lives in its own package purely to avoid symbol collisions with the binary // transport in package main, which uses many of the same names. package xorchunk import ( "context" "crypto/rand" "encoding/hex" "fmt" "io" "net" "strconv" "strings" "sync" "sync/atomic" "time" "dragontcp/internal/protocol" ) // requestCounter correlates UP request frames with their OK responses. It lived // in v4's main.go; the transport needs it, so it moves in here. 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, pollers, reconnectEvery int, pollDelay, txnTimeout time.Duration, tcpBuffer int) Options { return Options{ startSize: startSize, minSize: minSize, maxSize: maxSize, adaptive: adaptive, adaptSuccesses: adaptSuccesses, adaptLog: adaptLog, pollers: pollers, reconnectEvery: reconnectEvery, pollDelay: pollDelay, txnTimeout: txnTimeout, tcpBuffer: tcpBuffer, } } 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 } func wireToken(token string) string { if token == "" { return "-" } return token } type adaptiveSizer struct { mu sync.Mutex name string current int min int max int adaptive bool adaptSuccesses int successes int good int bad int logChanges bool } func newAdaptiveSizer(name string, opts Options) *adaptiveSizer { start := opts.startSize if start < opts.minSize { start = opts.minSize } if start > opts.maxSize { start = opts.maxSize } return &adaptiveSizer{ name: name, current: start, min: opts.minSize, max: opts.maxSize, adaptive: opts.adaptive, adaptSuccesses: opts.adaptSuccesses, logChanges: opts.adaptLog, } } func (s *adaptiveSizer) Current() int { s.mu.Lock() n := s.current s.mu.Unlock() return n } func (s *adaptiveSizer) Success(attempted int) { s.mu.Lock() defer s.mu.Unlock() if !s.adaptive || s.current >= s.max { return } // Ignore stale successes from records that were already in flight when // another worker changed the shared size. if attempted != s.current { return } if attempted > s.good { s.good = attempted } s.successes++ growAfter := s.adaptSuccesses // When we have converged close to a known failure boundary, stay stable // longer before probing again. This also lets us discover later network // improvements without constantly oscillating around the boundary. if s.bad > 0 && s.bad-s.good <= 32 { growAfter *= 8 } if s.successes < growAfter { return } s.successes = 0 old := s.current var next int if s.bad > old+1 { // Binary-search the gap between known-good and known-bad sizes. next = old + (s.bad-old)/2 } else { // Either there is no known ceiling, or we have stayed stable long enough // at it to probe the network again in case conditions improved. if s.bad > 0 { s.bad = 0 } step := old / 4 if step < 32 { step = 32 } next = old + step } if next > s.max { next = s.max } if next <= old { return } s.current = next if s.logChanges { fmt.Printf("adaptive %s chunk: %d -> %d after stable success\n", s.name, old, next) } } func (s *adaptiveSizer) Failure(attempted int) (old, next int) { s.mu.Lock() defer s.mu.Unlock() old = s.current if !s.adaptive { return old, old } // Multiple pollers can fail on the same oversized value at once. Only the // first failure for the current value is allowed to reduce it. if attempted != s.current { return old, old } s.successes = 0 if s.bad == 0 || attempted < s.bad { s.bad = attempted } if s.good > 0 && s.good < attempted { // Return directly to the last size that was proven to work. next = s.good } else { // A previously-good value just failed, so conditions worsened. Forget // the old lower bound and use multiplicative decrease. s.good = 0 next = attempted / 2 } if next < s.min { next = s.min } if next >= attempted && attempted > s.min { next = attempted - 1 } if next < s.min { next = s.min } s.current = next if s.logChanges && next != old { fmt.Printf("adaptive %s chunk: %d -> %d after transport failure\n", s.name, old, next) } return old, next } type txnLane struct { mu sync.Mutex serverAddr string tcpBuffer int reconnectEvery int timeout time.Duration conn net.Conn count int closed bool } func newTxnLane(serverAddr string, tcpBuffer, reconnectEvery int, timeout time.Duration) *txnLane { return &txnLane{ serverAddr: serverAddr, tcpBuffer: tcpBuffer, reconnectEvery: reconnectEvery, timeout: timeout, } } func (l *txnLane) closeLocked() { if l.conn != nil { _ = l.conn.Close() l.conn = nil } l.count = 0 } func (l *txnLane) Close() { l.mu.Lock() l.closed = true l.closeLocked() l.mu.Unlock() } func (l *txnLane) ensureConn() error { if l.closed { return net.ErrClosed } if l.conn != nil && (l.reconnectEvery <= 0 || l.count < l.reconnectEvery) { return nil } l.closeLocked() d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} conn, err := d.Dial("tcp", l.serverAddr) if err != nil { return err } protocol.TuneTCP(conn) protocol.TuneTCPBuffer(conn, l.tcpBuffer) l.conn = conn return nil } // Do performs exactly one framed transaction. Higher layers decide whether a // failed data record should be retried at a smaller adaptive size. func (l *txnLane) Do(payload []byte) ([]byte, error) { l.mu.Lock() defer l.mu.Unlock() if err := l.ensureConn(); err != nil { return nil, err } timeout := l.timeout if timeout <= 0 { timeout = 5 * time.Second } _ = l.conn.SetDeadline(time.Now().Add(timeout)) requestID := requestCounter.Add(1) if err := protocol.WriteRequestFrame(l.conn, requestID, payload); err != nil { l.closeLocked() return nil, err } responseID, response, err := protocol.ReadResponseFrame(l.conn) if err != nil { l.closeLocked() return nil, err } if responseID != requestID { l.closeLocked() return nil, fmt.Errorf("request ID mismatch") } l.count++ _ = l.conn.SetDeadline(time.Time{}) if l.reconnectEvery > 0 && l.count >= l.reconnectEvery { // For restrictive TCP/53 networks, reconnectEvery=1 must really mean // one request/response per TCP connection. Close immediately after // receiving the response rather than waiting for the next request. l.closeLocked() } return response, nil } func doControl(lane *txnLane, payload []byte) ([]byte, error) { var lastErr error for attempt := 0; attempt < 5; attempt++ { resp, err := lane.Do(payload) if err == nil { return resp, nil } lastErr = err time.Sleep(time.Duration(attempt+1) * 40 * time.Millisecond) } return nil, lastErr } type chunkResult struct { seq uint64 data []byte final uint64 eof bool err error } type chunkConn struct { serverAddr string token string sid string opts Options pushLane *txnLane pullLanes []*txnLane upSizer *adaptiveSizer downSizer *adaptiveSizer serverMax int ctx context.Context cancel context.CancelFunc once sync.Once writeMu sync.Mutex upSeq uint64 claim atomic.Uint64 ack atomic.Int64 results chan chunkResult workers sync.WaitGroup readMu sync.Mutex pending map[uint64][]byte nextRead uint64 current []byte currentSeq uint64 finalKnown bool finalSeq uint64 terminalErr error } func randomSessionID() (string, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", err } return hex.EncodeToString(b[:]), nil } func Open(serverAddr, token, targetHost string, targetPort int, opts Options) (net.Conn, error) { 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 } if opts.startSize < opts.minSize { opts.startSize = opts.minSize } if opts.startSize > opts.maxSize { opts.startSize = opts.maxSize } if opts.adaptSuccesses < 1 { opts.adaptSuccesses = 64 } if opts.pollers < 1 { opts.pollers = 1 } if opts.pollers > 128 { opts.pollers = 128 } if opts.txnTimeout <= 0 { opts.txnTimeout = 5 * time.Second } sid, err := randomSessionID() if err != nil { return nil, err } ctx, cancel := context.WithCancel(context.Background()) c := &chunkConn{ serverAddr: serverAddr, token: token, sid: sid, opts: opts, ctx: ctx, cancel: cancel, results: make(chan chunkResult, opts.pollers*4), pending: make(map[uint64][]byte, opts.pollers*2), } c.ack.Store(-1) c.upSizer = newAdaptiveSizer("upload", opts) c.downSizer = newAdaptiveSizer("download", opts) c.pushLane = newTxnLane(serverAddr, opts.tcpBuffer, opts.reconnectEvery, opts.txnTimeout) openPayload := []byte(fmt.Sprintf( "COPEN %s %s %s %d", wireToken(token), sid, targetHost, targetPort, )) resp, err := doControl(c.pushLane, openPayload) if err != nil { c.pushLane.Close() cancel() return nil, err } fields := strings.Fields(string(resp)) if len(fields) != 2 || fields[0] != "OPENED" { c.pushLane.Close() cancel() return nil, fmt.Errorf("%s", resp) } serverMax, err := strconv.Atoi(fields[1]) if err != nil || serverMax < 32 { c.pushLane.Close() cancel() return nil, fmt.Errorf("bad OPENED response: %q", resp) } c.serverMax = serverMax if serverMax < c.opts.maxSize { c.opts.maxSize = serverMax c.upSizer.max = serverMax c.downSizer.max = serverMax if c.upSizer.current > serverMax { c.upSizer.current = serverMax } if c.downSizer.current > serverMax { c.downSizer.current = serverMax } } c.pullLanes = make([]*txnLane, opts.pollers) for i := 0; i < opts.pollers; i++ { lane := newTxnLane(serverAddr, opts.tcpBuffer, opts.reconnectEvery, opts.txnTimeout) c.pullLanes[i] = lane c.workers.Add(1) go c.pullWorker(lane) } return c, nil } func parseDataResponse(resp []byte) (seq uint64, offset int, total int, data []byte, err error) { if len(resp) < 6 || string(resp[:5]) != "DATA " { return 0, 0, 0, nil, fmt.Errorf("not DATA") } rest := resp[5:] fields := make([][]byte, 0, 3) start := 0 for i := 0; i < len(rest) && len(fields) < 3; i++ { if rest[i] == ' ' { fields = append(fields, rest[start:i]) start = i + 1 } } if len(fields) != 3 { return 0, 0, 0, nil, fmt.Errorf("bad DATA response") } seq, err = strconv.ParseUint(string(fields[0]), 10, 64) if err != nil { return 0, 0, 0, nil, err } offset, err = strconv.Atoi(string(fields[1])) if err != nil || offset < 0 { return 0, 0, 0, nil, fmt.Errorf("bad DATA offset") } total, err = strconv.Atoi(string(fields[2])) if err != nil || total < 0 { return 0, 0, 0, nil, fmt.Errorf("bad DATA total") } // start now points immediately after the third separator. return seq, offset, total, rest[start:], nil } func (c *chunkConn) pullWorker(lane *txnLane) { defer c.workers.Done() for { select { case <-c.ctx.Done(): return default: } seq := c.claim.Add(1) - 1 offset := 0 var assembled []byte consecutiveMinFailures := 0 for { select { case <-c.ctx.Done(): return default: } limit := c.downSizer.Current() ack := c.ack.Load() payload := []byte(fmt.Sprintf( "CPULL %s %s %d %d %d %d", wireToken(c.token), c.sid, ack, seq, offset, limit, )) resp, err := lane.Do(payload) if err != nil { old, next := c.downSizer.Failure(limit) if next == old && next == c.opts.minSize { consecutiveMinFailures++ } else { consecutiveMinFailures = 0 } if consecutiveMinFailures >= 8 { select { case c.results <- chunkResult{seq: seq, err: fmt.Errorf("download failed at minimum chunk %d: %w", next, err)}: case <-c.ctx.Done(): } return } time.Sleep(30 * time.Millisecond) continue } if string(resp) == "WAIT" { if c.opts.pollDelay > 0 { select { case <-time.After(c.opts.pollDelay): case <-c.ctx.Done(): return } } continue } if strings.HasPrefix(string(resp), "ERR ") { select { case c.results <- chunkResult{seq: seq, err: fmt.Errorf("%s", resp)}: case <-c.ctx.Done(): } return } if strings.HasPrefix(string(resp), "EOF ") { n, err := strconv.ParseUint(strings.TrimSpace(string(resp[4:])), 10, 64) if err != nil { select { case c.results <- chunkResult{seq: seq, err: err}: case <-c.ctx.Done(): } return } select { case c.results <- chunkResult{seq: seq, eof: true, final: n}: case <-c.ctx.Done(): } break } gotSeq, gotOffset, total, fragment, err := parseDataResponse(resp) if err != nil { select { case c.results <- chunkResult{seq: seq, err: err}: case <-c.ctx.Done(): } return } if gotSeq != seq || gotOffset != offset { select { case c.results <- chunkResult{seq: seq, err: fmt.Errorf("DATA position mismatch")}: case <-c.ctx.Done(): } return } if total > c.serverMax || total < offset+len(fragment) || len(fragment) == 0 { select { case c.results <- chunkResult{seq: seq, err: fmt.Errorf("invalid DATA fragment size")}: case <-c.ctx.Done(): } return } if assembled == nil { assembled = make([]byte, 0, total) } assembled = append(assembled, fragment...) offset += len(fragment) consecutiveMinFailures = 0 c.downSizer.Success(limit) if offset == total { select { case c.results <- chunkResult{seq: seq, data: assembled}: case <-c.ctx.Done(): } break } } } } func (c *chunkConn) Read(p []byte) (int, error) { c.readMu.Lock() defer c.readMu.Unlock() for { if len(c.current) > 0 { n := copy(p, c.current) c.current = c.current[n:] if len(c.current) == 0 { c.nextRead++ c.ack.Store(int64(c.currentSeq)) } return n, nil } if c.terminalErr != nil { return 0, c.terminalErr } if c.finalKnown && c.nextRead >= c.finalSeq { return 0, io.EOF } if data, ok := c.pending[c.nextRead]; ok { delete(c.pending, c.nextRead) c.current = data c.currentSeq = c.nextRead continue } result, ok := <-c.results if !ok { return 0, io.EOF } if result.err != nil { c.terminalErr = result.err return 0, result.err } if result.eof { if !c.finalKnown || result.final < c.finalSeq { c.finalKnown = true c.finalSeq = result.final } continue } if result.seq < c.nextRead { continue } c.pending[result.seq] = result.data } } func parseAck(resp []byte, expectedSeq uint64) (int, error) { fields := strings.Fields(string(resp)) if len(fields) != 3 || fields[0] != "ACK" { return 0, fmt.Errorf("bad CPUSH response: %q", resp) } seq, err := strconv.ParseUint(fields[1], 10, 64) if err != nil || seq != expectedSeq { return 0, fmt.Errorf("bad CPUSH sequence: %q", resp) } n, err := strconv.Atoi(fields[2]) if err != nil || n <= 0 { return 0, fmt.Errorf("bad CPUSH length: %q", resp) } return n, nil } func (c *chunkConn) Write(p []byte) (int, error) { c.writeMu.Lock() defer c.writeMu.Unlock() total := 0 consecutiveMinFailures := 0 for len(p) > 0 { size := c.upSizer.Current() n := size if len(p) < n { n = len(p) } seq := c.upSeq prefix := []byte(fmt.Sprintf("CPUSH %s %s %d ", wireToken(c.token), c.sid, seq)) payload := make([]byte, len(prefix)+n) copy(payload, prefix) copy(payload[len(prefix):], p[:n]) resp, err := c.pushLane.Do(payload) if err != nil { old, next := c.upSizer.Failure(size) if next == old && next == c.opts.minSize { consecutiveMinFailures++ } else { consecutiveMinFailures = 0 } if consecutiveMinFailures >= 8 { return total, fmt.Errorf("upload failed at minimum chunk %d: %w", next, err) } time.Sleep(30 * time.Millisecond) continue } if strings.HasPrefix(string(resp), "ERR ") { return total, fmt.Errorf("%s", resp) } accepted, err := parseAck(resp, seq) if err != nil { return total, err } if accepted > len(p) { return total, fmt.Errorf("server ACK length %d exceeds pending write %d", accepted, len(p)) } c.upSeq++ total += accepted p = p[accepted:] consecutiveMinFailures = 0 c.upSizer.Success(size) } return total, nil } func (c *chunkConn) Close() error { c.once.Do(func() { c.cancel() lane := newTxnLane(c.serverAddr, c.opts.tcpBuffer, 1, c.opts.txnTimeout) _, _ = doControl(lane, []byte(fmt.Sprintf("CCLOSE %s %s", wireToken(c.token), c.sid))) lane.Close() if c.pushLane != nil { c.pushLane.Close() } for _, lane := range c.pullLanes { lane.Close() } c.workers.Wait() close(c.results) }) return nil } func (c *chunkConn) LocalAddr() net.Addr { return dummyAddr("dragontcp-chunk-local") } func (c *chunkConn) RemoteAddr() net.Addr { return dummyAddr("dragontcp-chunk-remote") } func (c *chunkConn) SetDeadline(time.Time) error { return nil } func (c *chunkConn) SetReadDeadline(time.Time) error { return nil } func (c *chunkConn) SetWriteDeadline(time.Time) error { return nil } type dummyAddr string func (d dummyAddr) Network() string { return "dragontcp-chunk" } func (d dummyAddr) String() string { return string(d) }