Mult Protocol

This commit is contained in:
2026-08-16 15:22:03 -03:00
parent 96ea761b72
commit 1fb431ccba
17 changed files with 1873 additions and 204 deletions
+51 -15
View File
@@ -386,25 +386,25 @@ front end never knows it is talking to a record protocol. It holds:
* `downloadOffset` — next byte to request, * `downloadOffset` — next byte to request,
* `consumedOffset` — next unread byte, sent as `ack`, * `consumedOffset` — next unread byte, sent as `ack`,
* `readBuf` — received but not yet delivered to the reader, * `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. blocking download poll never delays an upload.
`Write` slices the caller's buffer into records of the current upload size, each `Write` slices the caller's buffer into records of the current upload size, each
acknowledged before the next is sent. `Read` refills `readBuf` via acknowledged before the next is sent. `Read` refills `readBuf` via
`fillReadBuffer`, which issues batched download requests. `Close` sends `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 ### 5.3 Request lanes and connection reuse
A `requestLane` owns at most one physical TCP connection and serialises requests 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 onto it under a mutex. Each tunnel has two of them (§5.7). Any I/O error discards
redials. Sockets get `TCP_NODELAY`, 30-second keepalives, and optionally explicit the connection; the next request redials. Sockets get `TCP_NODELAY`, 30-second
buffer sizes via `--tcp-buffer`. keepalives, and optionally explicit buffer sizes via `--tcp-buffer`.
| `--chunk-reconnect-every` | Behaviour | | `--chunk-reconnect-every` | Behaviour |
|---|---| |---|---|
| `0` | Persistent — one connection for the life of the lane | | `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 | | `N ≥ 2` | Rotate — close and redial after N logical requests |
### 5.4 Path probing ### 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 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. `--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 1128, 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: 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` | | Min chunk | 32 | `--chunk-min` |
| Batch max | 1 | `--chunk-concurrency` | | Batch max | 1 | `--chunk-concurrency` |
| Batch min | 1 | `--chunk-concurrency-min` | | Batch min | 1 | `--chunk-concurrency-min` |
| Reconnect every | 0 | `--chunk-reconnect-every` | | Reconnect every | 1 (auto) | `--chunk-reconnect-every` |
| Timeout (s) | 2 | `--chunk-timeout` | | Timeout (s) | 2 | `--chunk-timeout` |
Fixed by the service: `--listen-host 127.0.0.1`, `--listen-port 8080`, Fixed by the service: `--listen-host 127.0.0.1`, `--listen-port 8080`,
`--transport chunk`, `--chunk-pollers 1`, `--chunk-grow-after 16`, `--transport chunk`, `--chunk-grow-after 16`, `--chunk-adapt-log=true`.
`--chunk-adapt-log=true`.
Settings are laid out in four cards: CONNECTION, RECORD SIZE, DOWNLOAD BATCH, and Settings are laid out in four cards: CONNECTION, RECORD SIZE, DOWNLOAD BATCH,
ADVANCED. The batch card carries a live hint that restates the current setting in 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: words as you type, so the mode is never ambiguous:
```text ```text
@@ -827,7 +852,8 @@ Download batch: pinned at 5 records per request (never adapts)
``` ```
Validation ranges: port 165535, max chunk 321048576, min chunk 32max chunk, Validation ranges: port 165535, max chunk 321048576, min chunk 32max chunk,
batch values 1256 with `min ≤ max`, reconnect 01000000, timeout 1120. batch values 1256 with `min ≤ max`, reconnect 01000000,
timeout 1120.
### 7.10 Logs ### 7.10 Logs
@@ -1013,7 +1039,8 @@ Max chunk: 1048576
Min chunk: 32 Min chunk: 32
Batch max: 1 Batch max: 1
Batch min: 1 Batch min: 1
Reconnect every: 0 Pollers: 1
Reconnect every: 1
Timeout (s): 2 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-reconnect-every` | `0` | 0 persistent, 1 auto, N rotate |
| `--chunk-poll-delay` | `2ms` | Pause after an empty poll | | `--chunk-poll-delay` | `2ms` | Pause after an empty poll |
| `--chunk-timeout` | `2s` | Per-record transaction timeout | | `--chunk-timeout` | `2s` | Per-record transaction timeout |
| `--chunk-pollers` | `1` | Reserved compatibility knob; accepted but unused | | `--chunk-pollers` | `1` | Accepted for compatibility; validated 1128 but unused |
The `concurrency` flag names are historical. They control the download **batch The `concurrency` flag names are historical. They control the download **batch
depth** described in §5.6, not any form of threading. 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 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 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. `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 * **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 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 = N` for a few values of N and leave it pinned at whichever works. Pinned mode
+4 -4
View File
@@ -1,4 +1,4 @@
4e4a16f6c537f2c6be5c104f6e50ea907122092a21402a70506fe12e6f144bb7 bin/dragontcp-hybrid-server-linux-amd64 56707362bae6b388795150a77b27a14de046a56e05037485d5dfb4bb7db0f8b9 *bin/dragontcp-hybrid-server-linux-amd64
39a6bb16cbddefb50e791220747f5cd01b0c894992c657b24c6c2e5d1501299f bin/dragontcp-hybrid-server-linux-arm64 35e7dbbb84bbb76b0eea052eff18d58e24c1fa1c43274aa6c1a5c686a1d38f40 *bin/dragontcp-hybrid-server-linux-arm64
66ad3741d84b4e73098912725af762824bec629adab5522a5e161b4e23b79dee bin/dragontcp-hybrid-client-linux-amd64 2f820ce82a65c285684c875afd0311f1d62afe856f8b878eab682f9864252a19 *bin/dragontcp-hybrid-client-linux-amd64
fa6b41cc8c2999932cc78f731cb249cab677af9df7701c491fb688f9cbcb089d android/lib/arm64-v8a/libdragontcp_client.so 1b5bcf4d3a446cec397206557fa6f1a4c229b11b8e871a7a1072ac3ae9f81e31 *android/lib/arm64-v8a/libdragontcp_client.so
Binary file not shown.
+35 -3
View File
@@ -11,6 +11,8 @@ public final class AppLog {
private static final int MAX_LINES = 600; private static final int MAX_LINES = 600;
private static final ArrayDeque<String> lines = new ArrayDeque<>(); private static final ArrayDeque<String> lines = new ArrayDeque<>();
private static final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>(); private static final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
private static String lastLine;
private static int repeatCount;
private AppLog() {} private AppLog() {}
@@ -18,10 +20,36 @@ public final class AppLog {
if (line == null) return; if (line == null) return;
line = line.trim(); line = line.trim();
if (line.isEmpty()) return; 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) { synchronized (lines) {
while (lines.size() >= MAX_LINES) lines.removeFirst(); if (line.equals(lastLine)) {
lines.addLast(line); 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) { for (Listener listener : listeners) {
try { listener.onLine(line); } catch (Throwable ignored) {} try { listener.onLine(line); } catch (Throwable ignored) {}
} }
@@ -36,7 +64,11 @@ public final class AppLog {
} }
public static void clear() { 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); } public static void addListener(Listener listener) { listeners.addIfAbsent(listener); }
@@ -38,6 +38,8 @@ public class DragonService extends VpnService {
public static final String EXTRA_SERVER = "server"; public static final String EXTRA_SERVER = "server";
public static final String EXTRA_PORT = "port"; public static final String EXTRA_PORT = "port";
public static final String EXTRA_TOKEN = "token"; 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_MAX = "chunkMax";
public static final String EXTRA_CHUNK_MIN = "chunkMin"; public static final String EXTRA_CHUNK_MIN = "chunkMin";
/** Maximum download records per request. A transport setting, not a thread count. */ /** 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); String server = intent.getStringExtra(EXTRA_SERVER);
int port = intent.getIntExtra(EXTRA_PORT, 53); int port = intent.getIntExtra(EXTRA_PORT, 53);
String token = intent.getStringExtra(EXTRA_TOKEN); String token = intent.getStringExtra(EXTRA_TOKEN);
String wire = intent.getStringExtra(EXTRA_WIRE);
int chunkMax = intent.getIntExtra(EXTRA_CHUNK_MAX, 1024 * 1024); int chunkMax = intent.getIntExtra(EXTRA_CHUNK_MAX, 1024 * 1024);
int chunkMin = intent.getIntExtra(EXTRA_CHUNK_MIN, 32); int chunkMin = intent.getIntExtra(EXTRA_CHUNK_MIN, 32);
int batchMax = intent.getIntExtra(EXTRA_BATCH_MAX, 1); int batchMax = intent.getIntExtra(EXTRA_BATCH_MAX, 1);
int batchMin = intent.getIntExtra(EXTRA_BATCH_MIN, 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); int timeout = intent.getIntExtra(EXTRA_TIMEOUT, 2);
if (server == null || server.trim().isEmpty()) { if (server == null || server.trim().isEmpty()) {
@@ -123,6 +126,7 @@ public class DragonService extends VpnService {
} }
server = server.trim(); server = server.trim();
if (token == null) token = ""; if (token == null) token = "";
if (wire == null || !(wire.equals("b") || wire.equals("x"))) wire = "auto";
chunkMax = Math.max(32, Math.min(1024 * 1024, chunkMax)); chunkMax = Math.max(32, Math.min(1024 * 1024, chunkMax));
chunkMin = Math.max(32, Math.min(chunkMax, chunkMin)); chunkMin = Math.max(32, Math.min(chunkMax, chunkMin));
batchMax = Math.max(1, Math.min(BATCH_LIMIT, batchMax)); batchMax = Math.max(1, Math.min(BATCH_LIMIT, batchMax));
@@ -133,7 +137,8 @@ public class DragonService extends VpnService {
try { try {
AppLog.append("Starting DragonTCP → " + server + ":" + port); AppLog.append("Starting DragonTCP → " + server + ":" + port);
AppLog.append(describeBatch(batchMin, batchMax)); 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; } synchronized (stateLock) { coreProcess = process; }
startCoreLogReader(process); startCoreLogReader(process);
@@ -199,6 +204,7 @@ public class DragonService extends VpnService {
int chunkMin, int chunkMin,
int batchMax, int batchMax,
int batchMin, int batchMin,
String wire,
int reconnect, int reconnect,
int timeout int timeout
) throws Exception { ) throws Exception {
@@ -217,6 +223,7 @@ public class DragonService extends VpnService {
cmd.add("--chunk-min"); cmd.add(Integer.toString(chunkMin)); cmd.add("--chunk-min"); cmd.add(Integer.toString(chunkMin));
cmd.add("--chunk-max"); cmd.add(Integer.toString(chunkMax)); cmd.add("--chunk-max"); cmd.add(Integer.toString(chunkMax));
cmd.add("--chunk-pollers"); cmd.add("1"); 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"); cmd.add(Integer.toString(batchMax));
cmd.add("--chunk-concurrency-min"); cmd.add(Integer.toString(batchMin)); cmd.add("--chunk-concurrency-min"); cmd.add(Integer.toString(batchMin));
cmd.add("--chunk-reconnect-every"); cmd.add(Integer.toString(reconnect)); 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"; 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) { private void startCoreLogReader(Process process) {
Thread reader = new Thread(() -> { Thread reader = new Thread(() -> {
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) { try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
@@ -246,7 +261,7 @@ public class DragonService extends VpnService {
while ((line = br.readLine()) != null) { while ((line = br.readLine()) != null) {
// Keep the UI useful: adaptation changes and real errors only. // Keep the UI useful: adaptation changes and real errors only.
String lower = line.toLowerCase(); 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); AppLog.append(line);
} }
} }
@@ -53,9 +53,14 @@ public class MainActivity extends Activity {
private EditText batchMax; private EditText batchMax;
private EditText batchMin; private EditText batchMin;
private EditText reconnect; private EditText reconnect;
private Button wireAuto;
private Button wireB;
private Button wireX;
private String wireMode = "auto";
private EditText timeout; private EditText timeout;
private TextView batchHint; private TextView batchHint;
private TextView wireHint;
private Button connectButton; private Button connectButton;
private Button stopButton; private Button stopButton;
private Button logsButton; private Button logsButton;
@@ -159,6 +164,27 @@ public class MainActivity extends Activity {
connectionCard.addView(connectionRow); connectionCard.addView(connectionRow);
settings.addView(connectionCard, cardParams()); 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 // --------------------------------------------------------- record size
LinearLayout sizeCard = card("RECORD SIZE (BYTES)"); LinearLayout sizeCard = card("RECORD SIZE (BYTES)");
LinearLayout chunks = row(); LinearLayout chunks = row();
@@ -200,10 +226,12 @@ public class MainActivity extends Activity {
// ------------------------------------------------------------ advanced // ------------------------------------------------------------ advanced
LinearLayout advancedCard = card("ADVANCED"); LinearLayout advancedCard = card("ADVANCED");
LinearLayout timing = row(); 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); timeout = addFieldToRow(timing, "Timeout (s)", "2", "2", true, false, 0.42f);
advancedCard.addView(timing); 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()); settings.addView(advancedCard, cardParams());
// ------------------------------------------------------------- buttons // ------------------------------------------------------------- buttons
@@ -284,6 +312,7 @@ public class MainActivity extends Activity {
+ " on errors, recovers to " + max + "."); + " on errors, recovers to " + max + ".");
} }
private Integer readInt(EditText field) { private Integer readInt(EditText field) {
if (field == null) return null; if (field == null) return null;
String raw = field.getText().toString().trim(); String raw = field.getText().toString().trim();
@@ -364,6 +393,47 @@ public class MainActivity extends Activity {
return edit; 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() { private LinearLayout row() {
LinearLayout row = new LinearLayout(this); LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL); 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_SERVER, p.getString("server", ""));
i.putExtra(DragonService.EXTRA_PORT, p.getInt("port", 53)); i.putExtra(DragonService.EXTRA_PORT, p.getInt("port", 53));
i.putExtra(DragonService.EXTRA_TOKEN, p.getString("token", "")); 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_MAX, p.getInt("max", 1048576));
i.putExtra(DragonService.EXTRA_CHUNK_MIN, p.getInt("min", 32)); 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_MAX, p.getInt("batchMax", 1));
i.putExtra(DragonService.EXTRA_BATCH_MIN, p.getInt("batchMin", 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)); i.putExtra(DragonService.EXTRA_TIMEOUT, p.getInt("timeout", 2));
if (Build.VERSION.SDK_INT >= 26) startForegroundService(i); else startService(i); if (Build.VERSION.SDK_INT >= 26) startForegroundService(i); else startService(i);
} }
@@ -471,6 +542,7 @@ public class MainActivity extends Activity {
.putString("server", h) .putString("server", h)
.putInt("port", p) .putInt("port", p)
.putString("token", token.getText().toString()) .putString("token", token.getText().toString())
.putString("wire", wireMode)
.putInt("max", max) .putInt("max", max)
.putInt("min", min) .putInt("min", min)
.putInt("batchMax", bMax) .putInt("batchMax", bMax)
@@ -493,11 +565,12 @@ public class MainActivity extends Activity {
server.setText(p.getString("server", "")); server.setText(p.getString("server", ""));
port.setText(Integer.toString(p.getInt("port", 53))); port.setText(Integer.toString(p.getInt("port", 53)));
token.setText(p.getString("token", "")); token.setText(p.getString("token", ""));
setWireMode(p.getString("wire", "auto"));
chunkMax.setText(Integer.toString(p.getInt("max", 1048576))); chunkMax.setText(Integer.toString(p.getInt("max", 1048576)));
chunkMin.setText(Integer.toString(p.getInt("min", 32))); chunkMin.setText(Integer.toString(p.getInt("min", 32)));
batchMax.setText(Integer.toString(p.getInt("batchMax", 1))); batchMax.setText(Integer.toString(p.getInt("batchMax", 1)));
batchMin.setText(Integer.toString(p.getInt("batchMin", 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))); timeout.setText(Integer.toString(p.getInt("timeout", 2)));
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
+44 -78
View File
@@ -571,41 +571,40 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
// 0 = persistent (CLI explicit) // 0 = persistent (CLI explicit)
// 1 = auto: persistent when the path probe succeeds, otherwise one request/connection // 1 = auto: persistent when the path probe succeeds, otherwise one request/connection
// N>=2 = force connection rotation after N logical requests // N>=2 = force connection rotation after N logical requests
if reconnect == 1 { // Resolved silently: this runs once per proxied flow, so it must never log.
if profile.persistent { if reconnect == 1 && profile.persistent {
reconnect = 0 reconnect = 0
fmt.Printf("path probe: reconnect mode auto -> persistent\n")
} else {
fmt.Printf("path probe: reconnect mode auto -> every request\n")
}
} }
sid, err := randomSessionID() sid, err := randomSessionID()
if err != nil { if err != nil {
return nil, err 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) payload, err := encodeOpen(token, targetHost, targetPort)
if err != nil { if err != nil {
control.Close() uploadLane.Close()
return nil, err 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 { if err != nil {
control.Close() uploadLane.Close()
return nil, err return nil, err
} }
if status == wire.StatusError { if status == wire.StatusError {
control.Close() uploadLane.Close()
return nil, fmt.Errorf("%s", string(body)) return nil, fmt.Errorf("%s", string(body))
} }
if status != wire.StatusOK || len(body) != 4 { if status != wire.StatusOK || len(body) != 4 {
control.Close() uploadLane.Close()
return nil, fmt.Errorf("bad OPEN response") return nil, fmt.Errorf("bad OPEN response")
} }
serverMax := int(binary.BigEndian.Uint32(body)) serverMax := int(binary.BigEndian.Uint32(body))
control.Close()
if serverMax < opts.minSize { if serverMax < opts.minSize {
uploadLane.Close()
return nil, fmt.Errorf("server maximum chunk %d is below client minimum %d", serverMax, opts.minSize) return nil, fmt.Errorf("server maximum chunk %d is below client minimum %d", serverMax, opts.minSize)
} }
if opts.maxSize > serverMax { if opts.maxSize > serverMax {
@@ -624,13 +623,12 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
sid: sid, sid: sid,
opts: opts, opts: opts,
serverMax: serverMax, serverMax: serverMax,
uploadLane: newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout), uploadLane: uploadLane,
downloadLane: newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout), downloadLane: newRequestLane(serverAddr, opts.tcpBuffer, reconnect, opts.txnTimeout),
// Start at the user-configured ceiling. On transport failures the // Start at the configured ceiling. On transport failure the batch is
// pipeline is halved; successful data responses grow it back by one, // halved but never below minPipeline; successful data grows it back by
// always staying inside minPipeline..maxPipeline. When the two bounds // one. When min == max the depth is pinned and never adapts, which is
// are equal the depth is pinned and never adapts, which is what paths // what paths that only work at one specific batch size need.
// that only work at one specific batch size need.
pipeline: opts.maxPipeline, pipeline: opts.maxPipeline,
minPipeline: opts.minPipeline, minPipeline: opts.minPipeline,
maxPipeline: opts.maxPipeline, maxPipeline: opts.maxPipeline,
@@ -640,53 +638,6 @@ func openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts
return c, nil 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 { func (c *chunkConn) fillReadBuffer() error {
if c.eof { if c.eof {
return io.EOF return io.EOF
@@ -694,7 +645,18 @@ func (c *chunkConn) fillReadBuffer() error {
minFailures := 0 minFailures := 0
for len(c.readBuf) == 0 && !c.eof { for len(c.readBuf) == 0 && !c.eof {
chunk := c.downSizer.Current() 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) data, status, err := c.downloadLane.download(c.sid, c.downloadOffset, c.consumedOffset, chunk, count)
for _, part := range data { for _, part := range data {
@@ -703,16 +665,20 @@ func (c *chunkConn) fillReadBuffer() error {
} }
if len(data) > 0 { if len(data) > 0 {
c.downSizer.Success(chunk) c.downSizer.Success(chunk)
c.growPipeline() if c.pipeline < c.maxPipeline {
c.pipeline++
}
minFailures = 0 minFailures = 0
} }
if err != nil { if err != nil {
// Shrink the batch first, then the record size. When the batch is if c.pipeline > c.minPipeline {
// pinned (min == max) the depth is left alone entirely and only the old := c.pipeline
// record size adapts. c.pipeline /= 2
if old, next, shrank := c.shrinkPipeline(); shrank { if c.pipeline < c.minPipeline {
if c.opts.adaptLog { c.pipeline = c.minPipeline
fmt.Printf("adaptive download batch: %d -> %d after transport failure\n", old, next) }
if c.opts.adaptLog && old != c.pipeline {
fmt.Printf("adaptive download pipeline: %d -> %d after transport failure\n", old, c.pipeline)
} }
} else { } else {
old, next := c.downSizer.Failure(chunk) old, next := c.downSizer.Failure(chunk)
@@ -808,9 +774,9 @@ func (c *chunkConn) Write(p []byte) (int, error) {
func (c *chunkConn) Close() error { func (c *chunkConn) Close() error {
c.closeOnce.Do(func() { c.closeOnce.Do(func() {
lane := newRequestLane(c.uploadLane.serverAddr, c.opts.tcpBuffer, 1, c.opts.txnTimeout) // Reuse the upload lane rather than dialling a connection just to say
_, _, _ = lane.single(wire.ModeClose, c.sid, 0, nil) // goodbye; that was a second wasted dial per flow.
lane.Close() _, _, _ = c.uploadLane.single(wire.ModeClose, c.sid, 0, nil)
c.uploadLane.Close() c.uploadLane.Close()
c.downloadLane.Close() c.downloadLane.Close()
}) })
-70
View File
@@ -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) { func TestReconnectZeroMeansPersistent(t *testing.T) {
lane := newRequestLane("127.0.0.1:1", 0, 0, 0) lane := newRequestLane("127.0.0.1:1", 0, 0, 0)
if lane.reconnectEvery != 0 { if lane.reconnectEvery != 0 {
+55 -27
View File
@@ -13,6 +13,7 @@ import (
"time" "time"
"dragontcp/internal/protocol" "dragontcp/internal/protocol"
"dragontcp/internal/xorchunk"
) )
const maxHeader = 128 * 1024 const maxHeader = 128 * 1024
@@ -251,7 +252,7 @@ func writeHTTPError(conn net.Conn, code int, reason, detail string) {
_, _ = conn.Write(body) _, _ = 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() { defer func() {
<-slots <-slots
_ = conn.Close() _ = conn.Close()
@@ -285,7 +286,7 @@ func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer i
var remote net.Conn var remote net.Conn
if transport == "chunk" { if transport == "chunk" {
remote, err = openChunkTunnel(serverAddr, token, host, port, chunkOpts) remote, err = wires.dial(host, port)
} else { } else {
remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer) 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 var remote net.Conn
if transport == "chunk" { if transport == "chunk" {
remote, err = openChunkTunnel(serverAddr, token, host, port, chunkOpts) remote, err = wires.dial(host, port)
} else { } else {
remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer) 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() { func main() {
var ( var (
listenHost = flag.String("listen-host", "127.0.0.1", "local proxy listen host") listenHost = flag.String("listen-host", "127.0.0.1", "local proxy listen host")
listenPort = flag.Int("listen-port", 8080, "local proxy listen port") listenPort = flag.Int("listen-port", 8080, "local proxy listen port")
serverHost = flag.String("server-host", "", "remote DragonTCP server host") serverHost = flag.String("server-host", "", "remote DragonTCP server host")
serverPort = flag.Int("server-port", 53, "remote DragonTCP server port") serverPort = flag.Int("server-port", 53, "remote DragonTCP server port")
token = flag.String("token", "", "optional shared token") token = flag.String("token", "", "optional shared token")
maxConnections = flag.Int("max-connections", 20000, "max simultaneous proxy connections") maxConnections = flag.Int("max-connections", 20000, "max simultaneous proxy connections")
transport = flag.String("transport", "chunk", "transport: chunk (DragonTCP binary adaptive transport)") 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") 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") chunkStart = flag.Int("chunk-start", 1048576, "initial adaptive chunk payload bytes")
chunkMin = flag.Int("chunk-min", 32, "minimum 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)") 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") 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") 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") 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") 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") 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)") 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") 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") 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") 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") 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() flag.Parse()
@@ -426,6 +428,17 @@ func main() {
fmt.Fprintln(os.Stderr, "--chunk-concurrency-min must not exceed --chunk-concurrency") fmt.Fprintln(os.Stderr, "--chunk-concurrency-min must not exceed --chunk-concurrency")
os.Exit(2) 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 { if *chunkReconnect < 0 {
fmt.Fprintln(os.Stderr, "--chunk-reconnect-every must be 0 or greater") fmt.Fprintln(os.Stderr, "--chunk-reconnect-every must be 0 or greater")
os.Exit(2) os.Exit(2)
@@ -438,14 +451,19 @@ func main() {
adaptSuccesses: *chunkSuccesses, adaptSuccesses: *chunkSuccesses,
adaptLog: *chunkAdaptLog, adaptLog: *chunkAdaptLog,
pollers: *chunkPollers, pollers: *chunkPollers,
minPipeline: *chunkConcurrencyMin,
maxPipeline: *chunkConcurrency,
reconnectEvery: *chunkReconnect, reconnectEvery: *chunkReconnect,
pollDelay: *chunkPollDelay, pollDelay: *chunkPollDelay,
txnTimeout: *chunkTimeout, txnTimeout: *chunkTimeout,
tcpBuffer: *tcpBuffer, 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)) listenAddr := net.JoinHostPort(*listenHost, strconv.Itoa(*listenPort))
serverAddr := net.JoinHostPort(*serverHost, strconv.Itoa(*serverPort)) 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) slots := make(chan struct{}, *maxConnections)
for { for {
@@ -491,7 +519,7 @@ func main() {
select { select {
case slots <- struct{}{}: case slots <- struct{}{}:
go handleLocal(conn, serverAddr, *token, *transport, *tcpBuffer, chunkOpts, slots) go handleLocal(conn, serverAddr, *token, *transport, *tcpBuffer, wires, slots)
default: default:
writeHTTPError( writeHTTPError(
conn, conn,
+142
View File
@@ -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
}
}
+25
View File
@@ -160,8 +160,10 @@ func handle(
tcpBuffer int, tcpBuffer int,
slots chan struct{}, slots chan struct{},
manager *streamManager, manager *streamManager,
xorManager *chunkManager,
chunkMax int, chunkMax int,
bufferBytes int, bufferBytes int,
chunkBuffered int,
chunkPollWait time.Duration, chunkPollWait time.Duration,
debug *serverDebug, debug *serverDebug,
) { ) {
@@ -174,6 +176,26 @@ func handle(
protocol.TuneTCP(conn) protocol.TuneTCP(conn)
protocol.TuneTCPBuffer(conn, tcpBuffer) 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 { for {
_ = conn.SetDeadline(time.Now().Add(30 * time.Second)) _ = conn.SetDeadline(time.Now().Add(30 * time.Second))
req, err := wire.ReadRequest(conn) req, err := wire.ReadRequest(conn)
@@ -249,6 +271,7 @@ func main() {
bufferBytes = 64 * 1024 * 1024 bufferBytes = 64 * 1024 * 1024
} }
manager := newStreamManager(*sessionTimeout, debug) 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()) fmt.Printf("binary_transport=true chunk_max=%d buffer_bytes=%d poll_wait=%s\n", *chunkMax, bufferBytes, chunkPollWait.String())
if debug.enabled { if debug.enabled {
fmt.Printf("debug=true debug_chunks=%t stats_interval=%s\n", debug.chunks, debug.statsEvery) fmt.Printf("debug=true debug_chunks=%t stats_interval=%s\n", debug.chunks, debug.statsEvery)
@@ -275,8 +298,10 @@ func main() {
*tcpBuffer, *tcpBuffer,
slots, slots,
manager, manager,
xorManager,
*chunkMax, *chunkMax,
bufferBytes, bufferBytes,
*chunkBuffered,
*chunkPollWait, *chunkPollWait,
debug, debug,
) )
+632
View File
@@ -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
}
}
Binary file not shown.
+790
View File
@@ -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) }