diff --git a/README.md b/README.md index ce8c825..746ac98 100644 --- a/README.md +++ b/README.md @@ -386,25 +386,25 @@ front end never knows it is talking to a record protocol. It holds: * `downloadOffset` — next byte to request, * `consumedOffset` — next unread byte, sent as `ack`, * `readBuf` — received but not yet delivered to the reader, -* two independent `requestLane`s, one for uploads and one for downloads, so a +* two `requestLane`s of its own, one for uploads and one for downloads, so a blocking download poll never delays an upload. `Write` slices the caller's buffer into records of the current upload size, each acknowledged before the next is sent. `Read` refills `readBuf` via `fillReadBuffer`, which issues batched download requests. `Close` sends -`ModeClose` on a throwaway lane and closes both persistent lanes. +`ModeClose` over the upload lane, then closes both lanes. ### 5.3 Request lanes and connection reuse A `requestLane` owns at most one physical TCP connection and serialises requests -onto it under a mutex. Any I/O error discards the connection; the next request -redials. Sockets get `TCP_NODELAY`, 30-second keepalives, and optionally explicit -buffer sizes via `--tcp-buffer`. +onto it under a mutex. Each tunnel has two of them (§5.7). Any I/O error discards +the connection; the next request redials. Sockets get `TCP_NODELAY`, 30-second +keepalives, and optionally explicit buffer sizes via `--tcp-buffer`. | `--chunk-reconnect-every` | Behaviour | |---|---| | `0` | Persistent — one connection for the life of the lane | -| `1` | Auto — persistent if the path probe showed reuse works, otherwise one logical request per connection | +| `1` (default) | Auto — persistent if the path probe showed reuse works, otherwise one logical request per connection. Resolved silently, since it runs once per flow | | `N ≥ 2` | Rotate — close and redial after N logical requests | ### 5.4 Path probing @@ -510,7 +510,33 @@ count = max(count, minPipeline) // the floor always wins The server independently clamps `count` to 256 and `limit` to its own `--chunk-max`, so a client can never demand more than the server allows. -### 5.7 Failure escalation +### 5.7 Connection model + +Each proxied socket gets its own tunnel, and each tunnel dials **two** TCP +connections to the server: one upload lane and one download lane. `OPEN` and +`CLOSE` ride the upload lane rather than dialling their own connections. + +So a device browsing normally holds roughly `2 × active flows` connections to +port 53, plus churn as flows come and go. That is the transport behaving as a +proxy, not as a single multiplexed link. + +**Why it is not one connection.** A response header is only `status + length` — +it carries no session or request ID. Responses can therefore only be matched to +requests by **arrival order**, which means a connection must finish one full +exchange before another session may use it. Since a download is a long poll that +can block for `--chunk-poll-wait`, sharing one connection across sessions lets +idle pollers starve real traffic. Per-tunnel lanes are a requirement of the +current wire format, not an oversight. + +Making the client hold a single connection would require adding a session ID to +the response header, demultiplexing responses asynchronously on the client, and +handling requests concurrently per connection on the server — a wire-format +change affecting both ends. + +`--chunk-pollers` is accepted for compatibility and validated to 1–128, but the +transport uses one download worker per tunnel and never reads it. + +### 5.8 Failure escalation On a download failure the client escalates in a fixed order: @@ -800,15 +826,14 @@ checksum of zero is written as `0xFFFF` per RFC 768. | Min chunk | 32 | `--chunk-min` | | Batch max | 1 | `--chunk-concurrency` | | Batch min | 1 | `--chunk-concurrency-min` | -| Reconnect every | 0 | `--chunk-reconnect-every` | +| Reconnect every | 1 (auto) | `--chunk-reconnect-every` | | Timeout (s) | 2 | `--chunk-timeout` | Fixed by the service: `--listen-host 127.0.0.1`, `--listen-port 8080`, -`--transport chunk`, `--chunk-pollers 1`, `--chunk-grow-after 16`, -`--chunk-adapt-log=true`. +`--transport chunk`, `--chunk-grow-after 16`, `--chunk-adapt-log=true`. -Settings are laid out in four cards: CONNECTION, RECORD SIZE, DOWNLOAD BATCH, and -ADVANCED. The batch card carries a live hint that restates the current setting in +Settings are laid out in four cards: CONNECTION, RECORD SIZE, DOWNLOAD BATCH, +and ADVANCED. The batch card carries a live hint that restates the current setting in words as you type, so the mode is never ambiguous: ```text @@ -827,7 +852,8 @@ Download batch: pinned at 5 records per request (never adapts) ``` Validation ranges: port 1–65535, max chunk 32–1048576, min chunk 32–max chunk, -batch values 1–256 with `min ≤ max`, reconnect 0–1000000, timeout 1–120. +batch values 1–256 with `min ≤ max`, reconnect 0–1000000, +timeout 1–120. ### 7.10 Logs @@ -1013,7 +1039,8 @@ Max chunk: 1048576 Min chunk: 32 Batch max: 1 Batch min: 1 -Reconnect every: 0 +Pollers: 1 +Reconnect every: 1 Timeout (s): 2 ``` @@ -1065,7 +1092,7 @@ Use **OPEN LOGS** to watch the path probe and any adaptation. | `--chunk-reconnect-every` | `0` | 0 persistent, 1 auto, N rotate | | `--chunk-poll-delay` | `2ms` | Pause after an empty poll | | `--chunk-timeout` | `2s` | Per-record transaction timeout | -| `--chunk-pollers` | `1` | Reserved compatibility knob; accepted but unused | +| `--chunk-pollers` | `1` | Accepted for compatibility; validated 1–128 but unused | The `concurrency` flag names are historical. They control the download **batch depth** described in §5.6, not any form of threading. @@ -1099,6 +1126,15 @@ manual tuning makes things worse. 1. More records per request is the main lever when latency to the server is high, because each round trip returns more data. If the log repeatedly shows `adaptive download batch: N -> N/2`, the path cannot sustain that depth. +* **Streams die mid-transfer, or nothing loads at all.** Set `Reconnect every` + to `1` (auto). `0` forces persistent connections, and many networks silently + kill long-lived port-53 connections; auto probes first and falls back to one + logical request per connection when persistence does not survive. This is the + single most important setting on a restrictive path. +* **Logs show the same transition many times over (`128 -> 64` repeatedly).** + Each tunnel adapts independently, so a burst of flows produces a burst of + identical lines. The app collapses consecutive duplicates into a counted line; + the underlying behaviour is normal. * **The batch keeps collapsing to 1 and throughput dies with it.** Some paths only deliver correctly at one specific number of records. Try `Batch max = Batch min = N` for a few values of N and leave it pinned at whichever works. Pinned mode diff --git a/SHA256SUMS b/SHA256SUMS index a9ba0ab..73e82ce 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,4 +1,4 @@ -4e4a16f6c537f2c6be5c104f6e50ea907122092a21402a70506fe12e6f144bb7 bin/dragontcp-hybrid-server-linux-amd64 -39a6bb16cbddefb50e791220747f5cd01b0c894992c657b24c6c2e5d1501299f bin/dragontcp-hybrid-server-linux-arm64 -66ad3741d84b4e73098912725af762824bec629adab5522a5e161b4e23b79dee bin/dragontcp-hybrid-client-linux-amd64 -fa6b41cc8c2999932cc78f731cb249cab677af9df7701c491fb688f9cbcb089d android/lib/arm64-v8a/libdragontcp_client.so +56707362bae6b388795150a77b27a14de046a56e05037485d5dfb4bb7db0f8b9 *bin/dragontcp-hybrid-server-linux-amd64 +35e7dbbb84bbb76b0eea052eff18d58e24c1fa1c43274aa6c1a5c686a1d38f40 *bin/dragontcp-hybrid-server-linux-arm64 +2f820ce82a65c285684c875afd0311f1d62afe856f8b878eab682f9864252a19 *bin/dragontcp-hybrid-client-linux-amd64 +1b5bcf4d3a446cec397206557fa6f1a4c229b11b8e871a7a1072ac3ae9f81e31 *android/lib/arm64-v8a/libdragontcp_client.so diff --git a/android/lib/arm64-v8a/libdragontcp_client.so b/android/lib/arm64-v8a/libdragontcp_client.so index d3912ed..b69a4d9 100644 Binary files a/android/lib/arm64-v8a/libdragontcp_client.so and b/android/lib/arm64-v8a/libdragontcp_client.so differ diff --git a/android/src/com/dragontcp/client/AppLog.java b/android/src/com/dragontcp/client/AppLog.java index f84d320..815fbb5 100644 --- a/android/src/com/dragontcp/client/AppLog.java +++ b/android/src/com/dragontcp/client/AppLog.java @@ -11,6 +11,8 @@ public final class AppLog { private static final int MAX_LINES = 600; private static final ArrayDeque lines = new ArrayDeque<>(); private static final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + private static String lastLine; + private static int repeatCount; private AppLog() {} @@ -18,10 +20,36 @@ public final class AppLog { if (line == null) return; line = line.trim(); if (line.isEmpty()) return; + + // The core emits one adaptation line per proxied flow, so a busy page + // load produces dozens of identical entries. Collapse consecutive + // repeats into a single counted line instead of burying the log. + String emit; synchronized (lines) { - while (lines.size() >= MAX_LINES) lines.removeFirst(); - lines.addLast(line); + if (line.equals(lastLine)) { + repeatCount++; + return; + } + if (repeatCount > 0) { + String summary = " (previous line repeated " + repeatCount + " more times)"; + repeatCount = 0; + push(summary); + notifyListeners(summary); + } + lastLine = line; + push(line); + emit = line; } + notifyListeners(emit); + } + + /** Caller must hold the {@code lines} lock. */ + private static void push(String line) { + while (lines.size() >= MAX_LINES) lines.removeFirst(); + lines.addLast(line); + } + + private static void notifyListeners(String line) { for (Listener listener : listeners) { try { listener.onLine(line); } catch (Throwable ignored) {} } @@ -36,7 +64,11 @@ public final class AppLog { } public static void clear() { - synchronized (lines) { lines.clear(); } + synchronized (lines) { + lines.clear(); + lastLine = null; + repeatCount = 0; + } } public static void addListener(Listener listener) { listeners.addIfAbsent(listener); } diff --git a/android/src/com/dragontcp/client/DragonService.java b/android/src/com/dragontcp/client/DragonService.java index b06b47c..13404bf 100644 --- a/android/src/com/dragontcp/client/DragonService.java +++ b/android/src/com/dragontcp/client/DragonService.java @@ -38,6 +38,8 @@ public class DragonService extends VpnService { public static final String EXTRA_SERVER = "server"; public static final String EXTRA_PORT = "port"; public static final String EXTRA_TOKEN = "token"; + /** Wire format: "auto", "b" or "x". */ + public static final String EXTRA_WIRE = "wire"; public static final String EXTRA_CHUNK_MAX = "chunkMax"; public static final String EXTRA_CHUNK_MIN = "chunkMin"; /** Maximum download records per request. A transport setting, not a thread count. */ @@ -110,11 +112,12 @@ public class DragonService extends VpnService { String server = intent.getStringExtra(EXTRA_SERVER); int port = intent.getIntExtra(EXTRA_PORT, 53); String token = intent.getStringExtra(EXTRA_TOKEN); + String wire = intent.getStringExtra(EXTRA_WIRE); int chunkMax = intent.getIntExtra(EXTRA_CHUNK_MAX, 1024 * 1024); int chunkMin = intent.getIntExtra(EXTRA_CHUNK_MIN, 32); int batchMax = intent.getIntExtra(EXTRA_BATCH_MAX, 1); int batchMin = intent.getIntExtra(EXTRA_BATCH_MIN, 1); - int reconnect = intent.getIntExtra(EXTRA_RECONNECT, 0); + int reconnect = intent.getIntExtra(EXTRA_RECONNECT, 1); int timeout = intent.getIntExtra(EXTRA_TIMEOUT, 2); if (server == null || server.trim().isEmpty()) { @@ -123,6 +126,7 @@ public class DragonService extends VpnService { } server = server.trim(); if (token == null) token = ""; + if (wire == null || !(wire.equals("b") || wire.equals("x"))) wire = "auto"; chunkMax = Math.max(32, Math.min(1024 * 1024, chunkMax)); chunkMin = Math.max(32, Math.min(chunkMax, chunkMin)); batchMax = Math.max(1, Math.min(BATCH_LIMIT, batchMax)); @@ -133,7 +137,8 @@ public class DragonService extends VpnService { try { AppLog.append("Starting DragonTCP → " + server + ":" + port); AppLog.append(describeBatch(batchMin, batchMax)); - Process process = startDragonCore(server, port, token, chunkMax, chunkMin, batchMax, batchMin, reconnect, timeout); + AppLog.append(describeWire(wire)); + Process process = startDragonCore(server, port, token, chunkMax, chunkMin, batchMax, batchMin, wire, reconnect, timeout); synchronized (stateLock) { coreProcess = process; } startCoreLogReader(process); @@ -199,6 +204,7 @@ public class DragonService extends VpnService { int chunkMin, int batchMax, int batchMin, + String wire, int reconnect, int timeout ) throws Exception { @@ -217,6 +223,7 @@ public class DragonService extends VpnService { cmd.add("--chunk-min"); cmd.add(Integer.toString(chunkMin)); cmd.add("--chunk-max"); cmd.add(Integer.toString(chunkMax)); cmd.add("--chunk-pollers"); cmd.add("1"); + cmd.add("--wire"); cmd.add(wire); cmd.add("--chunk-concurrency"); cmd.add(Integer.toString(batchMax)); cmd.add("--chunk-concurrency-min"); cmd.add(Integer.toString(batchMin)); cmd.add("--chunk-reconnect-every"); cmd.add(Integer.toString(reconnect)); @@ -239,6 +246,14 @@ public class DragonService extends VpnService { return "Download batch: adaptive " + min + "-" + max + " records per request"; } + + /** Human-readable summary of the wire selection, for the log screen. */ + private static String describeWire(String wire) { + if ("b".equals(wire)) return "Wire: B (manual)"; + if ("x".equals(wire)) return "Wire: X (manual)"; + return "Wire: auto (probing)"; + } + private void startCoreLogReader(Process process) { Thread reader = new Thread(() -> { try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) { @@ -246,7 +261,7 @@ public class DragonService extends VpnService { while ((line = br.readLine()) != null) { // Keep the UI useful: adaptation changes and real errors only. String lower = line.toLowerCase(); - if (line.startsWith("adaptive ") || line.startsWith("path probe:") || lower.contains("error") || lower.contains("failed")) { + if (line.startsWith("adaptive ") || line.startsWith("path probe:") || line.startsWith("wire") || lower.contains("error") || lower.contains("failed")) { AppLog.append(line); } } diff --git a/android/src/com/dragontcp/client/MainActivity.java b/android/src/com/dragontcp/client/MainActivity.java index 6f3fefe..561a4d5 100644 --- a/android/src/com/dragontcp/client/MainActivity.java +++ b/android/src/com/dragontcp/client/MainActivity.java @@ -53,9 +53,14 @@ public class MainActivity extends Activity { private EditText batchMax; private EditText batchMin; private EditText reconnect; + private Button wireAuto; + private Button wireB; + private Button wireX; + private String wireMode = "auto"; private EditText timeout; private TextView batchHint; + private TextView wireHint; private Button connectButton; private Button stopButton; private Button logsButton; @@ -159,6 +164,27 @@ public class MainActivity extends Activity { connectionCard.addView(connectionRow); settings.addView(connectionCard, cardParams()); + // ---------------------------------------------------------------- wire + LinearLayout wireCard = card("WIRE"); + wireCard.addView(hint("Networks differ in which mode they pass.")); + LinearLayout wireRow = row(); + wireAuto = segmentButton("A"); + wireB = segmentButton("B"); + wireX = segmentButton("X"); + addSegment(wireRow, wireAuto); + addSegment(wireRow, wireB); + addSegment(wireRow, wireX); + wireCard.addView(wireRow); + wireHint = text("", 11, MUTED, true); + wireHint.setLineSpacing(0, 1.08f); + wireHint.setPadding(dp(2), dp(8), dp(2), dp(2)); + wireCard.addView(wireHint); + settings.addView(wireCard, cardParams()); + + wireAuto.setOnClickListener(v -> setWireMode("auto")); + wireB.setOnClickListener(v -> setWireMode("b")); + wireX.setOnClickListener(v -> setWireMode("x")); + // --------------------------------------------------------- record size LinearLayout sizeCard = card("RECORD SIZE (BYTES)"); LinearLayout chunks = row(); @@ -200,10 +226,12 @@ public class MainActivity extends Activity { // ------------------------------------------------------------ advanced LinearLayout advancedCard = card("ADVANCED"); LinearLayout timing = row(); - reconnect = addFieldToRow(timing, "Reconnect every", "0 = persistent", "0", true, false, 0.58f); + reconnect = addFieldToRow(timing, "Reconnect every", "1 = auto", "1", true, false, 0.58f); timeout = addFieldToRow(timing, "Timeout (s)", "2", "2", true, false, 0.42f); advancedCard.addView(timing); - advancedCard.addView(hint("0 reconnect = persistent • 1 = auto • N = rotate every N requests")); + advancedCard.addView(hint( + "1 = auto (recommended: probes the path, falls back to one request per " + + "connection) • 0 = persistent • N = rotate every N requests")); settings.addView(advancedCard, cardParams()); // ------------------------------------------------------------- buttons @@ -284,6 +312,7 @@ public class MainActivity extends Activity { + " on errors, recovers to " + max + "."); } + private Integer readInt(EditText field) { if (field == null) return null; String raw = field.getText().toString().trim(); @@ -364,6 +393,47 @@ public class MainActivity extends Activity { return edit; } + private Button segmentButton(String label) { + Button b = new Button(this); + b.setText(label); + b.setTextSize(15); + b.setTypeface(Typeface.DEFAULT, Typeface.BOLD); + b.setAllCaps(false); + return b; + } + + private void addSegment(LinearLayout parent, Button b) { + LinearLayout.LayoutParams p = new LinearLayout.LayoutParams(0, dp(46), 1f); + if (parent.getChildCount() > 0) p.setMarginStart(dp(8)); + parent.addView(b, p); + } + + /** Selects the wire format and repaints the segmented control. */ + private void setWireMode(String mode) { + wireMode = mode; + paintSegment(wireAuto, "auto".equals(mode)); + paintSegment(wireB, "b".equals(mode)); + paintSegment(wireX, "x".equals(mode)); + if (wireHint == null) return; + if ("auto".equals(mode)) { + wireHint.setTextColor(ACCENT); + wireHint.setText("Auto: tries B, then X, keeping the one that connects."); + } else if ("b".equals(mode)) { + wireHint.setTextColor(OK); + wireHint.setText("Manual: B."); + } else { + wireHint.setTextColor(OK); + wireHint.setText("Manual: X."); + } + } + + private void paintSegment(Button b, boolean selected) { + if (b == null) return; + b.setTextColor(selected ? Color.WHITE : Color.rgb(132, 142, 155)); + b.setBackground(roundRect(selected ? ACCENT : Color.rgb(32, 38, 47), 12, + selected ? ACCENT : BORDER, 1)); + } + private LinearLayout row() { LinearLayout row = new LinearLayout(this); row.setOrientation(LinearLayout.HORIZONTAL); @@ -446,11 +516,12 @@ public class MainActivity extends Activity { i.putExtra(DragonService.EXTRA_SERVER, p.getString("server", "")); i.putExtra(DragonService.EXTRA_PORT, p.getInt("port", 53)); i.putExtra(DragonService.EXTRA_TOKEN, p.getString("token", "")); + i.putExtra(DragonService.EXTRA_WIRE, p.getString("wire", "auto")); i.putExtra(DragonService.EXTRA_CHUNK_MAX, p.getInt("max", 1048576)); i.putExtra(DragonService.EXTRA_CHUNK_MIN, p.getInt("min", 32)); i.putExtra(DragonService.EXTRA_BATCH_MAX, p.getInt("batchMax", 1)); i.putExtra(DragonService.EXTRA_BATCH_MIN, p.getInt("batchMin", 1)); - i.putExtra(DragonService.EXTRA_RECONNECT, p.getInt("reconnect", 0)); + i.putExtra(DragonService.EXTRA_RECONNECT, p.getInt("reconnect", 1)); i.putExtra(DragonService.EXTRA_TIMEOUT, p.getInt("timeout", 2)); if (Build.VERSION.SDK_INT >= 26) startForegroundService(i); else startService(i); } @@ -471,6 +542,7 @@ public class MainActivity extends Activity { .putString("server", h) .putInt("port", p) .putString("token", token.getText().toString()) + .putString("wire", wireMode) .putInt("max", max) .putInt("min", min) .putInt("batchMax", bMax) @@ -493,11 +565,12 @@ public class MainActivity extends Activity { server.setText(p.getString("server", "")); port.setText(Integer.toString(p.getInt("port", 53))); token.setText(p.getString("token", "")); + setWireMode(p.getString("wire", "auto")); chunkMax.setText(Integer.toString(p.getInt("max", 1048576))); chunkMin.setText(Integer.toString(p.getInt("min", 32))); batchMax.setText(Integer.toString(p.getInt("batchMax", 1))); batchMin.setText(Integer.toString(p.getInt("batchMin", 1))); - reconnect.setText(Integer.toString(p.getInt("reconnect", 0))); + reconnect.setText(Integer.toString(p.getInt("reconnect", 1))); timeout.setText(Integer.toString(p.getInt("timeout", 2))); } diff --git a/bin/dragontcp-hybrid-client-linux-amd64 b/bin/dragontcp-hybrid-client-linux-amd64 index f7223aa..26dfe89 100644 Binary files a/bin/dragontcp-hybrid-client-linux-amd64 and b/bin/dragontcp-hybrid-client-linux-amd64 differ diff --git a/bin/dragontcp-hybrid-server-linux-amd64 b/bin/dragontcp-hybrid-server-linux-amd64 index f44b5f4..f05687a 100644 Binary files a/bin/dragontcp-hybrid-server-linux-amd64 and b/bin/dragontcp-hybrid-server-linux-amd64 differ diff --git a/bin/dragontcp-hybrid-server-linux-arm64 b/bin/dragontcp-hybrid-server-linux-arm64 index 7e55ea5..f0cdb32 100644 Binary files a/bin/dragontcp-hybrid-server-linux-arm64 and b/bin/dragontcp-hybrid-server-linux-arm64 differ diff --git a/core/cmd/dragontcp-client/chunk.go b/core/cmd/dragontcp-client/chunk.go index 5efe873..dfee850 100644 --- a/core/cmd/dragontcp-client/chunk.go +++ b/core/cmd/dragontcp-client/chunk.go @@ -571,41 +571,40 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts // 0 = persistent (CLI explicit) // 1 = auto: persistent when the path probe succeeds, otherwise one request/connection // N>=2 = force connection rotation after N logical requests - if reconnect == 1 { - if profile.persistent { - reconnect = 0 - fmt.Printf("path probe: reconnect mode auto -> persistent\n") - } else { - fmt.Printf("path probe: reconnect mode auto -> every request\n") - } + // Resolved silently: this runs once per proxied flow, so it must never log. + if reconnect == 1 && profile.persistent { + reconnect = 0 } sid, err := randomSessionID() if err != nil { return nil, err } - control := newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout) + // OPEN rides the upload lane instead of a throwaway connection. A dedicated + // control connection cost one extra dial per proxied flow, which shows up on + // the server as connection churn on top of the steady-state count. + uploadLane := newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout) payload, err := encodeOpen(token, targetHost, targetPort) if err != nil { - control.Close() + uploadLane.Close() return nil, err } - status, body, err := control.single(wire.ModeOpen, sid, 0, payload) + status, body, err := uploadLane.single(wire.ModeOpen, sid, 0, payload) if err != nil { - control.Close() + uploadLane.Close() return nil, err } if status == wire.StatusError { - control.Close() + uploadLane.Close() return nil, fmt.Errorf("%s", string(body)) } if status != wire.StatusOK || len(body) != 4 { - control.Close() + uploadLane.Close() return nil, fmt.Errorf("bad OPEN response") } serverMax := int(binary.BigEndian.Uint32(body)) - control.Close() if serverMax < opts.minSize { + uploadLane.Close() return nil, fmt.Errorf("server maximum chunk %d is below client minimum %d", serverMax, opts.minSize) } if opts.maxSize > serverMax { @@ -624,13 +623,12 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts sid: sid, opts: opts, serverMax: serverMax, - uploadLane: newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout), + uploadLane: uploadLane, downloadLane: newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout), - // Start at the user-configured ceiling. On transport failures the - // pipeline is halved; successful data responses grow it back by one, - // always staying inside minPipeline..maxPipeline. When the two bounds - // are equal the depth is pinned and never adapts, which is what paths - // that only work at one specific batch size need. + // Start at the configured ceiling. On transport failure the batch is + // halved but never below minPipeline; successful data grows it back by + // one. When min == max the depth is pinned and never adapts, which is + // what paths that only work at one specific batch size need. pipeline: opts.maxPipeline, minPipeline: opts.minPipeline, maxPipeline: opts.maxPipeline, @@ -640,53 +638,6 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts return c, nil } -// pinnedBatch reports whether the batch depth is fixed. A pinned depth never -// grows or shrinks: some paths only deliver correctly at one specific number of -// records per request, so the adaptive controller must stay out of the way. -func (c *chunkConn) pinnedBatch() bool { return c.minPipeline >= c.maxPipeline } - -// batchCount is how many records the next download request will ask for. -func (c *chunkConn) batchCount(chunk int) int { - count := c.pipeline - if count < c.minPipeline { - count = c.minPipeline - } - if count > c.maxPipeline { - count = c.maxPipeline - } - // Bound each batch to roughly 1 MiB of useful data, but never below the - // configured floor: a pinned depth is a path requirement, not a hint. - if maxCount := (1024 * 1024) / maxInt(chunk, 1); maxCount < count { - count = maxInt(maxCount, c.minPipeline) - } - return count -} - -// growPipeline widens the batch by one after a successful data response. -func (c *chunkConn) growPipeline() { - if c.pinnedBatch() { - return - } - if c.pipeline < c.maxPipeline { - c.pipeline++ - } -} - -// shrinkPipeline halves the batch after a transport failure. It reports the old -// and new depth, and whether anything actually changed; when it returns false -// the caller should shrink the record size instead. -func (c *chunkConn) shrinkPipeline() (int, int, bool) { - if c.pinnedBatch() || c.pipeline <= c.minPipeline { - return c.pipeline, c.pipeline, false - } - old := c.pipeline - c.pipeline /= 2 - if c.pipeline < c.minPipeline { - c.pipeline = c.minPipeline - } - return old, c.pipeline, old != c.pipeline -} - func (c *chunkConn) fillReadBuffer() error { if c.eof { return io.EOF @@ -694,7 +645,18 @@ func (c *chunkConn) fillReadBuffer() error { minFailures := 0 for len(c.readBuf) == 0 && !c.eof { chunk := c.downSizer.Current() - count := c.batchCount(chunk) + count := c.pipeline + if count < c.minPipeline { + count = c.minPipeline + } + if count > c.maxPipeline { + count = c.maxPipeline + } + // Bound each batch to roughly 1 MiB of useful data, but never below the + // configured floor: a pinned depth is a path requirement, not a hint. + if maxCount := (1024 * 1024) / maxInt(chunk, 1); maxCount < count { + count = maxInt(maxCount, c.minPipeline) + } data, status, err := c.downloadLane.download(c.sid, c.downloadOffset, c.consumedOffset, chunk, count) for _, part := range data { @@ -703,16 +665,20 @@ func (c *chunkConn) fillReadBuffer() error { } if len(data) > 0 { c.downSizer.Success(chunk) - c.growPipeline() + if c.pipeline < c.maxPipeline { + c.pipeline++ + } minFailures = 0 } if err != nil { - // Shrink the batch first, then the record size. When the batch is - // pinned (min == max) the depth is left alone entirely and only the - // record size adapts. - if old, next, shrank := c.shrinkPipeline(); shrank { - if c.opts.adaptLog { - fmt.Printf("adaptive download batch: %d -> %d after transport failure\n", old, next) + if c.pipeline > c.minPipeline { + old := c.pipeline + c.pipeline /= 2 + if c.pipeline < c.minPipeline { + c.pipeline = c.minPipeline + } + if c.opts.adaptLog && old != c.pipeline { + fmt.Printf("adaptive download pipeline: %d -> %d after transport failure\n", old, c.pipeline) } } else { old, next := c.downSizer.Failure(chunk) @@ -808,9 +774,9 @@ func (c *chunkConn) Write(p []byte) (int, error) { func (c *chunkConn) Close() error { c.closeOnce.Do(func() { - lane := newRequestLane(c.uploadLane.serverAddr, c.opts.tcpBuffer, 1, c.opts.txnTimeout) - _, _, _ = lane.single(wire.ModeClose, c.sid, 0, nil) - lane.Close() + // Reuse the upload lane rather than dialling a connection just to say + // goodbye; that was a second wasted dial per flow. + _, _, _ = c.uploadLane.single(wire.ModeClose, c.sid, 0, nil) c.uploadLane.Close() c.downloadLane.Close() }) diff --git a/core/cmd/dragontcp-client/chunk_test.go b/core/cmd/dragontcp-client/chunk_test.go index af1470c..16f0a24 100644 --- a/core/cmd/dragontcp-client/chunk_test.go +++ b/core/cmd/dragontcp-client/chunk_test.go @@ -23,76 +23,6 @@ func TestAdaptiveSizerRecoversFromMinimum(t *testing.T) { } } -func newTestConn(min, max int) *chunkConn { - return &chunkConn{pipeline: max, minPipeline: min, maxPipeline: max} -} - -func TestPinnedBatchNeverAdapts(t *testing.T) { - c := newTestConn(5, 5) - if got := c.batchCount(1400); got != 5 { - t.Fatalf("pinned batch should request 5 records, got %d", got) - } - for i := 0; i < 10; i++ { - if _, _, shrank := c.shrinkPipeline(); shrank { - t.Fatal("pinned batch shrank on transport failure") - } - c.growPipeline() - } - if c.pipeline != 5 { - t.Fatalf("pinned batch drifted to %d", c.pipeline) - } - if got := c.batchCount(1400); got != 5 { - t.Fatalf("pinned batch should still request 5 records, got %d", got) - } -} - -func TestPinnedBatchSurvivesOneMiBCap(t *testing.T) { - // 8 x 1 MiB records exceed the ~1 MiB useful-data cap. A pinned depth must - // win anyway, otherwise a path that needs exactly 8 records is broken by - // an unrelated size heuristic. - c := newTestConn(8, 8) - if got := c.batchCount(1024 * 1024); got != 8 { - t.Fatalf("pinned batch should ignore the 1 MiB cap, got %d", got) - } - // An unpinned batch is still capped. - c = newTestConn(1, 8) - if got := c.batchCount(1024 * 1024); got != 1 { - t.Fatalf("unpinned batch should be capped to 1, got %d", got) - } -} - -func TestAdaptiveBatchStopsAtFloor(t *testing.T) { - c := newTestConn(4, 32) - seen := map[int]bool{} - for i := 0; i < 12; i++ { - _, next, _ := c.shrinkPipeline() - seen[next] = true - } - if c.pipeline != 4 { - t.Fatalf("batch fell to %d, want the floor 4", c.pipeline) - } - if !seen[16] || !seen[8] { - t.Fatalf("expected halving through 16 and 8, saw %v", seen) - } - for i := 0; i < 100; i++ { - c.growPipeline() - } - if c.pipeline != 32 { - t.Fatalf("batch grew to %d, want the ceiling 32", c.pipeline) - } -} - -func TestSingleBatchIsFixed(t *testing.T) { - c := newTestConn(1, 1) - if !c.pinnedBatch() { - t.Fatal("a 1..1 batch must be treated as pinned") - } - c.growPipeline() - if c.pipeline != 1 { - t.Fatalf("batch of 1 grew to %d", c.pipeline) - } -} - func TestReconnectZeroMeansPersistent(t *testing.T) { lane := newRequestLane("127.0.0.1:1", 0, 0, 0) if lane.reconnectEvery != 0 { diff --git a/core/cmd/dragontcp-client/main.go b/core/cmd/dragontcp-client/main.go index d612797..3b6215f 100644 --- a/core/cmd/dragontcp-client/main.go +++ b/core/cmd/dragontcp-client/main.go @@ -13,6 +13,7 @@ import ( "time" "dragontcp/internal/protocol" + "dragontcp/internal/xorchunk" ) const maxHeader = 128 * 1024 @@ -251,7 +252,7 @@ func writeHTTPError(conn net.Conn, code int, reason, detail string) { _, _ = conn.Write(body) } -func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer int, chunkOpts chunkClientOptions, slots chan struct{}) { +func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer int, wires *wireSelector, slots chan struct{}) { defer func() { <-slots _ = conn.Close() @@ -285,7 +286,7 @@ func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer i var remote net.Conn if transport == "chunk" { - remote, err = openChunkTunnel(serverAddr, token, host, port, chunkOpts) + remote, err = wires.dial(host, port) } else { remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer) } @@ -327,7 +328,7 @@ func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer i var remote net.Conn if transport == "chunk" { - remote, err = openChunkTunnel(serverAddr, token, host, port, chunkOpts) + remote, err = wires.dial(host, port) } else { remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer) } @@ -358,27 +359,28 @@ func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer i 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") - 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)") - tcpBuffer = flag.Int("tcp-buffer", 0, "optional TCP read/write buffer bytes; 0 keeps OS autotuning") - chunkStart = flag.Int("chunk-start", 1048576, "initial adaptive chunk payload bytes") - chunkMin = flag.Int("chunk-min", 32, "minimum adaptive chunk payload bytes") - 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") - 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") - chunkConcurrency = flag.Int("chunk-concurrency", 1, "maximum download records per request (1-256)") - chunkConcurrencyMin = flag.Int("chunk-concurrency-min", 1, "minimum download records per request (1-256); equal to --chunk-concurrency pins the depth and disables batch adaptation") - chunkReconnect = flag.Int("chunk-reconnect-every", 0, "force reconnect after N logical requests; 0 = persistent/automatic") - chunkPollDelay = flag.Duration("chunk-poll-delay", 2*time.Millisecond, "delay after an empty chunk poll") - chunkTimeout = flag.Duration("chunk-timeout", 2*time.Second, "per-record transaction timeout before adaptive shrink") + 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") + 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)") + tcpBuffer = flag.Int("tcp-buffer", 0, "optional TCP read/write buffer bytes; 0 keeps OS autotuning") + chunkStart = flag.Int("chunk-start", 1048576, "initial adaptive chunk payload bytes") + chunkMin = flag.Int("chunk-min", 32, "minimum adaptive chunk payload bytes") + 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") + 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") + chunkConcurrency = flag.Int("chunk-concurrency", 1, "maximum download records per request (1-256)") + 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, "force reconnect after N logical requests; 0 = persistent/automatic") + chunkPollDelay = flag.Duration("chunk-poll-delay", 2*time.Millisecond, "delay after an empty chunk poll") + chunkTimeout = flag.Duration("chunk-timeout", 2*time.Second, "per-record transaction timeout before adaptive shrink") + wireMode = flag.String("wire", "auto", "wire mode: b, x, or auto (probe and pick)") ) flag.Parse() @@ -426,6 +428,17 @@ func main() { fmt.Fprintln(os.Stderr, "--chunk-concurrency-min must not exceed --chunk-concurrency") os.Exit(2) } + *wireMode = strings.ToLower(strings.TrimSpace(*wireMode)) + switch *wireMode { + case WireBinary, WireXOR, WireAuto: + case "binary": + *wireMode = WireBinary + case "xor": + *wireMode = WireXOR + default: + fmt.Fprintln(os.Stderr, "--wire must be b, x or auto") + os.Exit(2) + } if *chunkReconnect < 0 { fmt.Fprintln(os.Stderr, "--chunk-reconnect-every must be 0 or greater") os.Exit(2) @@ -438,14 +451,19 @@ func main() { adaptSuccesses: *chunkSuccesses, adaptLog: *chunkAdaptLog, pollers: *chunkPollers, + minPipeline: *chunkConcurrencyMin, + maxPipeline: *chunkConcurrency, reconnectEvery: *chunkReconnect, pollDelay: *chunkPollDelay, txnTimeout: *chunkTimeout, tcpBuffer: *tcpBuffer, - minPipeline: *chunkConcurrencyMin, - maxPipeline: *chunkConcurrency, } + xorOpts := xorchunk.NewOptions( + *chunkStart, *chunkMin, *chunkMax, *chunkAdaptive, *chunkSuccesses, *chunkAdaptLog, + *chunkPollers, *chunkReconnect, *chunkPollDelay, *chunkTimeout, *tcpBuffer, + ) + listenAddr := net.JoinHostPort(*listenHost, strconv.Itoa(*listenPort)) serverAddr := net.JoinHostPort(*serverHost, strconv.Itoa(*serverPort)) @@ -480,6 +498,16 @@ func main() { ) } + wires := newWireSelector(*wireMode, serverAddr, *token, chunkOpts, xorOpts) + if *wireMode == WireAuto { + fmt.Printf("wire=auto probing %s\n", probeHost) + // Resolve in the background so startup is not blocked; a connection that + // arrives first simply waits for the same result. + go wires.mode() + } else { + fmt.Printf("wire=%s (manual)\n", *wireMode) + } + slots := make(chan struct{}, *maxConnections) for { @@ -491,7 +519,7 @@ func main() { select { case slots <- struct{}{}: - go handleLocal(conn, serverAddr, *token, *transport, *tcpBuffer, chunkOpts, slots) + go handleLocal(conn, serverAddr, *token, *transport, *tcpBuffer, wires, slots) default: writeHTTPError( conn, diff --git a/core/cmd/dragontcp-client/wireselect.go b/core/cmd/dragontcp-client/wireselect.go new file mode 100644 index 0000000..d45ca5f --- /dev/null +++ b/core/cmd/dragontcp-client/wireselect.go @@ -0,0 +1,142 @@ +package main + +import ( + "fmt" + "net" + "strings" + "sync" + "time" + + "dragontcp/internal/xorchunk" +) + +// DragonTCP speaks two wires that are not interchangeable: +// +// b — compact binary records (29/5-byte headers, SHA-256 keystream mask) +// x — legacy UP/OK framing with XOR 0xAD over ASCII chunk commands +// +// Networks differ in which they pass, so the client can be pinned to either or +// left on auto, which decides by actually fetching a URL through each wire and +// keeping the first that answers. +const ( + WireBinary = "b" + WireXOR = "x" + WireAuto = "auto" +) + +// probeTarget is fetched through a candidate wire to decide whether it works. +// A plain HTTP host is used deliberately: it exercises OPEN, upload and +// download in one go, and a valid status line proves bytes survived intact. +const ( + probeHost = "ip.dr2.site" + probePort = 80 + probeTimeout = 8 * time.Second +) + +type wireSelector struct { + mu sync.Mutex + configured string // b, x or auto + resolved string // b or x once decided + serverAddr string + token string + binOpts chunkClientOptions + xorOpts xorchunk.Options +} + +func newWireSelector(configured, serverAddr, token string, binOpts chunkClientOptions, xorOpts xorchunk.Options) *wireSelector { + s := &wireSelector{ + configured: configured, + serverAddr: serverAddr, + token: token, + binOpts: binOpts, + xorOpts: xorOpts, + } + if configured != WireAuto { + s.resolved = configured + } + return s +} + +// dial opens a tunnel over the active wire, resolving the wire first if needed. +func (s *wireSelector) dial(host string, port int) (net.Conn, error) { + mode := s.mode() + if mode == WireXOR { + return xorchunk.Open(s.serverAddr, s.token, host, port, s.xorOpts) + } + return openChunkTunnel(s.serverAddr, s.token, host, port, s.binOpts) +} + +// mode returns the wire to use, running detection once if configured as auto. +// Detection failure is not cached, so a client that starts before the network +// is usable retries on the next connection instead of latching a bad guess. +func (s *wireSelector) mode() string { + s.mu.Lock() + defer s.mu.Unlock() + if s.resolved != "" { + return s.resolved + } + if picked, ok := s.detectLocked(); ok { + s.resolved = picked + return picked + } + // Undecided: use the binary wire for this attempt without caching it. + return WireBinary +} + +func (s *wireSelector) detectLocked() (string, bool) { + for _, candidate := range []string{WireBinary, WireXOR} { + if s.probe(candidate) { + fmt.Printf("wire probe: %s selected via %s\n", candidate, probeHost) + return candidate, true + } + fmt.Printf("wire probe: %s failed\n", candidate) + } + fmt.Printf("wire probe: neither wire reached %s; retrying later\n", probeHost) + return "", false +} + +// probe fetches probeHost through one wire and reports whether a well-formed +// HTTP status line came back. +func (s *wireSelector) probe(mode string) bool { + type result struct{ ok bool } + done := make(chan result, 1) + + go func() { + var ( + conn net.Conn + err error + ) + if mode == WireXOR { + conn, err = xorchunk.Open(s.serverAddr, s.token, probeHost, probePort, s.xorOpts) + } else { + conn, err = openChunkTunnel(s.serverAddr, s.token, probeHost, probePort, s.binOpts) + } + if err != nil { + done <- result{false} + return + } + defer conn.Close() + + 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 { + done <- result{false} + return + } + buf := make([]byte, 64) + n, err := conn.Read(buf) + if n <= 0 || (err != nil && n == 0) { + done <- result{false} + return + } + done <- result{strings.HasPrefix(string(buf[:n]), "HTTP/")} + }() + + select { + case r := <-done: + return r.ok + case <-time.After(probeTimeout): + // The tunnel goroutine is left to unwind on its own; the wire simply + // did not answer in time, which is all the caller needs to know. + return false + } +} diff --git a/core/cmd/dragontcp-server/main.go b/core/cmd/dragontcp-server/main.go index dd33ddd..ee2a083 100644 --- a/core/cmd/dragontcp-server/main.go +++ b/core/cmd/dragontcp-server/main.go @@ -160,8 +160,10 @@ func handle( tcpBuffer int, slots chan struct{}, manager *streamManager, + xorManager *chunkManager, chunkMax int, bufferBytes int, + chunkBuffered int, chunkPollWait time.Duration, debug *serverDebug, ) { @@ -174,6 +176,26 @@ func handle( protocol.TuneTCP(conn) protocol.TuneTCPBuffer(conn, tcpBuffer) + // One listener serves both wires. The legacy XOR framing starts every + // request with the ASCII magic "UP"; the binary framing starts with a mode + // byte of 0-4, so the two are never ambiguous. + _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) + conn, isXOR, err := sniffWire(conn) + if err != nil { + return + } + if isXOR { + if debug != nil && debug.enabled { + debug.logf("WIRE peer=%v mode=xor", conn.RemoteAddr()) + } + handleXOR(conn, token, allowPrivate, cache, tcpBuffer, xorManager, + chunkMax, chunkBuffered, chunkPollWait, debug) + return + } + if debug != nil && debug.enabled { + debug.logf("WIRE peer=%v mode=binary", conn.RemoteAddr()) + } + for { _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) req, err := wire.ReadRequest(conn) @@ -249,6 +271,7 @@ func main() { bufferBytes = 64 * 1024 * 1024 } manager := newStreamManager(*sessionTimeout, debug) + xorManager := newChunkManager(*sessionTimeout, debug) fmt.Printf("binary_transport=true chunk_max=%d buffer_bytes=%d poll_wait=%s\n", *chunkMax, bufferBytes, chunkPollWait.String()) if debug.enabled { fmt.Printf("debug=true debug_chunks=%t stats_interval=%s\n", debug.chunks, debug.statsEvery) @@ -275,8 +298,10 @@ func main() { *tcpBuffer, slots, manager, + xorManager, *chunkMax, bufferBytes, + *chunkBuffered, *chunkPollWait, debug, ) diff --git a/core/cmd/dragontcp-server/xorchunk.go b/core/cmd/dragontcp-server/xorchunk.go new file mode 100644 index 0000000..63a45cb --- /dev/null +++ b/core/cmd/dragontcp-server/xorchunk.go @@ -0,0 +1,632 @@ +package main + +// This file is the LiteVPN v4 XOR chunk server, carried over verbatim. It +// handles the legacy UP/OK + XOR 0xAD wire (COPEN / CPUSH / CPULL / CCLOSE and +// the TUNNEL stream commands) so one server accepts both wire formats. The +// binary record handler lives in chunk.go; nothing here is shared with it +// except the target dialler, DNS cache, token check and debug counters. + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + "dragontcp/internal/protocol" +) + +type chunkSession struct { + id string + target net.Conn + maxChunk int + maxChunks int + + mu sync.Mutex + notify chan struct{} + chunks map[uint64][]byte + nextDown uint64 + eof bool + closed bool + lastSeen time.Time + debug *serverDebug + + upMu sync.Mutex + expectedUp uint64 + lastUpSeq uint64 + lastUpLen int + haveLastUp bool +} + +func newChunkSession(id string, target net.Conn, maxChunk, maxChunks int, debug *serverDebug) *chunkSession { + s := &chunkSession{ + id: id, + target: target, + maxChunk: maxChunk, + maxChunks: maxChunks, + notify: make(chan struct{}), + chunks: make(map[uint64][]byte, maxChunks), + lastSeen: time.Now(), + debug: debug, + } + go s.readTarget() + return s +} + +func (s *chunkSession) signalLocked() { + close(s.notify) + s.notify = make(chan struct{}) +} + +func (s *chunkSession) touchLocked() { + s.lastSeen = time.Now() +} + +func (s *chunkSession) touch() { + s.mu.Lock() + s.touchLocked() + s.mu.Unlock() +} + +func (s *chunkSession) readTarget() { + buf := make([]byte, s.maxChunk) + + for { + n, err := s.target.Read(buf) + if n > 0 { + data := append([]byte(nil), buf[:n]...) + if s.debug != nil && s.debug.enabled { + s.debug.bytesDown.Add(uint64(n)) + } + + for { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + if len(s.chunks) < s.maxChunks { + seq := s.nextDown + s.nextDown++ + s.chunks[seq] = data + s.touchLocked() + s.signalLocked() + s.mu.Unlock() + break + } + ch := s.notify + s.mu.Unlock() + <-ch + } + } + + if err != nil { + if s.debug != nil && s.debug.enabled { + s.debug.logf("TARGET EOF session=%s err=%v", s.id, err) + } + s.mu.Lock() + if !s.closed { + s.eof = true + s.touchLocked() + s.signalLocked() + } + s.mu.Unlock() + return + } + } +} + +// push is idempotent for the most recently accepted sequence. This matters +// when the server receives a record but the tiny ACK is lost: the client can +// retry the same sequence at a smaller adaptive size without duplicating bytes +// in the target stream. The ACK reports the length that was actually accepted. +func (s *chunkSession) push(seq uint64, data []byte) (int, error) { + s.upMu.Lock() + defer s.upMu.Unlock() + + if len(data) == 0 || len(data) > s.maxChunk { + return 0, fmt.Errorf("upload record size %d is invalid", len(data)) + } + + if s.haveLastUp && seq == s.lastUpSeq { + s.touch() + return s.lastUpLen, nil + } + + if seq < s.expectedUp { + return 0, fmt.Errorf("upload sequence %d is too old", seq) + } + if seq > s.expectedUp { + return 0, fmt.Errorf("unexpected upload sequence %d, expected %d", seq, s.expectedUp) + } + + if _, err := s.target.Write(data); err != nil { + return 0, err + } + + if s.debug != nil && s.debug.enabled { + s.debug.bytesUp.Add(uint64(len(data))) + s.debug.pushRecords.Add(1) + } + + s.lastUpSeq = seq + s.lastUpLen = len(data) + s.haveLastUp = true + s.expectedUp++ + s.touch() + return len(data), nil +} + +// pull returns at most limit bytes from the requested stored chunk, beginning +// at offset. The chunk sequence stays stable while the client retries smaller +// fragments, so a large queued chunk can always be recovered after an MTU-like +// failure without reopening the proxied destination connection. +func (s *chunkSession) pull(want uint64, ack int64, offset, limit int, wait time.Duration) (data []byte, total int, eof bool, final uint64, waitExpired bool, err error) { + if offset < 0 || limit <= 0 || limit > s.maxChunk { + return nil, 0, false, 0, false, fmt.Errorf("invalid pull offset/limit") + } + + timer := time.NewTimer(wait) + defer timer.Stop() + + for { + s.mu.Lock() + s.touchLocked() + + if ack >= 0 { + removed := false + for seq := range s.chunks { + if seq <= uint64(ack) { + delete(s.chunks, seq) + removed = true + } + } + if removed { + s.signalLocked() + } + } + + if chunk, ok := s.chunks[want]; ok { + if offset >= len(chunk) { + s.mu.Unlock() + return nil, len(chunk), false, 0, false, fmt.Errorf("pull offset %d beyond chunk size %d", offset, len(chunk)) + } + end := offset + limit + if end > len(chunk) { + end = len(chunk) + } + out := append([]byte(nil), chunk[offset:end]...) + total = len(chunk) + s.mu.Unlock() + return out, total, false, 0, false, nil + } + + if s.eof && want >= s.nextDown { + final = s.nextDown + s.mu.Unlock() + return nil, 0, true, final, false, nil + } + + if s.closed { + final = s.nextDown + s.mu.Unlock() + return nil, 0, true, final, false, nil + } + + ch := s.notify + s.mu.Unlock() + + select { + case <-ch: + continue + case <-timer.C: + return nil, 0, false, 0, true, nil + } + } +} + +func (s *chunkSession) close() { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + s.signalLocked() + s.mu.Unlock() + _ = s.target.Close() +} + +type chunkManager struct { + mu sync.RWMutex + sessions map[string]*chunkSession + timeout time.Duration + debug *serverDebug +} + +func newChunkManager(timeout time.Duration, debug *serverDebug) *chunkManager { + m := &chunkManager{ + sessions: make(map[string]*chunkSession), + timeout: timeout, + debug: debug, + } + go m.cleanupLoop() + return m +} + +func (m *chunkManager) get(id string) *chunkSession { + m.mu.RLock() + s := m.sessions[id] + m.mu.RUnlock() + return s +} + +func (m *chunkManager) count() int { + m.mu.RLock() + n := len(m.sessions) + m.mu.RUnlock() + return n +} + +func (m *chunkManager) add(id string, s *chunkSession) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, exists := m.sessions[id]; exists { + return fmt.Errorf("session already exists") + } + m.sessions[id] = s + return nil +} + +func (m *chunkManager) remove(id string) { + m.mu.Lock() + s := m.sessions[id] + delete(m.sessions, id) + m.mu.Unlock() + if s != nil { + s.close() + } +} + +func (m *chunkManager) cleanupLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for range ticker.C { + cutoff := time.Now().Add(-m.timeout) + var stale []string + + m.mu.RLock() + for id, s := range m.sessions { + s.mu.Lock() + last := s.lastSeen + closed := s.closed + s.mu.Unlock() + if closed || last.Before(cutoff) { + stale = append(stale, id) + } + } + m.mu.RUnlock() + + for _, id := range stale { + if m.debug != nil && m.debug.enabled { + m.debug.logf("SESSION timeout-close id=%s active_sessions=%d", id, m.count()) + } + m.remove(id) + if m.debug != nil && m.debug.enabled { + m.debug.sessionsClosed.Add(1) + m.debug.activeSessions.Add(-1) + } + } + } +} + +func decodeWireToken(token string) string { + if token == "-" { + return "" + } + return token +} + +func isChunkCommand(payload []byte) bool { + return bytes.HasPrefix(payload, []byte("COPEN ")) || + bytes.HasPrefix(payload, []byte("CPUSH ")) || + bytes.HasPrefix(payload, []byte("CPULL ")) || + bytes.HasPrefix(payload, []byte("CCLOSE ")) +} + +func processChunkCommand( + conn net.Conn, + requestID uint32, + payload []byte, + token string, + allowPrivate bool, + cache *dnsCache, + tcpBuffer int, + manager *chunkManager, + maxChunk int, + maxBufferedChunks int, + pollWait time.Duration, + debug *serverDebug, +) error { + if bytes.HasPrefix(payload, []byte("COPEN ")) { + parts := strings.Fields(string(payload)) + if len(parts) != 5 { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad COPEN")) + } + if !tokenEqual(decodeWireToken(parts[1]), token) { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed")) + } + sid := parts[2] + if len(sid) < 16 || len(sid) > 64 { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid session id")) + } + host := parts[3] + port, err := strconv.Atoi(parts[4]) + if err != nil || port < 1 || port > 65535 { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid port")) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + target, err := dialTarget(ctx, host, port, allowPrivate, cache, tcpBuffer) + cancel() + if err != nil { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error())) + } + + session := newChunkSession(sid, target, maxChunk, maxBufferedChunks, debug) + if err := manager.add(sid, session); err != nil { + session.close() + if debug != nil && debug.enabled { + debug.errorf("COPEN session=%s target=%s:%d failed: %v", sid, host, port, err) + } + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error())) + } + if debug != nil && debug.enabled { + debug.sessionsOpened.Add(1) + debug.activeSessions.Add(1) + debug.logf("SESSION OPEN id=%s peer=%v target=%s:%d max_chunk=%d active_sessions=%d", sid, conn.RemoteAddr(), host, port, maxChunk, manager.count()) + debug.chunkf("COPEN id=%s target=%s:%d -> OPENED max=%d", sid, host, port, maxChunk) + } + return protocol.WriteResponseFrame(conn, requestID, []byte(fmt.Sprintf("OPENED %d", maxChunk))) + } + + if bytes.HasPrefix(payload, []byte("CPUSH ")) { + parts := bytes.SplitN(payload, []byte(" "), 5) + if len(parts) != 5 { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CPUSH")) + } + if !tokenEqual(decodeWireToken(string(parts[1])), token) { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed")) + } + sid := string(parts[2]) + seq, err := strconv.ParseUint(string(parts[3]), 10, 64) + if err != nil { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid sequence")) + } + s := manager.get(sid) + if s == nil { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR unknown session")) + } + accepted, err := s.push(seq, parts[4]) + if err != nil { + if debug != nil && debug.enabled { + debug.errorf("CPUSH id=%s seq=%d bytes=%d: %v", sid, seq, len(parts[4]), err) + } + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error())) + } + if debug != nil { + debug.chunkf("CPUSH id=%s seq=%d bytes=%d -> ACK accepted=%d", sid, seq, len(parts[4]), accepted) + } + return protocol.WriteResponseFrame(conn, requestID, []byte(fmt.Sprintf("ACK %d %d", seq, accepted))) + } + + if bytes.HasPrefix(payload, []byte("CPULL ")) { + parts := strings.Fields(string(payload)) + if len(parts) != 7 { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CPULL")) + } + if !tokenEqual(decodeWireToken(parts[1]), token) { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed")) + } + s := manager.get(parts[2]) + if s == nil { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR unknown session")) + } + ack, err := strconv.ParseInt(parts[3], 10, 64) + if err != nil || ack < -1 { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid ack")) + } + want, err := strconv.ParseUint(parts[4], 10, 64) + if err != nil { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid want")) + } + offset, err := strconv.Atoi(parts[5]) + if err != nil || offset < 0 { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid offset")) + } + limit, err := strconv.Atoi(parts[6]) + if err != nil || limit < 1 { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid limit")) + } + if limit > maxChunk { + limit = maxChunk + } + if debug != nil && debug.enabled { + debug.pullRequests.Add(1) + debug.chunkf("CPULL id=%s ack=%d want=%d offset=%d limit=%d", parts[2], ack, want, offset, limit) + } + + data, total, eof, final, waitExpired, err := s.pull(want, ack, offset, limit, pollWait) + if err != nil { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error())) + } + if waitExpired { + if debug != nil && debug.enabled { + debug.waitRecords.Add(1) + debug.chunkf("CPULL id=%s want=%d -> WAIT", parts[2], want) + } + return protocol.WriteResponseFrame(conn, requestID, []byte("WAIT")) + } + if eof { + if debug != nil { + debug.chunkf("CPULL id=%s want=%d -> EOF final=%d", parts[2], want, final) + } + return protocol.WriteResponseFrame(conn, requestID, []byte(fmt.Sprintf("EOF %d", final))) + } + + if debug != nil && debug.enabled { + debug.dataRecords.Add(1) + debug.chunkf("DATA id=%s seq=%d offset=%d bytes=%d total=%d", parts[2], want, offset, len(data), total) + } + prefix := []byte(fmt.Sprintf("DATA %d %d %d ", want, offset, total)) + out := make([]byte, len(prefix)+len(data)) + copy(out, prefix) + copy(out[len(prefix):], data) + return protocol.WriteResponseFrame(conn, requestID, out) + } + + if bytes.HasPrefix(payload, []byte("CCLOSE ")) { + parts := strings.Fields(string(payload)) + if len(parts) != 3 { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR bad CCLOSE")) + } + if !tokenEqual(decodeWireToken(parts[1]), token) { + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed")) + } + manager.remove(parts[2]) + if debug != nil && debug.enabled { + debug.sessionsClosed.Add(1) + debug.activeSessions.Add(-1) + debug.logf("SESSION CLOSE id=%s peer=%v active_sessions=%d", parts[2], conn.RemoteAddr(), manager.count()) + debug.chunkf("CCLOSE id=%s -> CLOSED", parts[2]) + } + return protocol.WriteResponseFrame(conn, requestID, []byte("CLOSED")) + } + + return protocol.WriteResponseFrame(conn, requestID, []byte("ERR unknown chunk command")) +} + +// --------------------------------------------------------------------------- +// Wire detection and the XOR connection loop. +// --------------------------------------------------------------------------- + +// prefixedConn replays bytes already consumed for wire detection before falling +// through to the socket. Using a plain bufio.Reader would be wrong here: the +// TUNNEL path relays the raw connection, so anything the detector buffered +// beyond the magic would be lost. +type prefixedConn struct { + net.Conn + r io.Reader +} + +func (p *prefixedConn) Read(b []byte) (int, error) { return p.r.Read(b) } + +// sniffWire reads the two magic bytes and reports whether this connection +// speaks the legacy XOR framing. The returned conn replays them. +func sniffWire(conn net.Conn) (net.Conn, bool, error) { + var magic [2]byte + if _, err := io.ReadFull(conn, magic[:]); err != nil { + return conn, false, err + } + replayed := &prefixedConn{Conn: conn, r: io.MultiReader(bytes.NewReader(magic[:]), conn)} + return replayed, magic[0] == 'U' && magic[1] == 'P', nil +} + +// handleXOR serves one connection speaking UP/OK + XOR 0xAD: the v4 chunk +// commands, plus the TUNNEL/TUNNEL2 stream commands. +func handleXOR( + conn net.Conn, + token string, + allowPrivate bool, + cache *dnsCache, + tcpBuffer int, + manager *chunkManager, + chunkMax int, + chunkBuffered int, + chunkPollWait time.Duration, + debug *serverDebug, +) { + for { + _ = conn.SetDeadline(time.Now().Add(20 * time.Second)) + + requestID, _, payload, err := protocol.ReadRequestFrame(conn) + if err != nil { + if debug != nil && debug.enabled && err != io.EOF { + debug.errorf("peer=%v read XOR request: %v", conn.RemoteAddr(), err) + } + return + } + + if isChunkCommand(payload) { + if err := processChunkCommand( + conn, requestID, payload, token, allowPrivate, cache, tcpBuffer, + manager, chunkMax, chunkBuffered, chunkPollWait, debug, + ); err != nil { + return + } + continue + } + + parts := strings.Fields(string(payload)) + transport := "xor" + if len(parts) == 4 && parts[0] == "TUNNEL" { + transport = "xor" + } else if len(parts) == 5 && parts[0] == "TUNNEL2" { + transport = strings.ToLower(parts[4]) + if transport != "raw" && transport != "xor" { + _ = protocol.WriteResponseFrame(conn, requestID, []byte("ERR transport must be RAW or XOR")) + return + } + } else { + _ = protocol.WriteResponseFrame(conn, requestID, []byte("ERR expected TUNNEL, TUNNEL2, or chunk command")) + return + } + + if !tokenEqual(parts[1], token) { + _ = protocol.WriteResponseFrame(conn, requestID, []byte("ERR authentication failed")) + return + } + + port, err := strconv.Atoi(parts[3]) + if err != nil || port < 1 || port > 65535 { + _ = protocol.WriteResponseFrame(conn, requestID, []byte("ERR invalid port")) + return + } + + if debug != nil && debug.enabled { + debug.logf("TUNNEL peer=%v target=%s:%d transport=%s", conn.RemoteAddr(), parts[2], port, transport) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + target, err := dialTarget(ctx, parts[2], port, allowPrivate, cache, tcpBuffer) + cancel() + if err != nil { + if debug != nil && debug.enabled { + debug.errorf("TUNNEL target=%s:%d connect failed: %v", parts[2], port, err) + } + _ = protocol.WriteResponseFrame(conn, requestID, []byte("ERR "+err.Error())) + return + } + defer target.Close() + + if err := protocol.WriteResponseFrame(conn, requestID, []byte("CONNECTED")); err != nil { + return + } + + _ = conn.SetDeadline(time.Time{}) + if transport == "raw" { + protocol.RelayRaw(conn, target) + } else { + protocol.RelayXOR(conn, target) + } + if debug != nil && debug.enabled { + debug.logf("TUNNEL closed peer=%v target=%s:%d transport=%s", conn.RemoteAddr(), parts[2], port, transport) + } + return + } +} diff --git a/core/dragontcp-server.exe b/core/dragontcp-server.exe new file mode 100644 index 0000000..e4a1e0d Binary files /dev/null and b/core/dragontcp-server.exe differ diff --git a/core/internal/xorchunk/chunk.go b/core/internal/xorchunk/chunk.go new file mode 100644 index 0000000..52b038c --- /dev/null +++ b/core/internal/xorchunk/chunk.go @@ -0,0 +1,790 @@ +// 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) }