diff --git a/README.md b/README.md index 4324508..41151e4 100644 --- a/README.md +++ b/README.md @@ -1,168 +1,291 @@ -DragonTCP Proxy v6 - Server Debug Build -================================= +# DragonTCP Full Android VPN v1 -This build adds server-side diagnostic logging for the adaptive/chunk transport. -The wire protocol and v6 client remain compatible. +This build replaces the old HTTP-proxy-only Android design with a real layer-3 +VPN packet tunnel. -New server flags ----------------- +It does **not** use the uploaded `jni.zip` and does not depend on HEV or any +other tun2socks binary. The Android `VpnService` TUN file descriptor is passed +directly to the DragonTCP Go core with Unix `SCM_RIGHTS`, and the Go core moves +raw IPv4/IPv6 packets through DragonTCP's adaptive, XOR-framed TCP/53 +transport. ---debug - Session/connect/error logging plus periodic aggregate statistics. +## Architecture ---debug-chunks - Logs every COPEN, CPUSH, ACK, CPULL, DATA, WAIT, EOF, and CCLOSE event. - This is extremely verbose with 32-byte chunks and can reduce throughput. - Enabling --debug-chunks also enables normal debug logging. +```text +Android apps + | + | IPv4 + IPv6 default routes + v +Android VpnService TUN (MTU 1280) + | + v +DragonTCP Go VPN core + | + | adaptive small records, XOR 0xAD, TCP/53 + v +DragonTCP VPN server + | + v +Linux TUN dragontcp0 + | + | IP forwarding + NAT + v +Internet +``` ---debug-stats-interval DURATION - Aggregate statistics frequency. Default: 5s. - Set to 0 to disable periodic statistics. +Because complete IP packets are tunneled, this carries TCP, UDP, DNS, ICMP, +IPv4 and IPv6. Applications do not need HTTP or SOCKS proxy support. -Recommended diagnostic command for fixed 32-byte chunks --------------------------------------------------------- +## Included files -sudo ./dragontcp-server-linux-amd64 --token 'YOUR_SECRET' --chunk-max 32 --chunk-buffered 2048 --chunk-poll-wait 50ms --chunk-session-timeout 5m --max-connections 20000 --tcp-buffer 0 --debug --debug-chunks --debug-stats-interval 5s +```text +bin/dragontcp-vpn-server-linux-amd64 +bin/dragontcp-vpn-server-linux-arm64 +android/build/DragonTCP-VPN.apk +android/lib/arm64-v8a/libdragontcp_vpn.so +core/ complete Go source +android/src/ complete Android Java source +build_core.sh +build_all.sh +android/build_apk.sh +``` -Normal production command with useful low-overhead debug ---------------------------------------------------------- +## Server requirements -sudo ./dragontcp-server-linux-amd64 --token 'YOUR_SECRET' --chunk-max 32 --chunk-buffered 2048 --chunk-poll-wait 50ms --chunk-session-timeout 5m --max-connections 20000 --tcp-buffer 0 --debug --debug-stats-interval 10s +The full VPN server needs root/CAP_NET_ADMIN because it creates a Linux TUN +interface and enables packet forwarding/NAT. -Disable all debug logging -------------------------- +Install the normal Linux networking tools if they are not already present: -Simply omit --debug and --debug-chunks. +```bash +sudo apt-get update +sudo apt-get install -y iproute2 iptables +``` -Example debug output --------------------- +TCP port 53 must be free. -[DEBUG] SESSION OPEN id=... target=example.com:443 max_chunk=32 active_sessions=1 -[CHUNK] CPUSH id=... seq=0 bytes=32 -> ACK accepted=32 -[CHUNK] CPULL id=... ack=-1 want=0 offset=0 limit=32 -[CHUNK] DATA id=... seq=0 offset=0 bytes=32 total=32 -[CHUNK] CPULL id=... want=8 -> WAIT -[DEBUG] SESSION CLOSE id=... active_sessions=0 -[DEBUG] STATS uptime=10s active_connections=8 active_sessions=2 sessions_opened=5 sessions_closed=3 bytes_up=... bytes_down=... push_records=... pull_requests=... data_records=... waits=... errors=0 +Check: -Counters --------- +```bash +sudo ss -lntp | grep ':53' +``` -active_connections - currently open DragonTCP TCP connections -active_sessions - currently open chunk proxy sessions -sessions_opened - total chunk sessions opened -sessions_closed - total chunk sessions closed -bytes_up - bytes accepted from client and written toward target -bytes_down - bytes read from target into chunk buffering -push_records - accepted upload CPUSH records -pull_requests - CPULL requests received -data_records - DATA responses generated -waits - WAIT responses because downstream data was not ready yet -errors - debug-counted server/protocol errors +## Start the server -Important performance note --------------------------- +```bash +sudo ./dragontcp-vpn-server-linux-amd64 \ + --token 'YOUR_SECRET' \ + --debug +``` -At 32 bytes, --debug-chunks can generate thousands or millions of log lines for -large transfers. Use it while diagnosing a failure, then switch to --debug only -for normal use. +The defaults are: -Large-chunk update -================== +```text +listen 0.0.0.0:53/TCP +TUN dragontcp0 +TUN MTU 1280 +server IPv4 10.123.0.1/16 +server IPv6 fd7a:4472:6167:6f6e::1/64 +maximum fragment 65535 bytes +poll wait 100ms +auto NAT enabled +private targets blocked +``` -This is the v6 debug/adaptive-chunk branch with FIXED poller concurrency. -It intentionally does NOT include the later adaptive-poller controller. +The server automatically enables IPv4/IPv6 forwarding and installs +MASQUERADE/forward rules with `iptables`/`ip6tables` when available. -Chunk limits ------------- +If you manage routing/NAT yourself: -Previous hard limit: +```bash +sudo ./dragontcp-vpn-server-linux-amd64 \ + --token 'YOUR_SECRET' \ + --auto-nat=false +``` - 8192 bytes +To allow clients to reach private/LAN destination addresses too: -New hard limit: +```bash +--allow-private +``` - 1048576 bytes (1 MiB) +## Debug server -The framed protocol ceiling was increased to 2 MiB so a 1 MiB CPUSH/DATA -record plus protocol metadata fits safely. +Normal diagnostics: -New defaults: +```bash +sudo ./dragontcp-vpn-server-linux-amd64 \ + --token 'YOUR_SECRET' \ + --debug \ + --debug-stats-interval 5s +``` - client --chunk-max 65536 - server --chunk-max 65536 +Very verbose per-IP-packet diagnostics: -The adaptive client still begins at: +```bash +--debug-packets +``` - --chunk-start 256 +Do not leave `--debug-packets` enabled for high-throughput use. -and can grow toward the configured maximum after successful records. +## Android app -Use up to 1 MiB adaptive chunks --------------------------------- +Install: -Server: +```text +DragonTCP-VPN.apk +``` - sudo ./dragontcp-server-linux-amd64 --token 'YOUR_SECRET' --chunk-max 1048576 --debug --debug-stats-interval 10s +The UI is intentionally small: -Android ARM64 client with fixed poller count of 8: +```text +Server +TCP Port +Token +Maximum fragment +Minimum fragment +Timeout - ./dragontcp-client-android-arm64 --server-host YOUR_SERVER_IP --token 'YOUR_SECRET' --chunk-start 256 --chunk-min 32 --chunk-max 1048576 --chunk-pollers 8 --chunk-timeout 2s --chunk-adapt-log +CONNECT +STOP -The number of pollers stays exactly at the value passed with --chunk-pollers. -Only the chunk size adapts. +Live log +``` -Examples of useful ceilings ---------------------------- +Defaults: - --chunk-max 16384 # 16 KiB - --chunk-max 32768 # 32 KiB - --chunk-max 65536 # 64 KiB (new default maximum) - --chunk-max 131072 # 128 KiB - --chunk-max 262144 # 256 KiB - --chunk-max 524288 # 512 KiB - --chunk-max 1048576 # 1 MiB hard maximum +```text +Port 53 +Max 1280 +Min 32 +Timeout 2s +Pollers 1 (fixed) +MTU 1280 (fixed) +``` -Fixed-size mode also supports the same range: +The starting DragonTCP record size is always the configured maximum. On a +transport failure the client automatically reduces it. With Max=1280 and +Min=32 the reduction path can converge approximately as: - --chunk-size 262144 +```text +1280 -> 640 -> 320 -> 160 -> 80 -> 40 -> 32 +``` -Memory note ------------ +After sustained successful full-size records it cautiously grows again. -Larger server chunk maxima require larger per-session target-read buffers and -can increase buffered memory substantially when many sessions are active. -For thousands of simultaneous users, do not automatically use 1 MiB unless -measurements show that it is useful. Values such as 16-64 KiB are a more -reasonable starting point, while the adaptive client can still be configured -to probe higher when your network supports it. +The app assigns itself a stable private DragonTCP VPN IPv4/IPv6 pair on first +run. The DragonTCP app UID itself is excluded from the VPN so the TCP/53 +transport cannot recursively enter its own TUN interface. -Validation ----------- +## Why Max defaults to 1280 -The updated source and binaries were rebuilt from this v6 debug branch. -Validation included: +This version transports IP packets, not an HTTP byte stream. The Android VPN +MTU is 1280, so an individual IP packet normally cannot exceed 1280 bytes. +The UI still accepts larger DragonTCP record ceilings up to 65535, but there +is usually no throughput benefit unless the VPN MTU is raised too. -* Go builds for Linux amd64, Linux ARM64, Linux ARMv7 client, and Android ARM64. -* A protocol round-trip test with a full 1 MiB request and response frame. -* An 8 MiB HTTP download through the proxy using fixed 262144-byte (256 KiB) - chunk configuration; the downloaded SHA-256 matched the source exactly. +## Building everything from source -DragonTCP branding update -========================= +Requirements: -This package was renamed from HOX to DragonTCP. +- Go 1.22+ +- JDK 17+ +- Android SDK platform and build-tools +- `zip` -Binary names are now: +No Android NDK is required in this build. - dragontcp-server-linux-amd64 - dragontcp-server-arm64 - dragontcp-client-linux-amd64 - dragontcp-client-android-arm64 - dragontcp-client-arm64 - dragontcp-client-armv7 +Set the SDK path: -The Go module and command directories were also renamed to DragonTCP. -The existing UP/OK wire framing and chunk protocol were intentionally kept -unchanged, so this branding change does not break compatibility with the -previous protocol implementation. +```bash +export ANDROID_SDK_ROOT="$HOME/Android/Sdk" +``` + +Build server, Android native core, and APK: + +```bash +./build_all.sh +``` + +Outputs: + +```text +bin/dragontcp-vpn-server-linux-amd64 +bin/dragontcp-vpn-server-linux-arm64 +android/lib/arm64-v8a/libdragontcp_vpn.so +android/build/DragonTCP-VPN.apk +``` + +Build only Go/native components: + +```bash +./build_core.sh +``` + +Build only APK after the core is present: + +```bash +./android/build_apk.sh +``` + +## Android TUN fd handoff + +The Android service creates the VPN using `VpnService.Builder.establish()`. +It then sends that TUN file descriptor to the Go child over a private Unix +socket using Android `LocalSocket.setFileDescriptorsForSend()`. The Go side +receives the descriptor with `SCM_RIGHTS` and directly reads/writes IP +packets. + +This avoids JNI and avoids passing an inherited descriptor through +`ProcessBuilder`. + +## Protocol packet mode + +Packet mode still uses the DragonTCP request/response envelope: + +```text +request : UP + request-id + length + XOR(payload) +response : OK + request-id + length + XOR(payload) +``` + +The VPN payload protocol is binary rather than text to reduce overhead on very +small records. + +Commands include: + +```text +VOPEN +VPUSH fragment +VPULL fragment +VCLOSE +``` + +A random 128-bit session ID is used after authenticated session creation. +Packets and fragments have sequence/offset fields so retries do not duplicate +bytes. + +## Test mode + +For protocol testing without root/TUN/NAT, the server has: + +```bash +./dragontcp-vpn-server-linux-amd64 \ + --host 127.0.0.1 \ + --port 19053 \ + --token test \ + --mock-echo +``` + +This echoes complete IP packets back to the client instead of forwarding them +to the Internet. + +During development the packet path was tested with IPv4 and IPv6 1280-byte +packets while the server forced a 32-byte maximum DragonTCP fragment. Both +were reassembled byte-for-byte correctly. + +## Security + +XOR 0xAD remains protocol obfuscation, not cryptographic encryption. HTTPS +and other TLS-based application protocols retain their own end-to-end +security, but the DragonTCP transport itself should not be considered +cryptographically confidential. diff --git a/SHA256SUMS b/SHA256SUMS index e2b7746..08f7acd 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,6 +1,4 @@ -1f1058a03099fc04dd00c727656f26a88ebcf43f7ef924d11ecb5675aab96895 bin/dragontcp-client-android-arm64 -232c95474d90c009f18db9c2d870c910a8248f97185b715e54166eab4baeaebf bin/dragontcp-client-arm64 -759fc70875d3501c9954e9db99a2b6f51d1ff9f4f39ff3d9b21062b971890e3e bin/dragontcp-client-armv7 -3b18a4b7dc79819b22ef550b1761c8f36b51793bec35ff077d038c1a7d00ee79 bin/dragontcp-client-linux-amd64 -4a00acbc2fdeaa1acd34c528b84813c3688a03134d591bed34f94254a46d8d06 bin/dragontcp-server-arm64 -0bc46c805ceabad0dd149f8b068f98e75eba8cd08b86c9298dc7f34094e9c666 bin/dragontcp-server-linux-amd64 +1c854f81ee4d493c7e7b5956f7a81d08a4019e6a1ba9aef00ca64df702cdc90f android/build/DragonTCP-VPN.apk +a05c475d98b922c053142cd2f65b52c22facd8404370108671010846c26019bc bin/dragontcp-vpn-server-linux-amd64 +29d52797045da9d13114264187bfe7445f78f3ef4fac225d29aec9348efb1be1 bin/dragontcp-vpn-server-linux-arm64 +5d8282fd03af2178a3061711edba177ab144db50a0aeaa170d53598ff795fb07 android/lib/arm64-v8a/libdragontcp_vpn.so diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml new file mode 100644 index 0000000..89610a1 --- /dev/null +++ b/android/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/android/build_apk.sh b/android/build_apk.sh new file mode 100644 index 0000000..3bc20b5 --- /dev/null +++ b/android/build_apk.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" +[[ -n "$SDK" ]] || { echo "Set ANDROID_SDK_ROOT" >&2; exit 1; } +BUILD_TOOLS="${BUILD_TOOLS:-35.0.0}"; PLATFORM="${PLATFORM:-android-35}" +if [[ ! -d "$SDK/build-tools/$BUILD_TOOLS" ]]; then BUILD_TOOLS="$(find "$SDK/build-tools" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -V | tail -1)"; fi +if [[ ! -f "$SDK/platforms/$PLATFORM/android.jar" ]]; then PLATFORM="$(find "$SDK/platforms" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -V | tail -1)"; fi +BT="$SDK/build-tools/$BUILD_TOOLS"; AJ="$SDK/platforms/$PLATFORM/android.jar" +for tool in aapt d8 apksigner; do [[ -x "$BT/$tool" ]] || { echo "Missing $BT/$tool" >&2; exit 1; }; done +CORE="$ROOT/lib/arm64-v8a/libdragontcp_vpn.so"; [[ -f "$CORE" ]] || { echo "Run ../build_core.sh first" >&2; exit 1; } +B="$ROOT/build"; rm -rf "$B"; mkdir -p "$B/classes" "$B/dex" +"$BT/aapt" package -f -M "$ROOT/AndroidManifest.xml" -S "$ROOT/res" -I "$AJ" -F "$B/resources.ap_" +javac -source 8 -target 8 -classpath "$AJ" -d "$B/classes" $(find "$ROOT/src" -name '*.java' -print) +"$BT/d8" --lib "$AJ" --min-api 29 --output "$B/dex" $(find "$B/classes" -name '*.class' -print) +cp "$B/resources.ap_" "$B/DragonTCP-VPN-unsigned.apk" +(cd "$B/dex" && zip -q "$B/DragonTCP-VPN-unsigned.apk" classes.dex) +(cd "$ROOT" && zip -q -r "$B/DragonTCP-VPN-unsigned.apk" lib) +KEYSTORE="$ROOT/dragontcp-debug.jks" +if [[ ! -f "$KEYSTORE" ]]; then keytool -genkeypair -keystore "$KEYSTORE" -storepass dragontcp -keypass dragontcp -alias dragontcp -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=DragonTCP VPN,O=DragonTCP,C=US"; fi +"$BT/apksigner" sign --ks "$KEYSTORE" --ks-pass pass:dragontcp --key-pass pass:dragontcp --out "$B/DragonTCP-VPN.apk" "$B/DragonTCP-VPN-unsigned.apk" +"$BT/apksigner" verify --verbose "$B/DragonTCP-VPN.apk" +echo "Built APK: $B/DragonTCP-VPN.apk" diff --git a/android/lib/arm64-v8a/libdragontcp_vpn.so b/android/lib/arm64-v8a/libdragontcp_vpn.so new file mode 100644 index 0000000..2a4b79a Binary files /dev/null and b/android/lib/arm64-v8a/libdragontcp_vpn.so differ diff --git a/android/res/values/strings.xml b/android/res/values/strings.xml new file mode 100644 index 0000000..62f8bb8 --- /dev/null +++ b/android/res/values/strings.xml @@ -0,0 +1 @@ +DragonTCP VPN diff --git a/android/src/com/dragontcp/client/DragonService.java b/android/src/com/dragontcp/client/DragonService.java new file mode 100644 index 0000000..c6601d0 --- /dev/null +++ b/android/src/com/dragontcp/client/DragonService.java @@ -0,0 +1,106 @@ +package com.dragontcp.client; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.LocalSocket; +import android.net.LocalSocketAddress; +import android.net.VpnService; +import android.os.Build; +import android.os.ParcelFileDescriptor; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.FileDescriptor; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class DragonService extends VpnService { + public static final String ACTION_CONNECT="com.dragontcp.client.CONNECT"; + public static final String ACTION_STOP="com.dragontcp.client.STOP"; + public static volatile boolean active=false,running=false; + public static volatile String state="Stopped"; + private static final String CHANNEL_ID="dragontcp_vpn"; + private static final int NOTIFICATION_ID=53; + private final Object lifecycleLock=new Object(); + private Process process; + private Thread outputThread; + private ParcelFileDescriptor vpnInterface; + private File fdSocketFile; + + @Override public void onCreate(){super.onCreate();createNotificationChannel();} + @Override public int onStartCommand(Intent intent,int flags,int startId){ + if(intent==null)return START_NOT_STICKY;String action=intent.getAction(); + if(ACTION_STOP.equals(action)){appendLog("STOP requested");shutdown("Stopped by user",true);return START_NOT_STICKY;} + if(!ACTION_CONNECT.equals(action))return START_NOT_STICKY; + cleanupResources(true);clearLog();active=true;running=false;state="Starting VPN";startForeground(NOTIFICATION_ID,buildNotification("Starting full VPN")); + String server=intent.getStringExtra("server"),token=intent.getStringExtra("token"),timeout=intent.getStringExtra("timeout"),v4=intent.getStringExtra("vpnIPv4"),v6=intent.getStringExtra("vpnIPv6"); + int port=intent.getIntExtra("port",53),max=intent.getIntExtra("chunkMax",1280),min=intent.getIntExtra("chunkMin",32),start=intent.getIntExtra("chunkStart",max); + if(server==null||server.trim().isEmpty()){failStart("Server is empty");return START_NOT_STICKY;}if(token==null)token="";if(timeout==null||timeout.isEmpty())timeout="2s";if(v4==null||v6==null){failStart("Missing VPN client address");return START_NOT_STICKY;} + start=max; + try{ + establishPacketVpn(v4,v6); + state="Starting DragonTCP core"; + startCore(server.trim(),port,token,start,min,max,timeout.trim(),v4,v6); + state="Connecting to DragonTCP server"; + updateNotification("Connecting • TCP/"+port); + }catch(Exception e){failStart(e.getMessage()==null?e.toString():e.getMessage());} + return START_NOT_STICKY; + } + + private void establishPacketVpn(String v4,String v6)throws Exception{ + VpnService.Builder b=new VpnService.Builder();b.setSession("DragonTCP VPN");b.setMtu(1280); + b.addAddress(v4,32);b.addAddress(v6,128);b.addRoute("0.0.0.0",0);b.addRoute("::",0); + b.addDnsServer("1.1.1.1");b.addDnsServer("2606:4700:4700::1111"); + try{b.addDisallowedApplication(getPackageName());}catch(PackageManager.NameNotFoundException e){throw new Exception("Cannot exclude DragonTCP from its own VPN",e);} + Intent open=new Intent(this,MainActivity.class);open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP|Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pi=PendingIntent.getActivity(this,1,open,PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE);b.setConfigureIntent(pi); + vpnInterface=b.establish();if(vpnInterface==null)throw new Exception("Android did not establish the TUN interface"); + appendLog("TUN established: "+v4+" + "+v6+" MTU=1280");appendLog("Routes captured: 0.0.0.0/0 and ::/0");appendLog("DNS through VPN: 1.1.1.1 + 2606:4700:4700::1111");appendLog("DragonTCP app UID excluded from VPN to prevent recursion"); + } + + private void startCore(String server,int port,String token,int start,int min,int max,String timeout,String v4,String v6)throws Exception{ + String executable=getApplicationInfo().nativeLibraryDir+"/libdragontcp_vpn.so";File exe=new File(executable);if(!exe.exists())throw new Exception("Embedded DragonTCP VPN core was not extracted"); + fdSocketFile=new File(getFilesDir(),"dragontcp-tunfd.sock");if(fdSocketFile.exists())fdSocketFile.delete(); + List cmd=new ArrayList();cmd.add(executable);cmd.add("--server-host");cmd.add(server);cmd.add("--server-port");cmd.add(String.valueOf(port));cmd.add("--token");cmd.add(token); + cmd.add("--tun-fd-socket");cmd.add(fdSocketFile.getAbsolutePath());cmd.add("--vpn-ipv4");cmd.add(v4);cmd.add("--vpn-ipv6");cmd.add(v6);cmd.add("--vpn-mtu");cmd.add("1280"); + cmd.add("--chunk-start");cmd.add(String.valueOf(max));cmd.add("--chunk-max");cmd.add(String.valueOf(max));cmd.add("--chunk-min");cmd.add(String.valueOf(min));cmd.add("--chunk-grow-after");cmd.add("64");cmd.add("--chunk-timeout");cmd.add(timeout);cmd.add("--chunk-reconnect-every");cmd.add("32");cmd.add("--chunk-adapt-log"); + appendLog("Server: "+server+":"+port);appendLog("Transport chunks: start=max="+max+" min="+min+" pollers=1 timeout="+timeout); + ProcessBuilder pb=new ProcessBuilder(cmd);pb.redirectErrorStream(true);pb.directory(getFilesDir());final Process p=pb.start();synchronized(lifecycleLock){process=p;} + outputThread=new Thread(()->readCoreOutput(p),"DragonTCP-output");outputThread.setDaemon(true);outputThread.start(); + passTunFdWhenReady(); + } + + private void passTunFdWhenReady()throws Exception{ + long deadline=System.currentTimeMillis()+5000;while(System.currentTimeMillis()=26?new Notification.Builder(this,CHANNEL_ID):new Notification.Builder(this);return nb.setContentTitle("DragonTCP VPN").setContentText(msg).setSmallIcon(android.R.drawable.stat_sys_upload).setOngoing(true).setContentIntent(op).addAction(android.R.drawable.ic_menu_close_clear_cancel,"STOP",sp).build();} + private void updateNotification(String m){NotificationManager nm=(NotificationManager)getSystemService(NOTIFICATION_SERVICE);if(nm!=null)nm.notify(NOTIFICATION_ID,buildNotification(m));} + private void createNotificationChannel(){if(Build.VERSION.SDK_INT>=26){NotificationChannel c=new NotificationChannel(CHANNEL_ID,"DragonTCP VPN",NotificationManager.IMPORTANCE_LOW);c.setDescription("DragonTCP full packet VPN status");NotificationManager nm=(NotificationManager)getSystemService(NOTIFICATION_SERVICE);if(nm!=null)nm.createNotificationChannel(c);}} + private synchronized void appendLog(String line){try(PrintWriter out=new PrintWriter(new OutputStreamWriter(new FileOutputStream(new File(getFilesDir(),"dragontcp.log"),true),"UTF-8"))){out.println(line);out.flush();}catch(Exception ignored){}} + private void clearLog(){try{new FileOutputStream(new File(getFilesDir(),"dragontcp.log"),false).close();}catch(Exception ignored){}} + private void failStart(String m){appendLog("START ERROR: "+m);running=false;active=false;state="Start failed: "+m;cleanupResources(true);stopForeground(true);stopSelf();} + private void shutdown(String reason,boolean stop){state="Stopping";running=false;appendLog(reason);cleanupResources(true);active=false;state="Stopped";stopForeground(true);if(stop)stopSelf();} + private void cleanupResources(boolean kill){Process p; synchronized(lifecycleLock){p=process;process=null;}if(p!=null){try{p.getInputStream().close();}catch(Exception ignored){}try{p.destroy();}catch(Exception ignored){}if(kill){try{if(!p.waitFor(800,TimeUnit.MILLISECONDS)){p.destroyForcibly();p.waitFor(800,TimeUnit.MILLISECONDS);}}catch(Exception ignored){try{p.destroyForcibly();}catch(Exception ignored2){}}}}Thread t=outputThread;outputThread=null;if(t!=null&&t!=Thread.currentThread())t.interrupt();closeVpn();if(fdSocketFile!=null){fdSocketFile.delete();fdSocketFile=null;}running=false;} + private void closeVpn(){ParcelFileDescriptor v=vpnInterface;vpnInterface=null;if(v!=null){try{v.close();}catch(Exception ignored){}}} + @Override public void onRevoke(){appendLog("VPN permission revoked");shutdown("VPN revoked",true);super.onRevoke();} + @Override public void onDestroy(){cleanupResources(true);active=false;running=false;if(!state.startsWith("Start failed")&&!state.startsWith("Core exited"))state="Stopped";stopForeground(true);super.onDestroy();} +} diff --git a/android/src/com/dragontcp/client/MainActivity.java b/android/src/com/dragontcp/client/MainActivity.java new file mode 100644 index 0000000..5bdf7e8 --- /dev/null +++ b/android/src/com/dragontcp/client/MainActivity.java @@ -0,0 +1,133 @@ +package com.dragontcp.client; + +import android.app.Activity; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.graphics.Typeface; +import android.net.VpnService; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.text.InputType; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import java.io.File; +import java.io.FileInputStream; +import java.security.SecureRandom; + +public class MainActivity extends Activity { + private static final int VPN_REQUEST = 5301; + + private EditText server, port, token, chunkMax, chunkMin, timeout; + private TextView status, logs; + private ScrollView logScroll; + private Button connectButton, stopButton; + private Intent pendingServiceIntent; + private SharedPreferences prefs; + private String lastLogText = ""; + private final Handler handler = new Handler(); + + private final Runnable refresher = new Runnable() { + @Override public void run() { + refreshStatus(); + handler.postDelayed(this, 500); + } + }; + + @Override protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + prefs = getSharedPreferences("dragontcp", MODE_PRIVATE); + setTitle("DragonTCP VPN"); + buildUi(); + loadSettings(); + handler.post(refresher); + } + + private int dp(int v) { return (int)(v * getResources().getDisplayMetrics().density + 0.5f); } + private TextView text(String s, float sp, boolean bold) { + TextView v = new TextView(this); v.setText(s); v.setTextSize(sp); v.setTextColor(Color.rgb(232,236,241)); + if (bold) v.setTypeface(Typeface.DEFAULT, Typeface.BOLD); return v; + } + private EditText field(LinearLayout root, String label, int type) { + TextView t=text(label,13f,false);t.setPadding(0,dp(9),0,dp(4));root.addView(t); + EditText e=new EditText(this);e.setSingleLine(true);e.setTextColor(Color.WHITE);e.setHintTextColor(Color.GRAY);e.setInputType(type); + e.setBackgroundColor(Color.rgb(42,47,54));e.setPadding(dp(12),dp(9),dp(12),dp(9)); + root.addView(e,new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.WRAP_CONTENT));return e; + } + + private void buildUi() { + ScrollView page=new ScrollView(this);page.setFillViewport(true);page.setBackgroundColor(Color.rgb(20,23,27)); + LinearLayout root=new LinearLayout(this);root.setOrientation(LinearLayout.VERTICAL);root.setPadding(dp(18),dp(18),dp(18),dp(24));page.addView(root); + TextView title=text("DragonTCP VPN",27f,true);title.setTextColor(Color.rgb(104,207,255));root.addView(title); + TextView sub=text("Full IPv4 / IPv6 packet VPN over adaptive TCP/53",13f,false);sub.setTextColor(Color.rgb(170,179,188));sub.setPadding(0,dp(2),0,dp(12));root.addView(sub); + status=text("Stopped",16f,true);status.setPadding(dp(12),dp(12),dp(12),dp(12));status.setBackgroundColor(Color.rgb(34,39,45));root.addView(status); + + server=field(root,"Server IP / hostname",InputType.TYPE_CLASS_TEXT); + port=field(root,"TCP port",InputType.TYPE_CLASS_NUMBER); + token=field(root,"Token",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_PASSWORD); + chunkMax=field(root,"Maximum transport fragment bytes (start = max)",InputType.TYPE_CLASS_NUMBER); + chunkMin=field(root,"Minimum transport fragment bytes",InputType.TYPE_CLASS_NUMBER); + timeout=field(root,"Transaction timeout (example: 2s)",InputType.TYPE_CLASS_TEXT); + + TextView note=text("Pollers are fixed at 1. Adaptive chunks always start at Max and shrink on failures. All IPv4 and IPv6 routes are captured by the VPN; DragonTCP itself is excluded to prevent a tunnel loop.",12f,false); + note.setTextColor(Color.rgb(160,170,180));note.setPadding(0,dp(10),0,dp(8));root.addView(note); + + LinearLayout buttons=new LinearLayout(this);buttons.setOrientation(LinearLayout.HORIZONTAL);buttons.setGravity(Gravity.CENTER);buttons.setPadding(0,dp(8),0,dp(10));root.addView(buttons); + connectButton=new Button(this);connectButton.setText("CONNECT");buttons.addView(connectButton,new LinearLayout.LayoutParams(0,ViewGroup.LayoutParams.WRAP_CONTENT,1f)); + stopButton=new Button(this);stopButton.setText("STOP");buttons.addView(stopButton,new LinearLayout.LayoutParams(0,ViewGroup.LayoutParams.WRAP_CONTENT,1f)); + connectButton.setOnClickListener(v -> startDragon()); stopButton.setOnClickListener(v -> stopDragon()); + + LinearLayout lh=new LinearLayout(this);lh.setOrientation(LinearLayout.HORIZONTAL);lh.setGravity(Gravity.CENTER_VERTICAL);root.addView(lh); + TextView lt=text("Live log",17f,true);lh.addView(lt,new LinearLayout.LayoutParams(0,ViewGroup.LayoutParams.WRAP_CONTENT,1f)); + Button clear=new Button(this);clear.setText("CLEAR");lh.addView(clear);clear.setOnClickListener(v -> clearLog()); + logScroll=new ScrollView(this);logScroll.setFillViewport(true);logScroll.setVerticalScrollBarEnabled(true);logScroll.setBackgroundColor(Color.BLACK); + logs=text("",11f,false);logs.setTypeface(Typeface.MONOSPACE);logs.setTextIsSelectable(true);logs.setPadding(dp(10),dp(10),dp(10),dp(10));logs.setBackgroundColor(Color.BLACK); + logScroll.addView(logs,new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.WRAP_CONTENT)); + root.addView(logScroll,new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,dp(320))); + setContentView(page); + } + + private void loadSettings(){server.setText(prefs.getString("server",""));port.setText(prefs.getString("port","53"));token.setText(prefs.getString("token",""));chunkMax.setText(prefs.getString("chunkMax","1280"));chunkMin.setText(prefs.getString("chunkMin","32"));timeout.setText(prefs.getString("timeout","2s"));} + private int intValue(EditText e,int d){try{return Integer.parseInt(e.getText().toString().trim());}catch(Exception x){return d;}} + private boolean validateSettings(){ + if(server.getText().toString().trim().isEmpty()){toast("Enter the server IP or hostname");return false;} + int p=intValue(port,53),min=intValue(chunkMin,32),max=intValue(chunkMax,1280); + if(p<1||p>65535){toast("Port must be 1-65535");return false;} + if(min<32||max>65535||min>max){toast("Chunks must satisfy 32 <= Min <= Max <= 65535");return false;} + if(timeout.getText().toString().trim().isEmpty()){toast("Enter a timeout such as 2s");return false;} + return true; + } + private void saveSettings(){prefs.edit().putString("server",server.getText().toString().trim()).putString("port",port.getText().toString().trim()).putString("token",token.getText().toString()).putString("chunkMax",chunkMax.getText().toString().trim()).putString("chunkMin",chunkMin.getText().toString().trim()).putString("timeout",timeout.getText().toString().trim()).apply();} + + private int clientHostId(){ + int id=prefs.getInt("clientHostId",0);if(id>=2&&id<=65534)return id; + id=2+new SecureRandom().nextInt(65533);prefs.edit().putInt("clientHostId",id).apply();return id; + } + private String clientIPv4(int id){return "10.123."+((id>>8)&255)+"."+(id&255);} + private String clientIPv6(int id){return "fd7a:4472:6167:6f6e::"+Integer.toHexString(id);} + + private Intent buildServiceIntent(){ + int max=intValue(chunkMax,1280),id=clientHostId();Intent i=new Intent(this,DragonService.class);i.setAction(DragonService.ACTION_CONNECT); + i.putExtra("server",server.getText().toString().trim());i.putExtra("port",intValue(port,53));i.putExtra("token",token.getText().toString()); + i.putExtra("chunkStart",max);i.putExtra("chunkMax",max);i.putExtra("chunkMin",intValue(chunkMin,32));i.putExtra("timeout",timeout.getText().toString().trim()); + i.putExtra("vpnIPv4",clientIPv4(id));i.putExtra("vpnIPv6",clientIPv6(id));return i; + } + private void startDragon(){if(!validateSettings())return;saveSettings();pendingServiceIntent=buildServiceIntent();DragonService.active=true;DragonService.state="Waiting for VPN permission";refreshStatus();Intent prep=VpnService.prepare(this);if(prep!=null)startActivityForResult(prep,VPN_REQUEST);else{Intent i=pendingServiceIntent;pendingServiceIntent=null;launchService(i);}} + private void launchService(Intent i){if(i==null)return;DragonService.active=true;DragonService.state="Starting full VPN";refreshStatus();if(Build.VERSION.SDK_INT>=26)startForegroundService(i);else startService(i);toast("Starting DragonTCP VPN...");} + @Override protected void onActivityResult(int requestCode,int resultCode,Intent data){super.onActivityResult(requestCode,resultCode,data);if(requestCode!=VPN_REQUEST)return;if(resultCode==RESULT_OK&&pendingServiceIntent!=null){Intent i=pendingServiceIntent;pendingServiceIntent=null;launchService(i);}else{pendingServiceIntent=null;DragonService.active=false;DragonService.running=false;DragonService.state="VPN permission denied";refreshStatus();toast("VPN permission is required");}} + private void stopDragon(){pendingServiceIntent=null;DragonService.state="Stopping...";refreshStatus();Intent s=new Intent(this,DragonService.class);s.setAction(DragonService.ACTION_STOP);try{startService(s);}catch(Exception e){stopService(new Intent(this,DragonService.class));}handler.postDelayed(()->{if(DragonService.active)stopService(new Intent(MainActivity.this,DragonService.class));refreshStatus();},1800);} + private void clearLog(){try{File f=new File(getFilesDir(),"dragontcp.log");new java.io.FileOutputStream(f,false).close();lastLogText="";logs.setText("");}catch(Exception e){toast("Could not clear log: "+e.getMessage());}} + private String readTail(File f,int maxBytes){if(!f.exists())return "";try(FileInputStream in=new FileInputStream(f)){long len=f.length();int n=(int)Math.min((long)maxBytes,len);byte[]buf=new byte[n];long skip=len-n;while(skip>0){long s=in.skip(skip);if(s<=0)break;skip-=s;}int off=0;while(offlogScroll.fullScroll(View.FOCUS_DOWN));}} + private void toast(String s){Toast.makeText(this,s,Toast.LENGTH_LONG).show();} + @Override protected void onDestroy(){handler.removeCallbacks(refresher);super.onDestroy();} +} diff --git a/bin/dragontcp-client-android-arm64 b/bin/dragontcp-client-android-arm64 deleted file mode 100644 index 648dd2c..0000000 Binary files a/bin/dragontcp-client-android-arm64 and /dev/null differ diff --git a/bin/dragontcp-client-arm64 b/bin/dragontcp-client-arm64 deleted file mode 100644 index 7c89ac5..0000000 Binary files a/bin/dragontcp-client-arm64 and /dev/null differ diff --git a/bin/dragontcp-client-armv7 b/bin/dragontcp-client-armv7 deleted file mode 100644 index 968b301..0000000 Binary files a/bin/dragontcp-client-armv7 and /dev/null differ diff --git a/bin/dragontcp-client-linux-amd64 b/bin/dragontcp-client-linux-amd64 deleted file mode 100644 index ff8d550..0000000 Binary files a/bin/dragontcp-client-linux-amd64 and /dev/null differ diff --git a/bin/dragontcp-server-arm64 b/bin/dragontcp-server-arm64 deleted file mode 100644 index 7309a5e..0000000 Binary files a/bin/dragontcp-server-arm64 and /dev/null differ diff --git a/bin/dragontcp-server-linux-amd64 b/bin/dragontcp-server-linux-amd64 deleted file mode 100644 index c3244da..0000000 Binary files a/bin/dragontcp-server-linux-amd64 and /dev/null differ diff --git a/bin/dragontcp-vpn-client-linux-amd64 b/bin/dragontcp-vpn-client-linux-amd64 new file mode 100644 index 0000000..93d4b22 Binary files /dev/null and b/bin/dragontcp-vpn-client-linux-amd64 differ diff --git a/bin/dragontcp-vpn-server-linux-amd64 b/bin/dragontcp-vpn-server-linux-amd64 new file mode 100644 index 0000000..2fb4ef5 Binary files /dev/null and b/bin/dragontcp-vpn-server-linux-amd64 differ diff --git a/bin/dragontcp-vpn-server-linux-arm64 b/bin/dragontcp-vpn-server-linux-arm64 new file mode 100644 index 0000000..80182f4 Binary files /dev/null and b/bin/dragontcp-vpn-server-linux-arm64 differ diff --git a/build_all.sh b/build_all.sh new file mode 100644 index 0000000..29013ca --- /dev/null +++ b/build_all.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +"$ROOT/build_core.sh" +"$ROOT/android/build_apk.sh" diff --git a/build_core.sh b/build_core.sh new file mode 100644 index 0000000..ab6436f --- /dev/null +++ b/build_core.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT/core" +mkdir -p "$ROOT/bin" "$ROOT/android/lib/arm64-v8a" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$ROOT/bin/dragontcp-vpn-server-linux-amd64" ./cmd/dragontcp-vpn-server +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o "$ROOT/bin/dragontcp-vpn-server-linux-arm64" ./cmd/dragontcp-vpn-server +CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o "$ROOT/android/lib/arm64-v8a/libdragontcp_vpn.so" ./cmd/dragontcp-vpn-client +echo "Built DragonTCP VPN server + Android core" diff --git a/core/cmd/dragontcp-vpn-client/main.go b/core/cmd/dragontcp-vpn-client/main.go new file mode 100644 index 0000000..23d3922 --- /dev/null +++ b/core/cmd/dragontcp-vpn-client/main.go @@ -0,0 +1,513 @@ +package main + +import ( + "crypto/rand" + "errors" + "flag" + "fmt" + "io" + "net" + "net/netip" + "os" + "os/signal" + "strconv" + "sync" + "sync/atomic" + "syscall" + "time" + + "dragontcpvpn/internal/protocol" +) + +var requestCounter atomic.Uint32 + +type txnLane struct { + mu sync.Mutex + serverAddr string + timeout time.Duration + reconnectEvery int + conn net.Conn + count int + closed bool +} + +func newTxnLane(addr string, timeout time.Duration, reconnectEvery int) *txnLane { + return &txnLane{serverAddr: addr, timeout: timeout, reconnectEvery: reconnectEvery} +} +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} + c, err := d.Dial("tcp", l.serverAddr) + if err != nil { + return err + } + protocol.TuneTCP(c) + l.conn = c + return nil +} +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 = 3 * time.Second + } + _ = l.conn.SetDeadline(time.Now().Add(timeout)) + id := requestCounter.Add(1) + if err := protocol.WriteRequestFrame(l.conn, id, payload); err != nil { + l.closeLocked() + return nil, err + } + rid, resp, err := protocol.ReadResponseFrame(l.conn) + if err != nil { + l.closeLocked() + return nil, err + } + if rid != id { + l.closeLocked() + return nil, errors.New("request ID mismatch") + } + l.count++ + _ = l.conn.SetDeadline(time.Time{}) + return resp, nil +} +func doControl(l *txnLane, payload []byte) ([]byte, error) { + var last error + for i := 0; i < 6; i++ { + r, e := l.Do(payload) + if e == nil { + return r, nil + } + last = e + time.Sleep(time.Duration(i+1) * 50 * time.Millisecond) + } + return nil, last +} + +type adaptiveSizer struct { + mu sync.Mutex + name string + current, min, max int + successes int + growAfter int + log bool +} + +func newSizer(name string, start, min, max, growAfter int, log bool) *adaptiveSizer { + if min < 32 { + min = 32 + } + if max > protocol.VPNMaxFragment { + max = protocol.VPNMaxFragment + } + if max < min { + max = min + } + if start < min { + start = min + } + if start > max { + start = max + } + if growAfter < 1 { + growAfter = 32 + } + return &adaptiveSizer{name: name, current: start, min: min, max: max, growAfter: growAfter, log: log} +} +func (s *adaptiveSizer) Current() int { s.mu.Lock(); v := s.current; s.mu.Unlock(); return v } +func (s *adaptiveSizer) Failure(actual int) { + s.mu.Lock() + defer s.mu.Unlock() + old := s.current + s.successes = 0 + basis := actual + if basis <= 0 || basis > old { + basis = old + } + next := basis / 2 + if next < s.min { + next = s.min + } + if next >= old && old > s.min { + next = old / 2 + if next < s.min { + next = s.min + } + } + if next < old { + s.current = next + if s.log { + fmt.Printf("adaptive %s chunk: %d -> %d after transport failure (record=%d)\n", s.name, old, next, actual) + } + } +} +func (s *adaptiveSizer) Success(actual int, full bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.current >= s.max || !full { + return + } + s.successes++ + if s.successes < s.growAfter { + return + } + s.successes = 0 + old := s.current + step := old / 4 + if step < 32 { + step = 32 + } + next := old + step + if next > s.max { + next = s.max + } + if next > old { + s.current = next + if s.log { + fmt.Printf("adaptive %s chunk: %d -> %d after stable success\n", s.name, old, next) + } + } +} + +func receiveTunFD(path string, timeout time.Duration) (*os.File, error) { + _ = os.Remove(path) + addr := &net.UnixAddr{Name: path, Net: "unix"} + ln, err := net.ListenUnix("unix", addr) + if err != nil { + return nil, err + } + defer func() { ln.Close(); os.Remove(path) }() + _ = os.Chmod(path, 0600) + fmt.Printf("TUNFD READY %s\n", path) + _ = ln.SetDeadline(time.Now().Add(timeout)) + c, err := ln.AcceptUnix() + if err != nil { + return nil, err + } + defer c.Close() + buf := make([]byte, 1) + oob := make([]byte, 128) + n, oobn, _, _, err := c.ReadMsgUnix(buf, oob) + if err != nil { + return nil, err + } + if n < 1 { + return nil, errors.New("missing TUN fd marker") + } + msgs, err := syscall.ParseSocketControlMessage(oob[:oobn]) + if err != nil { + return nil, err + } + for _, m := range msgs { + fds, e := syscall.ParseUnixRights(&m) + if e == nil && len(fds) > 0 { + return os.NewFile(uintptr(fds[0]), "android-tun"), nil + } + } + return nil, errors.New("TUN file descriptor was not received") +} + +func randomSID() (protocol.VPNSessionID, error) { + var sid protocol.VPNSessionID + _, err := io.ReadFull(rand.Reader, sid[:]) + return sid, err +} + +type vpnClient struct { + tun *os.File + sid protocol.VPNSessionID + serverAddr string + token string + ipv4, ipv6 netip.Addr + mtu int + timeout time.Duration + reconnectEvery int + upSizer, downSizer *adaptiveSizer + control, upload, download *txnLane + upPackets, downPackets, upBytes, downBytes atomic.Uint64 + stopped chan struct{} + stopOnce sync.Once +} + +func newVPNClient(tun *os.File, addr, token string, v4, v6 netip.Addr, mtu, start, min, max, growAfter, reconnectEvery int, timeout time.Duration, adaptLog bool) (*vpnClient, error) { + sid, err := randomSID() + if err != nil { + return nil, err + } + return &vpnClient{tun: tun, sid: sid, serverAddr: addr, token: token, ipv4: v4, ipv6: v6, mtu: mtu, timeout: timeout, reconnectEvery: reconnectEvery, + upSizer: newSizer("upload", start, min, max, growAfter, adaptLog), downSizer: newSizer("download", start, min, max, growAfter, adaptLog), + control: newTxnLane(addr, timeout, reconnectEvery), upload: newTxnLane(addr, timeout, reconnectEvery), download: newTxnLane(addr, timeout, reconnectEvery), stopped: make(chan struct{})}, nil +} +func (v *vpnClient) open() error { + req, err := protocol.BuildVPNOpen(v.sid, v.token, v.ipv4, v.ipv6, v.mtu) + if err != nil { + return err + } + resp, err := doControl(v.control, req) + if err != nil { + return err + } + max, err := protocol.ParseVPNOpened(resp) + if err != nil { + return err + } + if max < v.upSizer.max { + v.upSizer.max = max + if v.upSizer.current > max { + v.upSizer.current = max + } + } + if max < v.downSizer.max { + v.downSizer.max = max + if v.downSizer.current > max { + v.downSizer.current = max + } + } + fmt.Printf("VPN SESSION OPEN ipv4=%s ipv6=%s mtu=%d server_chunk_max=%d\n", v.ipv4, v.ipv6, v.mtu, max) + return nil +} +func (v *vpnClient) close() { + v.stopOnce.Do(func() { + close(v.stopped) + if p, err := protocol.BuildVPNClose(v.sid), error(nil); err == nil { + _, _ = v.control.Do(p) + } + v.control.Close() + v.upload.Close() + v.download.Close() + _ = v.tun.Close() + }) +} + +func (v *vpnClient) uploadLoop(errs chan<- error) { + buf := make([]byte, 65535) + var seq uint32 + for { + n, err := v.tun.Read(buf) + if err != nil { + errs <- err + return + } + if n < 1 { + continue + } + packet := append([]byte(nil), buf[:n]...) + if n > 65535 { + continue + } + offset := 0 + for offset < n { + limit := v.upSizer.Current() + size := n - offset + if size > limit { + size = limit + } + req, e := protocol.BuildVPNPush(v.sid, seq, offset, n, packet[offset:offset+size]) + if e != nil { + errs <- e + return + } + resp, e := v.upload.Do(req) + if e != nil { + v.upSizer.Failure(size) + continue + } + rseq, accepted, e := protocol.ParseVPNAck(resp) + if e != nil { + errs <- e + return + } + if rseq != seq || accepted < offset || accepted > n { + errs <- errors.New("bad server upload ACK") + return + } + fullRecord := size == limit + v.upSizer.Success(size, fullRecord) + offset = accepted + } + v.upPackets.Add(1) + v.upBytes.Add(uint64(n)) + seq++ + } +} + +func (v *vpnClient) downloadLoop(errs chan<- error) { + var want uint32 + ack := protocol.VPNNoAck + offset := 0 + var packet []byte + total := 0 + for { + limit := v.downSizer.Current() + req, e := protocol.BuildVPNPull(v.sid, ack, want, offset, limit) + if e != nil { + errs <- e + return + } + resp, e := v.download.Do(req) + if e != nil { + v.downSizer.Failure(limit) + continue + } + seq, roff, rtotal, data, wait, e := protocol.ParseVPNData(resp) + if e != nil { + errs <- e + return + } + if wait { + continue + } + if seq != want || roff != offset || rtotal < 1 || rtotal > 65535 { + errs <- errors.New("bad server download sequence") + return + } + if offset == 0 { + total = rtotal + packet = make([]byte, 0, total) + } else if rtotal != total { + errs <- errors.New("download packet size changed") + return + } + packet = append(packet, data...) + offset += len(data) + v.downSizer.Success(len(data), len(data) == limit) + if offset < total { + continue + } + if offset != total { + errs <- errors.New("download packet overflow") + return + } + n, e := v.tun.Write(packet) + if e != nil { + errs <- e + return + } + if n != len(packet) { + errs <- io.ErrShortWrite + return + } + v.downPackets.Add(1) + v.downBytes.Add(uint64(n)) + ack = want + want++ + offset = 0 + packet = nil + total = 0 + } +} + +func (v *vpnClient) run() error { + if err := v.open(); err != nil { + return err + } + fmt.Println("VPN READY") + errs := make(chan error, 2) + go v.uploadLoop(errs) + go v.downloadLoop(errs) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case err := <-errs: + return err + case <-ticker.C: + fmt.Printf("STATS up_packets=%d down_packets=%d up_bytes=%d down_bytes=%d upload_chunk=%d download_chunk=%d pollers=1\n", v.upPackets.Load(), v.downPackets.Load(), v.upBytes.Load(), v.downBytes.Load(), v.upSizer.Current(), v.downSizer.Current()) + case <-v.stopped: + return nil + } + } +} + +func main() { + serverHost := flag.String("server-host", "", "DragonTCP VPN server host/IP") + serverPort := flag.Int("server-port", 53, "DragonTCP VPN server TCP port") + token := flag.String("token", "change-this-token", "shared token") + tunFDSocket := flag.String("tun-fd-socket", "", "Unix socket path used by Android to pass the VpnService TUN fd") + tunFD := flag.Int("tun-fd", -1, "existing TUN fd for testing/non-Android use") + ipv4Text := flag.String("vpn-ipv4", "10.123.0.2", "client VPN IPv4 address") + ipv6Text := flag.String("vpn-ipv6", "fd7a:4472:6167:6f6e::2", "client VPN IPv6 address") + mtu := flag.Int("vpn-mtu", 1280, "VPN interface MTU") + chunkMax := flag.Int("chunk-max", 65535, "maximum adaptive record bytes") + chunkMin := flag.Int("chunk-min", 32, "minimum adaptive record bytes") + chunkStart := flag.Int("chunk-start", 65535, "starting record bytes; app sets this equal to max") + growAfter := flag.Int("chunk-grow-after", 64, "full successful records before increasing chunk size") + timeout := flag.Duration("chunk-timeout", 2*time.Second, "framed transaction timeout") + reconnectEvery := flag.Int("chunk-reconnect-every", 32, "reconnect a TCP/53 lane after this many transactions; 0 keeps it open") + adaptLog := flag.Bool("chunk-adapt-log", false, "log adaptive chunk changes") + flag.Parse() + if *serverHost == "" { + fmt.Fprintln(os.Stderr, "--server-host is required") + os.Exit(2) + } + if *serverPort < 1 || *serverPort > 65535 { + fmt.Fprintln(os.Stderr, "invalid server port") + os.Exit(2) + } + if *chunkMin < 32 || *chunkMax > protocol.VPNMaxFragment || *chunkMin > *chunkMax { + fmt.Fprintf(os.Stderr, "chunks must satisfy 32 <= min <= max <= %d\n", protocol.VPNMaxFragment) + os.Exit(2) + } + if *chunkStart < *chunkMin { + *chunkStart = *chunkMin + } + if *chunkStart > *chunkMax { + *chunkStart = *chunkMax + } + v4, err := netip.ParseAddr(*ipv4Text) + if err != nil || !v4.Is4() { + fmt.Fprintln(os.Stderr, "invalid --vpn-ipv4") + os.Exit(2) + } + v6, err := netip.ParseAddr(*ipv6Text) + if err != nil || !v6.Is6() { + fmt.Fprintln(os.Stderr, "invalid --vpn-ipv6") + os.Exit(2) + } + var tun *os.File + if *tunFD >= 0 { + tun = os.NewFile(uintptr(*tunFD), "tun") + } else { + if *tunFDSocket == "" { + fmt.Fprintln(os.Stderr, "--tun-fd-socket is required on Android") + os.Exit(2) + } + tun, err = receiveTunFD(*tunFDSocket, 10*time.Second) + if err != nil { + fmt.Fprintln(os.Stderr, "receive TUN fd:", err) + os.Exit(1) + } + } + addr := net.JoinHostPort(*serverHost, strconv.Itoa(*serverPort)) + client, err := newVPNClient(tun, addr, *token, v4, v6, *mtu, *chunkStart, *chunkMin, *chunkMax, *growAfter, *reconnectEvery, *timeout, *adaptLog) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + go func() { <-sig; client.close() }() + if err := client.run(); err != nil && !errors.Is(err, os.ErrClosed) && !errors.Is(err, net.ErrClosed) { + fmt.Fprintln(os.Stderr, "VPN stopped:", err) + client.close() + os.Exit(1) + } + client.close() +} diff --git a/core/cmd/dragontcp-vpn-server/main.go b/core/cmd/dragontcp-vpn-server/main.go new file mode 100644 index 0000000..95e6605 --- /dev/null +++ b/core/cmd/dragontcp-vpn-server/main.go @@ -0,0 +1,743 @@ +package main + +import ( + "crypto/subtle" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "net" + "net/netip" + "os" + "os/exec" + "os/signal" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + "unsafe" + + "dragontcpvpn/internal/protocol" +) + +const ( + defaultVPNv4Prefix = "10.123.0.0/16" + defaultVPNv6Prefix = "fd7a:4472:6167:6f6e::/64" +) + +type debugStats struct { + enabled bool + packets bool + started time.Time + activeConns atomic.Int64 + activeSessions atomic.Int64 + upPackets atomic.Uint64 + downPackets atomic.Uint64 + upBytes atomic.Uint64 + downBytes atomic.Uint64 + dropped atomic.Uint64 + errors atomic.Uint64 +} + +func (d *debugStats) logf(format string, args ...any) { + if d != nil && d.enabled { + fmt.Printf("[DEBUG] "+format+"\n", args...) + } +} +func (d *debugStats) packetf(format string, args ...any) { + if d != nil && d.packets { + fmt.Printf("[PACKET] "+format+"\n", args...) + } +} +func (d *debugStats) errorf(format string, args ...any) { + if d != nil { + d.errors.Add(1) + if d.enabled { + fmt.Printf("[ERROR] "+format+"\n", args...) + } + } +} + +func tokenEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +type vpnSession struct { + sid protocol.VPNSessionID + ipv4 netip.Addr + ipv6 netip.Addr + mtu int + maxChunk int + maxPackets int + manager *vpnManager + + mu sync.Mutex + notify chan struct{} + packets map[uint32][]byte + nextDown uint32 + closed bool + lastSeen time.Time + + upMu sync.Mutex + expectedUp uint32 + currentSeq uint32 + currentTotal int + currentBuf []byte + haveCurrent bool + lastComplete uint32 + lastCompleteTotal int + haveLastComplete bool +} + +func newVPNSession(m *vpnManager, sid protocol.VPNSessionID, v4, v6 netip.Addr, mtu, maxChunk, maxPackets int) *vpnSession { + return &vpnSession{ + sid: sid, ipv4: v4, ipv6: v6, mtu: mtu, maxChunk: maxChunk, maxPackets: maxPackets, + manager: m, notify: make(chan struct{}), packets: make(map[uint32][]byte, maxPackets), lastSeen: time.Now(), + } +} + +func (s *vpnSession) signalLocked() { + close(s.notify) + s.notify = make(chan struct{}) +} +func (s *vpnSession) touchLocked() { s.lastSeen = time.Now() } +func (s *vpnSession) touch() { s.mu.Lock(); s.touchLocked(); s.mu.Unlock() } + +func (s *vpnSession) enqueue(packet []byte) bool { + if len(packet) == 0 || len(packet) > 65535 { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return false + } + if len(s.packets) >= s.maxPackets { + if s.manager.debug != nil { + s.manager.debug.dropped.Add(1) + } + return false + } + seq := s.nextDown + s.nextDown++ + s.packets[seq] = append([]byte(nil), packet...) + s.touchLocked() + s.signalLocked() + if s.manager.debug != nil { + s.manager.debug.downPackets.Add(1) + s.manager.debug.downBytes.Add(uint64(len(packet))) + s.manager.debug.packetf("QUEUE sid=%s seq=%d bytes=%d", shortSID(s.sid), seq, len(packet)) + } + return true +} + +func (s *vpnSession) push(seq uint32, offset, total int, data []byte) (int, error) { + s.upMu.Lock() + defer s.upMu.Unlock() + if total < 1 || total > 65535 || len(data) < 1 || len(data) > s.maxChunk || offset < 0 || offset+len(data) > total { + return 0, errors.New("invalid packet fragment") + } + + if s.haveLastComplete && seq == s.lastComplete { + s.touch() + return s.lastCompleteTotal, nil + } + if seq < s.expectedUp { + return 0, fmt.Errorf("old upload sequence %d", seq) + } + if seq > s.expectedUp { + return 0, fmt.Errorf("upload sequence %d expected %d", seq, s.expectedUp) + } + + if !s.haveCurrent { + if offset != 0 { + return 0, errors.New("first fragment offset must be zero") + } + s.haveCurrent = true + s.currentSeq = seq + s.currentTotal = total + s.currentBuf = make([]byte, 0, total) + } + if s.currentSeq != seq || s.currentTotal != total { + return 0, errors.New("packet fragment metadata changed") + } + + // Idempotent retry: if this exact offset was already accepted, acknowledge + // the existing bytes instead of appending duplicate data. + if offset < len(s.currentBuf) { + end := offset + len(data) + if end <= len(s.currentBuf) && string(s.currentBuf[offset:end]) == string(data) { + return len(s.currentBuf), nil + } + return 0, errors.New("retry fragment does not match accepted data") + } + if offset != len(s.currentBuf) { + return 0, fmt.Errorf("fragment offset %d expected %d", offset, len(s.currentBuf)) + } + + s.currentBuf = append(s.currentBuf, data...) + accepted := len(s.currentBuf) + if accepted < total { + s.touch() + return accepted, nil + } + + packet := append([]byte(nil), s.currentBuf...) + s.haveCurrent = false + s.currentBuf = nil + + if err := s.manager.acceptClientPacket(s, packet); err != nil { + return 0, err + } + + s.lastComplete = seq + s.lastCompleteTotal = total + s.haveLastComplete = true + s.expectedUp++ + s.touch() + if s.manager.debug != nil { + s.manager.debug.upPackets.Add(1) + s.manager.debug.upBytes.Add(uint64(len(packet))) + s.manager.debug.packetf("UP sid=%s seq=%d bytes=%d", shortSID(s.sid), seq, len(packet)) + } + return accepted, nil +} + +func (s *vpnSession) pull(ack, want uint32, offset, limit int, wait time.Duration) ([]byte, int, bool, error) { + if offset < 0 || limit < 1 || limit > s.maxChunk { + return nil, 0, false, errors.New("invalid pull") + } + timer := time.NewTimer(wait) + defer timer.Stop() + for { + s.mu.Lock() + s.touchLocked() + if ack != protocol.VPNNoAck { + for seq := range s.packets { + if seq <= ack { + delete(s.packets, seq) + } + } + } + if packet, ok := s.packets[want]; ok { + if offset >= len(packet) { + s.mu.Unlock() + return nil, len(packet), false, errors.New("pull offset beyond packet") + } + end := offset + limit + if end > len(packet) { + end = len(packet) + } + out := append([]byte(nil), packet[offset:end]...) + total := len(packet) + s.mu.Unlock() + return out, total, false, nil + } + if s.closed { + s.mu.Unlock() + return nil, 0, false, net.ErrClosed + } + ch := s.notify + s.mu.Unlock() + select { + case <-ch: + case <-timer.C: + return nil, 0, true, nil + } + } +} + +func (s *vpnSession) close() { + s.mu.Lock() + if !s.closed { + s.closed = true + s.signalLocked() + } + s.mu.Unlock() +} + +type vpnManager struct { + mu sync.RWMutex + sessions map[protocol.VPNSessionID]*vpnSession + byIPv4 map[netip.Addr]*vpnSession + byIPv6 map[netip.Addr]*vpnSession + maxChunk int + maxPackets int + pollWait time.Duration + timeout time.Duration + tun *os.File + tunWriteMu sync.Mutex + mockEcho bool + allowPrivate bool + debug *debugStats + v4Prefix netip.Prefix + v6Prefix netip.Prefix +} + +func newVPNManager(tun *os.File, mockEcho bool, maxChunk, maxPackets int, pollWait, timeout time.Duration, allowPrivate bool, debug *debugStats) *vpnManager { + v4p := netip.MustParsePrefix(defaultVPNv4Prefix) + v6p := netip.MustParsePrefix(defaultVPNv6Prefix) + m := &vpnManager{ + sessions: make(map[protocol.VPNSessionID]*vpnSession), byIPv4: make(map[netip.Addr]*vpnSession), byIPv6: make(map[netip.Addr]*vpnSession), + maxChunk: maxChunk, maxPackets: maxPackets, pollWait: pollWait, timeout: timeout, tun: tun, mockEcho: mockEcho, allowPrivate: allowPrivate, debug: debug, + v4Prefix: v4p, v6Prefix: v6p, + } + if tun != nil { + go m.tunReadLoop() + } + go m.cleanupLoop() + return m +} + +func (m *vpnManager) addOrGet(sid protocol.VPNSessionID, v4, v6 netip.Addr, mtu int) (*vpnSession, error) { + if !m.v4Prefix.Contains(v4) || v4 == netip.MustParseAddr("10.123.0.1") { + return nil, errors.New("client IPv4 outside DragonTCP subnet") + } + if !m.v6Prefix.Contains(v6) || v6 == netip.MustParseAddr("fd7a:4472:6167:6f6e::1") { + return nil, errors.New("client IPv6 outside DragonTCP subnet") + } + if mtu < 576 || mtu > 9000 { + return nil, errors.New("invalid client MTU") + } + + m.mu.Lock() + defer m.mu.Unlock() + if old := m.sessions[sid]; old != nil { + if old.ipv4 != v4 || old.ipv6 != v6 { + return nil, errors.New("session address mismatch") + } + old.touch() + return old, nil + } + if m.byIPv4[v4] != nil || m.byIPv6[v6] != nil { + return nil, errors.New("client VPN address already in use") + } + s := newVPNSession(m, sid, v4, v6, mtu, m.maxChunk, m.maxPackets) + m.sessions[sid] = s + m.byIPv4[v4] = s + m.byIPv6[v6] = s + if m.debug != nil { + m.debug.activeSessions.Add(1) + m.debug.logf("SESSION OPEN sid=%s ipv4=%s ipv6=%s mtu=%d", shortSID(sid), v4, v6, mtu) + } + return s, nil +} + +func (m *vpnManager) get(sid protocol.VPNSessionID) *vpnSession { + m.mu.RLock() + s := m.sessions[sid] + m.mu.RUnlock() + return s +} +func (m *vpnManager) remove(sid protocol.VPNSessionID) { + m.mu.Lock() + s := m.sessions[sid] + if s != nil { + delete(m.sessions, sid) + delete(m.byIPv4, s.ipv4) + delete(m.byIPv6, s.ipv6) + } + m.mu.Unlock() + if s != nil { + s.close() + if m.debug != nil { + m.debug.activeSessions.Add(-1) + m.debug.logf("SESSION CLOSE sid=%s", shortSID(sid)) + } + } +} + +func (m *vpnManager) cleanupLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + cutoff := time.Now().Add(-m.timeout) + var stale []protocol.VPNSessionID + m.mu.RLock() + for sid, s := range m.sessions { + s.mu.Lock() + last := s.lastSeen + closed := s.closed + s.mu.Unlock() + if closed || last.Before(cutoff) { + stale = append(stale, sid) + } + } + m.mu.RUnlock() + for _, sid := range stale { + m.remove(sid) + } + } +} + +func packetAddresses(packet []byte) (src, dst netip.Addr, err error) { + if len(packet) < 1 { + return src, dst, errors.New("empty IP packet") + } + switch packet[0] >> 4 { + case 4: + if len(packet) < 20 { + return src, dst, errors.New("short IPv4 packet") + } + total := int(packet[2])<<8 | int(packet[3]) + if total < 20 || total > len(packet) { + return src, dst, errors.New("invalid IPv4 total length") + } + var a, b [4]byte + copy(a[:], packet[12:16]) + copy(b[:], packet[16:20]) + return netip.AddrFrom4(a), netip.AddrFrom4(b), nil + case 6: + if len(packet) < 40 { + return src, dst, errors.New("short IPv6 packet") + } + total := 40 + (int(packet[4])<<8 | int(packet[5])) + if total > len(packet) { + return src, dst, errors.New("invalid IPv6 payload length") + } + var a, b [16]byte + copy(a[:], packet[8:24]) + copy(b[:], packet[24:40]) + return netip.AddrFrom16(a), netip.AddrFrom16(b), nil + default: + return src, dst, errors.New("unsupported IP version") + } +} + +func destinationAllowed(dst netip.Addr, allowPrivate bool) bool { + if dst.IsUnspecified() || dst.IsMulticast() { + return false + } + if allowPrivate { + return true + } + if dst.IsLoopback() || dst.IsLinkLocalUnicast() || dst.IsPrivate() { + return false + } + return true +} + +func (m *vpnManager) acceptClientPacket(s *vpnSession, packet []byte) error { + src, dst, err := packetAddresses(packet) + if err != nil { + return err + } + if src != s.ipv4 && src != s.ipv6 { + return fmt.Errorf("source %s does not match session address", src) + } + if !destinationAllowed(dst, m.allowPrivate) { + return fmt.Errorf("destination %s is blocked; use --allow-private to permit it", dst) + } + if m.mockEcho { + s.enqueue(packet) + return nil + } + if m.tun == nil { + return errors.New("VPN TUN is unavailable") + } + m.tunWriteMu.Lock() + n, err := m.tun.Write(packet) + m.tunWriteMu.Unlock() + if err != nil { + return err + } + if n != len(packet) { + return io.ErrShortWrite + } + return nil +} + +func (m *vpnManager) tunReadLoop() { + buf := make([]byte, 65535) + for { + n, err := m.tun.Read(buf) + if err != nil { + if m.debug != nil { + m.debug.errorf("TUN read: %v", err) + } + return + } + if n < 1 { + continue + } + packet := append([]byte(nil), buf[:n]...) + _, dst, e := packetAddresses(packet) + if e != nil { + continue + } + m.mu.RLock() + var s *vpnSession + if dst.Is4() { + s = m.byIPv4[dst] + } else { + s = m.byIPv6[dst] + } + m.mu.RUnlock() + if s != nil { + s.enqueue(packet) + } + } +} + +func shortSID(sid protocol.VPNSessionID) string { return hex.EncodeToString(sid[:4]) } + +func processVPN(conn net.Conn, requestID uint32, payload []byte, token string, m *vpnManager) error { + switch payload[0] { + case protocol.VPNCmdOpen: + sid, tok, v4, v6, mtu, err := protocol.ParseVPNOpen(payload) + if err != nil { + return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error())) + } + if !tokenEqual(tok, token) { + return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError("authentication failed")) + } + _, err = m.addOrGet(sid, v4, v6, mtu) + if err != nil { + return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error())) + } + return protocol.WriteResponseFrame(conn, requestID, protocol.BuildVPNOpened(m.maxChunk)) + case protocol.VPNCmdPush: + sid, seq, offset, total, data, err := protocol.ParseVPNPush(payload) + if err != nil { + return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error())) + } + s := m.get(sid) + if s == nil { + return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError("unknown VPN session")) + } + accepted, err := s.push(seq, offset, total, data) + if err != nil { + return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error())) + } + return protocol.WriteResponseFrame(conn, requestID, protocol.BuildVPNAck(seq, accepted)) + case protocol.VPNCmdPull: + sid, ack, want, offset, limit, err := protocol.ParseVPNPull(payload) + if err != nil { + return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error())) + } + s := m.get(sid) + if s == nil { + return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError("unknown VPN session")) + } + if limit > s.maxChunk { + limit = s.maxChunk + } + data, total, wait, err := s.pull(ack, want, offset, limit, m.pollWait) + if err != nil { + return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error())) + } + if wait { + return protocol.WriteResponseFrame(conn, requestID, []byte{protocol.VPNRespWait}) + } + m.debug.packetf("DOWN sid=%s seq=%d offset=%d bytes=%d total=%d", shortSID(sid), want, offset, len(data), total) + return protocol.WriteResponseFrame(conn, requestID, protocol.BuildVPNData(want, offset, total, data)) + case protocol.VPNCmdClose: + sid, err := protocol.ParseVPNClose(payload) + if err != nil { + return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError(err.Error())) + } + m.remove(sid) + return protocol.WriteResponseFrame(conn, requestID, []byte{protocol.VPNRespClosed}) + default: + return protocol.WriteResponseFrame(conn, requestID, protocol.VPNError("unknown VPN command")) + } +} + +func handleConn(conn net.Conn, token string, m *vpnManager, slots chan struct{}, debug *debugStats) { + defer func() { <-slots; debug.activeConns.Add(-1); _ = conn.Close() }() + protocol.TuneTCP(conn) + for { + _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) + requestID, _, payload, err := protocol.ReadRequestFrame(conn) + if err != nil { + if !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) { + debug.errorf("peer=%v read: %v", conn.RemoteAddr(), err) + } + return + } + if !protocol.IsVPNCommand(payload) { + _ = protocol.WriteResponseFrame(conn, requestID, protocol.VPNError("this binary accepts DragonTCP VPN packet commands only")) + continue + } + if err := processVPN(conn, requestID, payload, token, m); err != nil { + return + } + } +} + +// Linux TUN setup. +type ifreq struct { + Name [16]byte + Flags uint16 + _ [22]byte +} + +const tunSetIFF = 0x400454ca +const iffTun = 0x0001 +const iffNoPI = 0x1000 + +func openTun(name string) (*os.File, error) { + fd, err := syscall.Open("/dev/net/tun", syscall.O_RDWR|syscall.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + var req ifreq + copy(req.Name[:], []byte(name)) + req.Flags = iffTun | iffNoPI + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(tunSetIFF), uintptr(unsafe.Pointer(&req))) + if errno != 0 { + syscall.Close(fd) + return nil, errno + } + return os.NewFile(uintptr(fd), name), nil +} + +func run(cmd string, args ...string) error { + c := exec.Command(cmd, args...) + out, err := c.CombinedOutput() + if err != nil { + return fmt.Errorf("%s %s: %v: %s", cmd, strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return nil +} +func runOptional(debug *debugStats, cmd string, args ...string) { + if err := run(cmd, args...); err != nil { + debug.logf("optional command failed: %v", err) + } +} +func ensureRule(debug *debugStats, binary string, argsCheck, argsAdd []string) { + if err := exec.Command(binary, argsCheck...).Run(); err == nil { + return + } + if err := run(binary, argsAdd...); err != nil { + debug.logf("NAT rule warning: %v", err) + } +} +func setupLinuxVPN(tunName string, mtu int, autoNAT bool, debug *debugStats) (*os.File, error) { + tun, err := openTun(tunName) + if err != nil { + return nil, fmt.Errorf("open /dev/net/tun: %w", err) + } + fail := func(e error) (*os.File, error) { tun.Close(); return nil, e } + if err := run("ip", "link", "set", "dev", tunName, "mtu", strconv.Itoa(mtu)); err != nil { + return fail(err) + } + if err := run("ip", "addr", "replace", "10.123.0.1/16", "dev", tunName); err != nil { + return fail(err) + } + // IPv6 may be disabled on some hosts; report clearly instead of silently bypassing it. + if err := run("ip", "-6", "addr", "replace", "fd7a:4472:6167:6f6e::1/64", "dev", tunName); err != nil { + return fail(err) + } + if err := run("ip", "link", "set", "dev", tunName, "up"); err != nil { + return fail(err) + } + if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1\n"), 0644); err != nil { + return fail(fmt.Errorf("enable IPv4 forwarding: %w", err)) + } + if err := os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1\n"), 0644); err != nil { + return fail(fmt.Errorf("enable IPv6 forwarding: %w", err)) + } + if autoNAT { + if _, err := exec.LookPath("iptables"); err != nil { + return fail(errors.New("iptables not found; install iptables or start with --auto-nat=false and configure NAT yourself")) + } + ensureRule(debug, "iptables", []string{"-t", "nat", "-C", "POSTROUTING", "-s", "10.123.0.0/16", "-j", "MASQUERADE"}, []string{"-t", "nat", "-A", "POSTROUTING", "-s", "10.123.0.0/16", "-j", "MASQUERADE"}) + ensureRule(debug, "iptables", []string{"-C", "FORWARD", "-i", tunName, "-j", "ACCEPT"}, []string{"-A", "FORWARD", "-i", tunName, "-j", "ACCEPT"}) + ensureRule(debug, "iptables", []string{"-C", "FORWARD", "-o", tunName, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}, []string{"-A", "FORWARD", "-o", tunName, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}) + if _, err := exec.LookPath("ip6tables"); err == nil { + ensureRule(debug, "ip6tables", []string{"-t", "nat", "-C", "POSTROUTING", "-s", "fd7a:4472:6167:6f6e::/64", "-j", "MASQUERADE"}, []string{"-t", "nat", "-A", "POSTROUTING", "-s", "fd7a:4472:6167:6f6e::/64", "-j", "MASQUERADE"}) + ensureRule(debug, "ip6tables", []string{"-C", "FORWARD", "-i", tunName, "-j", "ACCEPT"}, []string{"-A", "FORWARD", "-i", tunName, "-j", "ACCEPT"}) + ensureRule(debug, "ip6tables", []string{"-C", "FORWARD", "-o", tunName, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}, []string{"-A", "FORWARD", "-o", tunName, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"}) + } else { + debug.logf("WARNING: ip6tables not found; IPv6 Internet access needs manual routing/NAT") + } + } + return tun, nil +} + +func main() { + host := flag.String("host", "0.0.0.0", "listen host") + port := flag.Int("port", 53, "listen TCP port") + token := flag.String("token", "change-this-token", "shared token") + maxConnections := flag.Int("max-connections", 20000, "maximum simultaneous TCP/53 connections") + maxChunk := flag.Int("chunk-max", 65535, "maximum VPN fragment payload bytes (32-65535)") + maxPackets := flag.Int("vpn-buffered-packets", 2048, "maximum queued return IP packets per client") + pollWait := flag.Duration("poll-wait", 100*time.Millisecond, "long-poll wait for a return packet") + sessionTimeout := flag.Duration("session-timeout", 5*time.Minute, "idle VPN session timeout") + tunName := flag.String("tun", "dragontcp0", "Linux TUN interface name") + mtu := flag.Int("mtu", 1280, "server TUN MTU") + autoNAT := flag.Bool("auto-nat", true, "configure IPv4/IPv6 forwarding and iptables MASQUERADE") + allowPrivate := flag.Bool("allow-private", false, "allow VPN clients to access private/link-local destinations") + mockEcho := flag.Bool("mock-echo", false, "test mode: echo client IP packets back instead of using Linux TUN/NAT") + debugOn := flag.Bool("debug", false, "debug sessions and statistics") + debugPackets := flag.Bool("debug-packets", false, "very verbose per-IP-packet logging") + statsEvery := flag.Duration("debug-stats-interval", 10*time.Second, "debug statistics interval; 0 disables") + flag.Parse() + if *maxChunk < 32 || *maxChunk > protocol.VPNMaxFragment { + fmt.Fprintf(os.Stderr, "--chunk-max must be 32-%d\n", protocol.VPNMaxFragment) + os.Exit(2) + } + if *mtu < 576 || *mtu > 9000 { + fmt.Fprintln(os.Stderr, "--mtu must be 576-9000") + os.Exit(2) + } + debug := &debugStats{enabled: *debugOn, packets: *debugPackets, started: time.Now()} + var tun *os.File + var err error + if !*mockEcho { + tun, err = setupLinuxVPN(*tunName, *mtu, *autoNAT, debug) + if err != nil { + fmt.Fprintln(os.Stderr, "VPN setup failed:", err) + os.Exit(1) + } + defer tun.Close() + } + manager := newVPNManager(tun, *mockEcho, *maxChunk, *maxPackets, *pollWait, *sessionTimeout, *allowPrivate, debug) + addr := net.JoinHostPort(*host, strconv.Itoa(*port)) + ln, err := net.Listen("tcp", addr) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer ln.Close() + fmt.Printf("DragonTCP VPN server listening on %s\n", addr) + if *mockEcho { + fmt.Println("mode=mock-echo (no Internet forwarding)") + } else { + fmt.Printf("tun=%s mtu=%d IPv4=10.123.0.1/16 IPv6=fd7a:4472:6167:6f6e::1/64 auto_nat=%t\n", *tunName, *mtu, *autoNAT) + } + fmt.Printf("chunk_max=%d poll_wait=%s buffered_packets=%d\n", *maxChunk, pollWait.String(), *maxPackets) + if debug.enabled && *statsEvery > 0 { + go func() { + t := time.NewTicker(*statsEvery) + defer t.Stop() + for range t.C { + fmt.Printf("[DEBUG] STATS uptime=%s conns=%d sessions=%d up_packets=%d down_packets=%d up_bytes=%d down_bytes=%d dropped=%d errors=%d\n", time.Since(debug.started).Round(time.Second), debug.activeConns.Load(), debug.activeSessions.Load(), debug.upPackets.Load(), debug.downPackets.Load(), debug.upBytes.Load(), debug.downBytes.Load(), debug.dropped.Load(), debug.errors.Load()) + } + }() + } + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + go func() { <-sig; fmt.Println("Stopping DragonTCP VPN server..."); ln.Close() }() + slots := make(chan struct{}, *maxConnections) + for { + conn, err := ln.Accept() + if err != nil { + break + } + select { + case slots <- struct{}{}: + debug.activeConns.Add(1) + go handleConn(conn, *token, manager, slots, debug) + default: + _ = conn.Close() + } + } +} diff --git a/core/go.mod b/core/go.mod new file mode 100644 index 0000000..5de27cd --- /dev/null +++ b/core/go.mod @@ -0,0 +1,3 @@ +module dragontcpvpn + +go 1.22 diff --git a/go/internal/protocol/protocol.go b/core/internal/protocol/protocol.go similarity index 100% rename from go/internal/protocol/protocol.go rename to core/internal/protocol/protocol.go diff --git a/core/internal/protocol/vpnproto.go b/core/internal/protocol/vpnproto.go new file mode 100644 index 0000000..6331bf0 --- /dev/null +++ b/core/internal/protocol/vpnproto.go @@ -0,0 +1,267 @@ +package protocol + +import ( + "encoding/binary" + "errors" + "fmt" + "net/netip" +) + +const ( + VPNCmdOpen byte = 0x30 + VPNCmdPush byte = 0x31 + VPNCmdPull byte = 0x32 + VPNCmdClose byte = 0x33 + + VPNRespOpened byte = 0x40 + VPNRespAck byte = 0x41 + VPNRespData byte = 0x42 + VPNRespWait byte = 0x43 + VPNRespClosed byte = 0x44 + VPNRespError byte = 0x7f + + VPNNoAck uint32 = 0xffffffff + VPNMaxFragment = 65535 +) + +type VPNSessionID [16]byte + +func VPNError(message string) []byte { + b := []byte(message) + if len(b) > 4096 { + b = b[:4096] + } + out := make([]byte, 1+len(b)) + out[0] = VPNRespError + copy(out[1:], b) + return out +} + +func ParseVPNError(payload []byte) error { + if len(payload) == 0 { + return errors.New("empty DragonTCP VPN response") + } + if payload[0] == VPNRespError { + return errors.New(string(payload[1:])) + } + return nil +} + +// OPEN request: +// cmd(1) sid(16) tokenLen(2) token(N) ipv4(4) ipv6(16) mtu(2) +func BuildVPNOpen(sid VPNSessionID, token string, ipv4, ipv6 netip.Addr, mtu int) ([]byte, error) { + if len(token) > 4096 { + return nil, errors.New("token too long") + } + if !ipv4.Is4() || !ipv6.Is6() { + return nil, errors.New("invalid VPN client addresses") + } + if mtu < 576 || mtu > 65535 { + return nil, errors.New("invalid VPN MTU") + } + out := make([]byte, 1+16+2+len(token)+4+16+2) + out[0] = VPNCmdOpen + copy(out[1:17], sid[:]) + binary.BigEndian.PutUint16(out[17:19], uint16(len(token))) + pos := 19 + copy(out[pos:pos+len(token)], token) + pos += len(token) + v4 := ipv4.As4() + copy(out[pos:pos+4], v4[:]) + pos += 4 + v6 := ipv6.As16() + copy(out[pos:pos+16], v6[:]) + pos += 16 + binary.BigEndian.PutUint16(out[pos:pos+2], uint16(mtu)) + return out, nil +} + +func ParseVPNOpen(payload []byte) (sid VPNSessionID, token string, ipv4, ipv6 netip.Addr, mtu int, err error) { + if len(payload) < 1+16+2+4+16+2 || payload[0] != VPNCmdOpen { + err = errors.New("bad VPN OPEN") + return + } + copy(sid[:], payload[1:17]) + tokenLen := int(binary.BigEndian.Uint16(payload[17:19])) + need := 1 + 16 + 2 + tokenLen + 4 + 16 + 2 + if tokenLen < 0 || len(payload) != need { + err = errors.New("bad VPN OPEN length") + return + } + pos := 19 + token = string(payload[pos : pos+tokenLen]) + pos += tokenLen + var a4 [4]byte + copy(a4[:], payload[pos:pos+4]) + ipv4 = netip.AddrFrom4(a4) + pos += 4 + var a6 [16]byte + copy(a6[:], payload[pos:pos+16]) + ipv6 = netip.AddrFrom16(a6) + pos += 16 + mtu = int(binary.BigEndian.Uint16(payload[pos : pos+2])) + return +} + +func BuildVPNOpened(maxChunk int) []byte { + if maxChunk > VPNMaxFragment { + maxChunk = VPNMaxFragment + } + if maxChunk < 1 { + maxChunk = 1 + } + out := make([]byte, 3) + out[0] = VPNRespOpened + binary.BigEndian.PutUint16(out[1:3], uint16(maxChunk)) + return out +} + +func ParseVPNOpened(payload []byte) (int, error) { + if err := ParseVPNError(payload); err != nil { + return 0, err + } + if len(payload) != 3 || payload[0] != VPNRespOpened { + return 0, errors.New("bad VPN OPENED response") + } + return int(binary.BigEndian.Uint16(payload[1:3])), nil +} + +// PUSH request: cmd(1) sid(16) seq(4) offset(2) total(2) data(N) +func BuildVPNPush(sid VPNSessionID, seq uint32, offset, total int, data []byte) ([]byte, error) { + if total < 1 || total > 65535 || offset < 0 || offset > total || len(data) < 1 || offset+len(data) > total || len(data) > VPNMaxFragment { + return nil, errors.New("invalid VPN PUSH fragment") + } + out := make([]byte, 25+len(data)) + out[0] = VPNCmdPush + copy(out[1:17], sid[:]) + binary.BigEndian.PutUint32(out[17:21], seq) + binary.BigEndian.PutUint16(out[21:23], uint16(offset)) + binary.BigEndian.PutUint16(out[23:25], uint16(total)) + copy(out[25:], data) + return out, nil +} + +func ParseVPNPush(payload []byte) (sid VPNSessionID, seq uint32, offset, total int, data []byte, err error) { + if len(payload) < 26 || payload[0] != VPNCmdPush { + err = errors.New("bad VPN PUSH") + return + } + copy(sid[:], payload[1:17]) + seq = binary.BigEndian.Uint32(payload[17:21]) + offset = int(binary.BigEndian.Uint16(payload[21:23])) + total = int(binary.BigEndian.Uint16(payload[23:25])) + data = payload[25:] + if total < 1 || offset < 0 || offset > total || len(data) < 1 || offset+len(data) > total { + err = errors.New("bad VPN PUSH fragment bounds") + } + return +} + +func BuildVPNAck(seq uint32, accepted int) []byte { + out := make([]byte, 7) + out[0] = VPNRespAck + binary.BigEndian.PutUint32(out[1:5], seq) + binary.BigEndian.PutUint16(out[5:7], uint16(accepted)) + return out +} + +func ParseVPNAck(payload []byte) (seq uint32, accepted int, err error) { + if e := ParseVPNError(payload); e != nil { + err = e + return + } + if len(payload) != 7 || payload[0] != VPNRespAck { + err = errors.New("bad VPN ACK") + return + } + seq = binary.BigEndian.Uint32(payload[1:5]) + accepted = int(binary.BigEndian.Uint16(payload[5:7])) + return +} + +// PULL request: cmd(1) sid(16) ack(4) want(4) offset(2) limit(2) +func BuildVPNPull(sid VPNSessionID, ack, want uint32, offset, limit int) ([]byte, error) { + if offset < 0 || offset > 65535 || limit < 1 || limit > VPNMaxFragment { + return nil, errors.New("invalid VPN PULL") + } + out := make([]byte, 29) + out[0] = VPNCmdPull + copy(out[1:17], sid[:]) + binary.BigEndian.PutUint32(out[17:21], ack) + binary.BigEndian.PutUint32(out[21:25], want) + binary.BigEndian.PutUint16(out[25:27], uint16(offset)) + binary.BigEndian.PutUint16(out[27:29], uint16(limit)) + return out, nil +} + +func ParseVPNPull(payload []byte) (sid VPNSessionID, ack, want uint32, offset, limit int, err error) { + if len(payload) != 29 || payload[0] != VPNCmdPull { + err = errors.New("bad VPN PULL") + return + } + copy(sid[:], payload[1:17]) + ack = binary.BigEndian.Uint32(payload[17:21]) + want = binary.BigEndian.Uint32(payload[21:25]) + offset = int(binary.BigEndian.Uint16(payload[25:27])) + limit = int(binary.BigEndian.Uint16(payload[27:29])) + if limit < 1 { + err = errors.New("bad VPN PULL limit") + } + return +} + +// DATA response: cmd(1) seq(4) offset(2) total(2) data(N) +func BuildVPNData(seq uint32, offset, total int, data []byte) []byte { + out := make([]byte, 9+len(data)) + out[0] = VPNRespData + binary.BigEndian.PutUint32(out[1:5], seq) + binary.BigEndian.PutUint16(out[5:7], uint16(offset)) + binary.BigEndian.PutUint16(out[7:9], uint16(total)) + copy(out[9:], data) + return out +} + +func ParseVPNData(payload []byte) (seq uint32, offset, total int, data []byte, wait bool, err error) { + if e := ParseVPNError(payload); e != nil { + err = e + return + } + if len(payload) == 1 && payload[0] == VPNRespWait { + wait = true + return + } + if len(payload) < 10 || payload[0] != VPNRespData { + err = fmt.Errorf("bad VPN DATA response type/length") + return + } + seq = binary.BigEndian.Uint32(payload[1:5]) + offset = int(binary.BigEndian.Uint16(payload[5:7])) + total = int(binary.BigEndian.Uint16(payload[7:9])) + data = payload[9:] + if total < 1 || offset < 0 || offset+len(data) > total || len(data) < 1 { + err = errors.New("bad VPN DATA bounds") + } + return +} + +func BuildVPNClose(sid VPNSessionID) []byte { + out := make([]byte, 17) + out[0] = VPNCmdClose + copy(out[1:17], sid[:]) + return out +} + +func ParseVPNClose(payload []byte) (sid VPNSessionID, err error) { + if len(payload) != 17 || payload[0] != VPNCmdClose { + return sid, errors.New("bad VPN CLOSE") + } + copy(sid[:], payload[1:17]) + return sid, nil +} + +func IsVPNCommand(payload []byte) bool { + if len(payload) == 0 { + return false + } + return payload[0] >= VPNCmdOpen && payload[0] <= VPNCmdClose +} diff --git a/go/internal/protocol/xor_fast32.go b/core/internal/protocol/xor_fast32.go similarity index 100% rename from go/internal/protocol/xor_fast32.go rename to core/internal/protocol/xor_fast32.go diff --git a/go/internal/protocol/xor_fast64.go b/core/internal/protocol/xor_fast64.go similarity index 100% rename from go/internal/protocol/xor_fast64.go rename to core/internal/protocol/xor_fast64.go diff --git a/go/internal/protocol/xor_generic.go b/core/internal/protocol/xor_generic.go similarity index 100% rename from go/internal/protocol/xor_generic.go rename to core/internal/protocol/xor_generic.go diff --git a/go/cmd/dragontcp-client/chunk.go b/go/cmd/dragontcp-client/chunk.go deleted file mode 100644 index add7507..0000000 --- a/go/cmd/dragontcp-client/chunk.go +++ /dev/null @@ -1,748 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "encoding/hex" - "fmt" - "io" - "net" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "dragontcp/internal/protocol" -) - -type chunkClientOptions 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 -} - -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 chunkClientOptions) *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{}) - 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 chunkClientOptions - - 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 openChunkTunnel(serverAddr, token, targetHost string, targetPort int, opts chunkClientOptions) (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", - 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", - 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 ", 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", 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) } diff --git a/go/cmd/dragontcp-client/main.go b/go/cmd/dragontcp-client/main.go deleted file mode 100644 index 5944d50..0000000 --- a/go/cmd/dragontcp-client/main.go +++ /dev/null @@ -1,478 +0,0 @@ -package main - -import ( - "bytes" - "flag" - "fmt" - "net" - "net/url" - "os" - "strconv" - "strings" - "sync/atomic" - "time" - - "dragontcp/internal/protocol" -) - -const maxHeader = 128 * 1024 - -var requestCounter atomic.Uint32 - -func readHTTPHeaders(conn net.Conn) ([]byte, []byte, error) { - buf := make([]byte, 0, 8192) - tmp := make([]byte, 8192) - - for { - n, err := conn.Read(tmp) - if n > 0 { - buf = append(buf, tmp[:n]...) - - if len(buf) > maxHeader { - return nil, nil, fmt.Errorf("HTTP headers too large") - } - - if i := bytes.Index(buf, []byte("\r\n\r\n")); i >= 0 { - end := i + 4 - return buf[:end], buf[end:], nil - } - } - - if err != nil { - return nil, nil, err - } - } -} - -func parseHostPort(authority string, defaultPort int) (string, int, error) { - authority = strings.TrimSpace(authority) - - if host, portText, err := net.SplitHostPort(authority); err == nil { - port, err := strconv.Atoi(portText) - return host, port, err - } - - // Host without port. - if strings.HasPrefix(authority, "[") && strings.HasSuffix(authority, "]") { - return strings.Trim(authority, "[]"), defaultPort, nil - } - - if strings.Count(authority, ":") == 0 { - return authority, defaultPort, nil - } - - // Bare IPv6. - if ip := net.ParseIP(authority); ip != nil { - return authority, defaultPort, nil - } - - return "", 0, fmt.Errorf("invalid authority: %s", authority) -} - -func rewritePlainHTTPRequest(header []byte) (string, int, []byte, error) { - text := string(header) - lines := strings.Split(text, "\r\n") - if len(lines) == 0 { - return "", 0, nil, fmt.Errorf("empty request") - } - - parts := strings.SplitN(lines[0], " ", 3) - if len(parts) != 3 { - return "", 0, nil, fmt.Errorf("invalid request line") - } - - method, target, version := parts[0], parts[1], parts[2] - - var ( - hostHeader string - headers []string - ) - - for _, line := range lines[1:] { - if line == "" { - continue - } - - k, v, ok := strings.Cut(line, ":") - if !ok { - continue - } - - lk := strings.ToLower(strings.TrimSpace(k)) - - if lk == "host" { - hostHeader = strings.TrimSpace(v) - } - - if lk == "connection" || - lk == "proxy-connection" || - lk == "proxy-authorization" { - continue - } - - headers = append(headers, k+": "+strings.TrimSpace(v)) - } - - u, err := url.Parse(target) - if err != nil { - return "", 0, nil, err - } - - var host string - var port int - path := target - - if u.Hostname() != "" { - if strings.ToLower(u.Scheme) != "http" { - return "", 0, nil, fmt.Errorf("unsupported plain HTTP scheme: %s", u.Scheme) - } - - host = u.Hostname() - port = 80 - - if u.Port() != "" { - port, err = strconv.Atoi(u.Port()) - if err != nil { - return "", 0, nil, err - } - } - - path = u.EscapedPath() - if path == "" { - path = "/" - } - if u.RawQuery != "" { - path += "?" + u.RawQuery - } - } else { - if hostHeader == "" { - return "", 0, nil, fmt.Errorf("missing Host header") - } - - host, port, err = parseHostPort(hostHeader, 80) - if err != nil { - return "", 0, nil, err - } - if path == "" { - path = "/" - } - } - - var out strings.Builder - fmt.Fprintf(&out, "%s %s %s\r\n", method, path, version) - - sawHost := false - for _, h := range headers { - if strings.HasPrefix(strings.ToLower(h), "host:") { - sawHost = true - } - out.WriteString(h) - out.WriteString("\r\n") - } - - if !sawHost { - if port == 80 { - fmt.Fprintf(&out, "Host: %s\r\n", host) - } else { - fmt.Fprintf(&out, "Host: %s\r\n", net.JoinHostPort(host, strconv.Itoa(port))) - } - } - - out.WriteString("Connection: close\r\n\r\n") - - return host, port, []byte(out.String()), nil -} - -func openDragonTCPTunnel(serverAddr, token, targetHost string, targetPort int, transport string, tcpBuffer int) (net.Conn, error) { - d := net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: 30 * time.Second, - } - - conn, err := d.Dial("tcp", serverAddr) - if err != nil { - return nil, err - } - - protocol.TuneTCP(conn) - protocol.TuneTCPBuffer(conn, tcpBuffer) - _ = conn.SetDeadline(time.Now().Add(15 * time.Second)) - - // Correlation only; cryptographic randomness is unnecessary here. - requestID := requestCounter.Add(1) - - var command []byte - if transport == "raw" { - command = []byte(fmt.Sprintf("TUNNEL2 %s %s %d RAW", token, targetHost, targetPort)) - } else { - // Legacy XOR command remains compatible with the older server. - command = []byte(fmt.Sprintf("TUNNEL %s %s %d", token, targetHost, targetPort)) - } - - if err := protocol.WriteRequestFrame(conn, requestID, command); err != nil { - conn.Close() - return nil, err - } - - responseID, response, err := protocol.ReadResponseFrame(conn) - if err != nil { - conn.Close() - return nil, err - } - - if responseID != requestID { - conn.Close() - return nil, fmt.Errorf("request ID mismatch") - } - - if string(response) != "CONNECTED" { - conn.Close() - return nil, fmt.Errorf("%s", response) - } - - _ = conn.SetDeadline(time.Time{}) - return conn, nil -} - -func writeHTTPError(conn net.Conn, code int, reason, detail string) { - if detail == "" { - detail = reason - } - - body := []byte(detail) - - fmt.Fprintf( - conn, - "HTTP/1.1 %d %s\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n", - code, - reason, - len(body), - ) - _, _ = conn.Write(body) -} - -func handleLocal(conn net.Conn, serverAddr, token, transport string, tcpBuffer int, chunkOpts chunkClientOptions, slots chan struct{}) { - defer func() { - <-slots - _ = conn.Close() - }() - - protocol.TuneTCP(conn) - protocol.TuneTCPBuffer(conn, tcpBuffer) - _ = conn.SetDeadline(time.Now().Add(15 * time.Second)) - - header, extra, err := readHTTPHeaders(conn) - if err != nil { - return - } - - firstLine := strings.SplitN(string(header), "\r\n", 2)[0] - parts := strings.SplitN(firstLine, " ", 3) - - if len(parts) != 3 { - writeHTTPError(conn, 400, "Bad Request", "invalid HTTP request line") - return - } - - method, target := parts[0], parts[1] - - if strings.EqualFold(method, "CONNECT") { - host, port, err := parseHostPort(target, 443) - if err != nil { - writeHTTPError(conn, 400, "Bad Request", err.Error()) - return - } - - var remote net.Conn - if transport == "chunk" { - remote, err = openChunkTunnel(serverAddr, token, host, port, chunkOpts) - } else { - remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer) - } - if err != nil { - writeHTTPError(conn, 502, "Bad Gateway", err.Error()) - return - } - defer remote.Close() - - _, _ = conn.Write([]byte( - "HTTP/1.1 200 Connection Established\r\n" + - "Proxy-Agent: dragontcp-proxy/2.0\r\n\r\n", - )) - - if len(extra) > 0 { - if transport == "xor" { - protocol.XorInPlace(extra) - } - if _, err := remote.Write(extra); err != nil { - return - } - } - - _ = conn.SetDeadline(time.Time{}) - if transport == "xor" { - protocol.RelayXOR(conn, remote) - } else { - // raw and chunk connections expose a normal plaintext net.Conn. - protocol.RelayRaw(conn, remote) - } - return - } - - host, port, rewritten, err := rewritePlainHTTPRequest(header) - if err != nil { - writeHTTPError(conn, 400, "Bad Request", err.Error()) - return - } - - var remote net.Conn - if transport == "chunk" { - remote, err = openChunkTunnel(serverAddr, token, host, port, chunkOpts) - } else { - remote, err = openDragonTCPTunnel(serverAddr, token, host, port, transport, tcpBuffer) - } - if err != nil { - writeHTTPError(conn, 502, "Bad Gateway", err.Error()) - return - } - defer remote.Close() - - initial := make([]byte, 0, len(rewritten)+len(extra)) - initial = append(initial, rewritten...) - initial = append(initial, extra...) - if transport == "xor" { - protocol.XorInPlace(initial) - } - - if _, err := remote.Write(initial); err != nil { - return - } - - _ = conn.SetDeadline(time.Time{}) - if transport == "xor" { - protocol.RelayXOR(conn, remote) - } else { - protocol.RelayRaw(conn, remote) - } -} - -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", "change-this-token", "shared token") - maxConnections = flag.Int("max-connections", 20000, "max simultaneous proxy connections") - transport = flag.String("transport", "chunk", "transport: chunk (adaptive framed records), xor, or raw") - tcpBuffer = flag.Int("tcp-buffer", 0, "optional TCP read/write buffer bytes; 0 keeps OS autotuning") - chunkStart = flag.Int("chunk-start", 256, "initial adaptive chunk payload bytes") - chunkMin = flag.Int("chunk-min", 32, "minimum adaptive chunk payload bytes") - chunkMax = flag.Int("chunk-max", 65536, "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", 64, "successful data records required before increasing chunk size") - chunkAdaptLog = flag.Bool("chunk-adapt-log", false, "print adaptive chunk size changes") - chunkSizeLegacy = flag.Int("chunk-size", 0, "legacy fixed chunk size; nonzero disables adaptation") - chunkPollers = flag.Int("chunk-pollers", 16, "parallel downstream chunk pollers (1-128)") - chunkReconnect = flag.Int("chunk-reconnect-every", 32, "reconnect each transaction lane after N requests; 0 keeps it open") - chunkPollDelay = flag.Duration("chunk-poll-delay", 2*time.Millisecond, "delay after an empty chunk poll") - chunkTimeout = flag.Duration("chunk-timeout", 5*time.Second, "per-record transaction timeout before adaptive shrink") - ) - flag.Parse() - - if *serverHost == "" { - fmt.Fprintln(os.Stderr, "--server-host is required") - os.Exit(2) - } - - *transport = strings.ToLower(*transport) - if *transport != "raw" && *transport != "xor" && *transport != "chunk" { - fmt.Fprintln(os.Stderr, "--transport must be chunk, xor, or raw") - os.Exit(2) - } - if *chunkSizeLegacy != 0 { - if *chunkSizeLegacy < 32 || *chunkSizeLegacy > protocol.MaxChunkPayload { - fmt.Fprintf(os.Stderr, "--chunk-size must be between 32 and %d\n", protocol.MaxChunkPayload) - os.Exit(2) - } - *chunkStart = *chunkSizeLegacy - *chunkMin = *chunkSizeLegacy - *chunkMax = *chunkSizeLegacy - *chunkAdaptive = false - } - if *chunkMin < 32 || *chunkMax > protocol.MaxChunkPayload || *chunkMin > *chunkStart || *chunkStart > *chunkMax { - fmt.Fprintf(os.Stderr, "require 32 <= --chunk-min <= --chunk-start <= --chunk-max <= %d\n", protocol.MaxChunkPayload) - os.Exit(2) - } - if *chunkSuccesses < 1 { - fmt.Fprintln(os.Stderr, "--chunk-grow-after must be at least 1") - os.Exit(2) - } - if *chunkPollers < 1 || *chunkPollers > 128 { - fmt.Fprintln(os.Stderr, "--chunk-pollers must be between 1 and 128") - os.Exit(2) - } - chunkOpts := chunkClientOptions{ - startSize: *chunkStart, - minSize: *chunkMin, - maxSize: *chunkMax, - adaptive: *chunkAdaptive, - adaptSuccesses: *chunkSuccesses, - adaptLog: *chunkAdaptLog, - pollers: *chunkPollers, - reconnectEvery: *chunkReconnect, - pollDelay: *chunkPollDelay, - txnTimeout: *chunkTimeout, - tcpBuffer: *tcpBuffer, - } - - listenAddr := net.JoinHostPort(*listenHost, strconv.Itoa(*listenPort)) - serverAddr := net.JoinHostPort(*serverHost, strconv.Itoa(*serverPort)) - - ln, err := net.Listen("tcp", listenAddr) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - defer ln.Close() - - fmt.Printf("local Go HTTP proxy listening on %s\n", listenAddr) - fmt.Printf("remote DragonTCP endpoint=%s\n", serverAddr) - fmt.Printf("max_connections=%d transport=%s tcp_buffer=%d\n", *maxConnections, *transport, *tcpBuffer) - if *transport == "chunk" { - fmt.Printf( - "adaptive_chunk=%v start=%d min=%d max=%d grow_after=%d pollers=%d reconnect_every=%d timeout=%s\n", - *chunkAdaptive, - *chunkStart, - *chunkMin, - *chunkMax, - *chunkSuccesses, - *chunkPollers, - *chunkReconnect, - chunkTimeout.String(), - ) - } - - slots := make(chan struct{}, *maxConnections) - - for { - conn, err := ln.Accept() - if err != nil { - fmt.Fprintln(os.Stderr, "accept:", err) - continue - } - - select { - case slots <- struct{}{}: - go handleLocal(conn, serverAddr, *token, *transport, *tcpBuffer, chunkOpts, slots) - default: - writeHTTPError( - conn, - 503, - "Service Unavailable", - "proxy connection limit reached", - ) - _ = conn.Close() - } - } -} diff --git a/go/cmd/dragontcp-server/chunk.go b/go/cmd/dragontcp-server/chunk.go deleted file mode 100644 index 0b9546d..0000000 --- a/go/cmd/dragontcp-server/chunk.go +++ /dev/null @@ -1,498 +0,0 @@ -package main - -import ( - "bytes" - "context" - "fmt" - "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 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(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(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(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(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")) -} diff --git a/go/cmd/dragontcp-server/debug.go b/go/cmd/dragontcp-server/debug.go deleted file mode 100644 index f23f94b..0000000 --- a/go/cmd/dragontcp-server/debug.go +++ /dev/null @@ -1,83 +0,0 @@ -package main - -import ( - "fmt" - "os" - "sync/atomic" - "time" -) - -type serverDebug struct { - enabled bool - chunks bool - statsEvery time.Duration - started time.Time - - sessionsOpened atomic.Uint64 - sessionsClosed atomic.Uint64 - activeSessions atomic.Int64 - bytesUp atomic.Uint64 - bytesDown atomic.Uint64 - pushRecords atomic.Uint64 - pullRequests atomic.Uint64 - dataRecords atomic.Uint64 - waitRecords atomic.Uint64 - errors atomic.Uint64 -} - -func newServerDebug(enabled, chunks bool, statsEvery time.Duration) *serverDebug { - d := &serverDebug{ - enabled: enabled || chunks, - chunks: chunks, - statsEvery: statsEvery, - started: time.Now(), - } - if d.enabled && d.statsEvery > 0 { - go d.statsLoop() - } - return d -} - -func (d *serverDebug) logf(format string, args ...any) { - if d == nil || !d.enabled { - return - } - fmt.Fprintf(os.Stderr, "%s [DEBUG] "+format+"\n", append([]any{time.Now().Format("2006-01-02 15:04:05.000")}, args...)...) -} - -func (d *serverDebug) chunkf(format string, args ...any) { - if d == nil || !d.chunks { - return - } - fmt.Fprintf(os.Stderr, "%s [CHUNK] "+format+"\n", append([]any{time.Now().Format("2006-01-02 15:04:05.000")}, args...)...) -} - -func (d *serverDebug) errorf(format string, args ...any) { - if d == nil || !d.enabled { - return - } - d.errors.Add(1) - fmt.Fprintf(os.Stderr, "%s [ERROR] "+format+"\n", append([]any{time.Now().Format("2006-01-02 15:04:05.000")}, args...)...) -} - -func (d *serverDebug) statsLoop() { - ticker := time.NewTicker(d.statsEvery) - defer ticker.Stop() - for range ticker.C { - d.logf( - "STATS uptime=%s active_connections=%d active_sessions=%d sessions_opened=%d sessions_closed=%d bytes_up=%d bytes_down=%d push_records=%d pull_requests=%d data_records=%d waits=%d errors=%d", - time.Since(d.started).Round(time.Second), - atomic.LoadInt64(&active), - d.activeSessions.Load(), - d.sessionsOpened.Load(), - d.sessionsClosed.Load(), - d.bytesUp.Load(), - d.bytesDown.Load(), - d.pushRecords.Load(), - d.pullRequests.Load(), - d.dataRecords.Load(), - d.waitRecords.Load(), - d.errors.Load(), - ) - } -} diff --git a/go/cmd/dragontcp-server/main.go b/go/cmd/dragontcp-server/main.go deleted file mode 100644 index dce7cc9..0000000 --- a/go/cmd/dragontcp-server/main.go +++ /dev/null @@ -1,367 +0,0 @@ -package main - -import ( - "context" - "crypto/subtle" - "flag" - "fmt" - "io" - "net" - "net/netip" - "os" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "dragontcp/internal/protocol" -) - -var active int64 - -type dnsEntry struct { - ips []netip.Addr - expires time.Time -} - -type dnsCache struct { - mu sync.RWMutex - entries map[string]dnsEntry - ttl time.Duration - max int -} - -func newDNSCache(ttl time.Duration, max int) *dnsCache { - return &dnsCache{ - entries: make(map[string]dnsEntry), - ttl: ttl, - max: max, - } -} - -func (c *dnsCache) resolve(ctx context.Context, host string) ([]netip.Addr, error) { - if ip, err := netip.ParseAddr(host); err == nil { - return []netip.Addr{ip}, nil - } - - now := time.Now() - c.mu.RLock() - entry, ok := c.entries[host] - c.mu.RUnlock() - if ok && now.Before(entry.expires) { - return entry.ips, nil - } - - ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) - if err != nil { - return nil, err - } - - c.mu.Lock() - if len(c.entries) >= c.max { - // Simple bounded reset keeps the hot cache cheap and prevents growth. - c.entries = make(map[string]dnsEntry, c.max) - } - c.entries[host] = dnsEntry{ips: ips, expires: now.Add(c.ttl)} - c.mu.Unlock() - - return ips, nil -} - -func tokenEqual(a, b string) bool { - if len(a) != len(b) { - return false - } - return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 -} - -var blockedSpecial = []netip.Prefix{ - netip.MustParsePrefix("0.0.0.0/8"), - netip.MustParsePrefix("100.64.0.0/10"), - netip.MustParsePrefix("192.0.0.0/24"), - netip.MustParsePrefix("192.0.2.0/24"), - netip.MustParsePrefix("198.18.0.0/15"), - netip.MustParsePrefix("198.51.100.0/24"), - netip.MustParsePrefix("203.0.113.0/24"), - netip.MustParsePrefix("240.0.0.0/4"), - netip.MustParsePrefix("2001:db8::/32"), -} - -func addressAllowed(addr netip.Addr, allowPrivate bool) bool { - if addr.IsUnspecified() || addr.IsMulticast() { - return false - } - - if allowPrivate { - return true - } - - if !addr.IsGlobalUnicast() || - addr.IsPrivate() || - addr.IsLoopback() || - addr.IsLinkLocalUnicast() { - return false - } - - for _, prefix := range blockedSpecial { - if prefix.Contains(addr) { - return false - } - } - - return true -} - -func dialTarget(ctx context.Context, host string, port int, allowPrivate bool, cache *dnsCache, tcpBuffer int) (net.Conn, error) { - ips, err := cache.resolve(ctx, host) - if err != nil { - return nil, err - } - - var lastErr error - var blocked []string - - d := net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: 30 * time.Second, - } - - for _, ip := range ips { - if !addressAllowed(ip, allowPrivate) { - blocked = append(blocked, ip.String()) - continue - } - - addr := net.JoinHostPort(ip.String(), strconv.Itoa(port)) - conn, err := d.DialContext(ctx, "tcp", addr) - if err == nil { - protocol.TuneTCP(conn) - protocol.TuneTCPBuffer(conn, tcpBuffer) - return conn, nil - } - lastErr = err - } - - if lastErr != nil { - return nil, lastErr - } - if len(blocked) > 0 { - return nil, fmt.Errorf("target resolves only to blocked addresses: %s", strings.Join(blocked, ",")) - } - return nil, fmt.Errorf("no usable target address") -} - -func handle( - conn net.Conn, - token string, - allowPrivate bool, - cache *dnsCache, - tcpBuffer int, - slots chan struct{}, - manager *chunkManager, - chunkMax int, - chunkBuffered int, - chunkPollWait time.Duration, - debug *serverDebug, -) { - defer func() { - <-slots - atomic.AddInt64(&active, -1) - _ = conn.Close() - }() - - protocol.TuneTCP(conn) - protocol.TuneTCPBuffer(conn, tcpBuffer) - - 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 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 - } -} - -func main() { - var ( - host = flag.String("host", "0.0.0.0", "listen host") - port = flag.Int("port", 53, "listen port") - token = flag.String("token", "change-this-token", "shared token") - maxConnections = flag.Int("max-connections", 20000, "max simultaneous tunnels") - allowPrivate = flag.Bool("allow-private", false, "allow private/loopback targets") - dnsCacheTTL = flag.Duration("dns-cache-ttl", 30*time.Second, "server DNS cache TTL") - dnsCacheSize = flag.Int("dns-cache-size", 4096, "maximum cached DNS hostnames") - tcpBuffer = flag.Int("tcp-buffer", 0, "optional TCP read/write buffer bytes; 0 keeps OS autotuning") - chunkMax = flag.Int("chunk-max", 65536, "maximum adaptive chunk payload bytes (32 bytes to 1 MiB)") - chunkBuffered = flag.Int("chunk-buffered", 256, "maximum buffered destination chunks per session") - chunkPollWait = flag.Duration("chunk-poll-wait", 200*time.Millisecond, "server long-poll wait for chunk data") - sessionTimeout = flag.Duration("chunk-session-timeout", 2*time.Minute, "idle chunk session timeout") - debugEnabled = flag.Bool("debug", false, "log session/connect/errors and periodic statistics") - debugChunks = flag.Bool("debug-chunks", false, "log every chunk protocol record; very verbose") - debugStats = flag.Duration("debug-stats-interval", 5*time.Second, "periodic debug statistics interval; 0 disables") - ) - flag.Parse() - - if *chunkMax < 32 || *chunkMax > protocol.MaxChunkPayload { - fmt.Fprintf(os.Stderr, "--chunk-max must be between 32 and %d\n", protocol.MaxChunkPayload) - os.Exit(2) - } - if *chunkBuffered < 8 { - fmt.Fprintln(os.Stderr, "--chunk-buffered must be at least 8") - os.Exit(2) - } - - listenAddr := net.JoinHostPort(*host, strconv.Itoa(*port)) - ln, err := net.Listen("tcp", listenAddr) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - defer ln.Close() - - fmt.Printf("DragonTCP Go server listening on %s\n", listenAddr) - fmt.Printf("max_connections=%d tcp_buffer=%d\n", *maxConnections, *tcpBuffer) - - slots := make(chan struct{}, *maxConnections) - cache := newDNSCache(*dnsCacheTTL, *dnsCacheSize) - debug := newServerDebug(*debugEnabled, *debugChunks, *debugStats) - manager := newChunkManager(*sessionTimeout, debug) - fmt.Printf("adaptive_chunk_max=%d buffered_chunks=%d poll_wait=%s\n", *chunkMax, *chunkBuffered, chunkPollWait.String()) - if debug.enabled { - fmt.Printf("debug=true debug_chunks=%t stats_interval=%s\n", debug.chunks, debug.statsEvery) - } - - for { - conn, err := ln.Accept() - if err != nil { - fmt.Fprintln(os.Stderr, "accept:", err) - continue - } - - select { - case slots <- struct{}{}: - atomic.AddInt64(&active, 1) - if debug.enabled { - debug.logf("ACCEPT peer=%v active_connections=%d", conn.RemoteAddr(), atomic.LoadInt64(&active)) - } - go handle( - conn, - *token, - *allowPrivate, - cache, - *tcpBuffer, - slots, - manager, - *chunkMax, - *chunkBuffered, - *chunkPollWait, - debug, - ) - default: - if debug.enabled { - debug.errorf("REJECT peer=%v reason=max-connections", conn.RemoteAddr()) - } - _ = conn.Close() - } - } -} diff --git a/go/go.mod b/go/go.mod deleted file mode 100644 index a8d2a13..0000000 --- a/go/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module dragontcp - -go 1.22