This commit is contained in:
2026-08-16 03:48:57 -03:00
parent 5621de243a
commit c02b36c83d
48 changed files with 5398 additions and 2221 deletions
@@ -0,0 +1,44 @@
package com.dragontcp.client;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public final class AppLog {
public interface Listener { void onLine(String line); }
private static final int MAX_LINES = 600;
private static final ArrayDeque<String> lines = new ArrayDeque<>();
private static final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
private AppLog() {}
public static void append(String line) {
if (line == null) return;
line = line.trim();
if (line.isEmpty()) return;
synchronized (lines) {
while (lines.size() >= MAX_LINES) lines.removeFirst();
lines.addLast(line);
}
for (Listener listener : listeners) {
try { listener.onLine(line); } catch (Throwable ignored) {}
}
}
public static String history() {
StringBuilder out = new StringBuilder();
synchronized (lines) {
for (String line : lines) out.append(line).append('\n');
}
return out.toString();
}
public static void clear() {
synchronized (lines) { lines.clear(); }
}
public static void addListener(Listener listener) { listeners.addIfAbsent(listener); }
public static void removeListener(Listener listener) { listeners.remove(listener); }
}
@@ -6,101 +6,376 @@ 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.IBinder;
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.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket;
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;
import tech.xvanturing.freeproxy.data.model.DnsMode;
import tech.xvanturing.freeproxy.data.model.ProxyProfile;
import tech.xvanturing.freeproxy.data.model.ProxyType;
import tech.xvanturing.freeproxy.vpn.TunnelEngine;
import tech.xvanturing.freeproxy.vpn.proxy.SocketProtector;
@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",1048576),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());}
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 final String EXTRA_SERVER = "server";
public static final String EXTRA_PORT = "port";
public static final String EXTRA_TOKEN = "token";
public static final String EXTRA_CHUNK_MAX = "chunkMax";
public static final String EXTRA_CHUNK_MIN = "chunkMin";
public static final String EXTRA_RECONNECT = "reconnect";
public static final String EXTRA_TIMEOUT = "timeout";
private static final int NOTIFICATION_ID = 53;
private static final String CHANNEL_ID = "dragontcp-lite";
private static final int LOCAL_PROXY_PORT = 8080;
private static final int TUN_MTU = 1400;
private final Object stateLock = new Object();
private volatile Process coreProcess;
private volatile TunnelEngine tunnelEngine;
private volatile ParcelFileDescriptor tunFd;
private volatile boolean connected;
private volatile boolean stopping;
@Override
public void onCreate() {
super.onCreate();
createNotificationChannel();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String action = intent != null ? intent.getAction() : null;
if (ACTION_STOP.equals(action)) {
new Thread(() -> stopEverything("Stopped"), "dragontcp-stop").start();
return START_NOT_STICKY;
}
if (ACTION_CONNECT.equals(action)) {
startForeground(NOTIFICATION_ID, buildNotification("Starting..."));
Intent copy = new Intent(intent);
new Thread(() -> startEverything(copy), "dragontcp-start").start();
return START_STICKY;
}
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 startEverything(Intent intent) {
synchronized (stateLock) {
if (connected || coreProcess != null || tunnelEngine != null) {
// Restart in-place without stopSelf(); this avoids a race where
// Android destroys the service just after a new CONNECT begins.
stopping = true;
cleanupComponentsLocked();
}
stopping = false;
}
String server = intent.getStringExtra(EXTRA_SERVER);
int port = intent.getIntExtra(EXTRA_PORT, 53);
String token = intent.getStringExtra(EXTRA_TOKEN);
int chunkMax = intent.getIntExtra(EXTRA_CHUNK_MAX, 1024 * 1024);
int chunkMin = intent.getIntExtra(EXTRA_CHUNK_MIN, 32);
int reconnect = intent.getIntExtra(EXTRA_RECONNECT, 1);
int timeout = intent.getIntExtra(EXTRA_TIMEOUT, 2);
if (server == null || server.trim().isEmpty()) {
failStart("Server is required");
return;
}
server = server.trim();
if (token == null) token = "";
chunkMax = Math.max(32, Math.min(1024 * 1024, chunkMax));
chunkMin = Math.max(32, Math.min(chunkMax, chunkMin));
reconnect = Math.max(1, reconnect);
timeout = Math.max(1, timeout);
try {
AppLog.append("Starting DragonTCP → " + server + ":" + port);
Process process = startDragonCore(server, port, token, chunkMax, chunkMin, reconnect, timeout);
synchronized (stateLock) { coreProcess = process; }
startCoreLogReader(process);
waitForLocalProxy(process);
AppLog.append("Local DragonTCP proxy ready on 127.0.0.1:8080");
ParcelFileDescriptor pfd = establishVpn();
if (pfd == null) throw new IllegalStateException("Android refused to establish the VPN interface");
SocketProtector protector = new SocketProtector() {
@Override public boolean protect(Socket socket) {
return DragonService.this.protect(socket);
}
@Override public boolean protect(DatagramSocket socket) {
return DragonService.this.protect(socket);
}
};
ProxyProfile profile = new ProxyProfile(ProxyType.HTTP, DnsMode.PROXY, false);
InetSocketAddress localProxy = new InetSocketAddress(
InetAddress.getByAddress(new byte[]{127, 0, 0, 1}),
LOCAL_PROXY_PORT
);
TunnelEngine engine = new TunnelEngine(
pfd,
profile,
localProxy,
TUN_MTU,
protector,
null,
null
);
synchronized (stateLock) {
tunFd = pfd;
tunnelEngine = engine;
connected = true;
}
engine.start();
updateNotification("Connected");
AppLog.append("VPN connected");
AppLog.append("DNS: forced through 1.1.1.1 over DragonTCP");
AppLog.append("IPv6: captured and blocked to prevent bypass");
} catch (Throwable t) {
failStart(t.getMessage() != null ? t.getMessage() : t.toString());
}
}
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<String> cmd=new ArrayList<String>();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 Process startDragonCore(
String server,
int port,
String token,
int chunkMax,
int chunkMin,
int reconnect,
int timeout
) throws Exception {
File executable = new File(getApplicationInfo().nativeLibraryDir, "libdragontcp_client.so");
if (!executable.exists()) throw new IllegalStateException("Embedded DragonTCP core is missing");
List<String> cmd = new ArrayList<>();
cmd.add(executable.getAbsolutePath());
cmd.add("--listen-host"); cmd.add("127.0.0.1");
cmd.add("--listen-port"); cmd.add(Integer.toString(LOCAL_PROXY_PORT));
cmd.add("--server-host"); cmd.add(server);
cmd.add("--server-port"); cmd.add(Integer.toString(port));
if (!token.isEmpty()) { cmd.add("--token"); cmd.add(token); }
cmd.add("--transport"); cmd.add("chunk");
cmd.add("--chunk-start"); cmd.add(Integer.toString(chunkMax));
cmd.add("--chunk-min"); cmd.add(Integer.toString(chunkMin));
cmd.add("--chunk-max"); cmd.add(Integer.toString(chunkMax));
cmd.add("--chunk-pollers"); cmd.add("1");
cmd.add("--chunk-reconnect-every"); cmd.add(Integer.toString(reconnect));
cmd.add("--chunk-timeout"); cmd.add(timeout + "s");
cmd.add("--chunk-grow-after"); cmd.add("16");
cmd.add("--chunk-adapt-log=true");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true);
return pb.start();
}
private void passTunFdWhenReady()throws Exception{
long deadline=System.currentTimeMillis()+5000;while(System.currentTimeMillis()<deadline){if(fdSocketFile!=null&&fdSocketFile.exists())break;Process p; synchronized(lifecycleLock){p=process;}if(p==null||!p.isAlive())throw new Exception("DragonTCP core exited before TUN handoff");Thread.sleep(25);}
if(fdSocketFile==null||!fdSocketFile.exists())throw new Exception("DragonTCP core did not create its TUN-fd socket");
LocalSocket s=new LocalSocket();try{s.connect(new LocalSocketAddress(fdSocketFile.getAbsolutePath(),LocalSocketAddress.Namespace.FILESYSTEM));FileDescriptor fd=vpnInterface.getFileDescriptor();s.setFileDescriptorsForSend(new FileDescriptor[]{fd});s.getOutputStream().write(0x44);s.getOutputStream().flush();appendLog("TUN file descriptor passed to DragonTCP core");}finally{try{s.close();}catch(Exception ignored){}}
private void startCoreLogReader(Process process) {
Thread reader = new Thread(() -> {
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = br.readLine()) != null) {
// Keep the UI useful: adaptation changes and real errors only.
String lower = line.toLowerCase();
if (line.startsWith("adaptive ") || lower.contains("error") || lower.contains("failed")) {
AppLog.append(line);
}
}
} catch (Throwable ignored) {
}
}, "dragontcp-core-log");
reader.setDaemon(true);
reader.start();
Thread watcher = new Thread(() -> {
try {
int code = process.waitFor();
boolean shouldStop;
synchronized (stateLock) {
shouldStop = !stopping && coreProcess == process && connected;
}
if (shouldStop) {
AppLog.append("DragonTCP core exited: " + code);
stopEverything("Core stopped");
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}, "dragontcp-core-watch");
watcher.setDaemon(true);
watcher.start();
}
private void readCoreOutput(Process p){
try{BufferedReader br=new BufferedReader(new InputStreamReader(p.getInputStream()));String line;while((line=br.readLine())!=null){appendLog(line);if(line.contains("VPN READY")){running=true;active=true;state="Connected • Full VPN";updateNotification("Connected • IPv4 + IPv6 • TCP/UDP");}}
int code=p.waitFor();handleCoreExit(p,code);
}catch(Exception e){appendLog("Core reader: "+e);handleCoreExit(p,-1);}
private void waitForLocalProxy(Process process) throws Exception {
long deadline = System.currentTimeMillis() + 10_000;
Throwable last = null;
while (System.currentTimeMillis() < deadline) {
if (!process.isAlive()) throw new IllegalStateException("DragonTCP core exited before proxy startup");
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress("127.0.0.1", LOCAL_PROXY_PORT), 150);
return;
} catch (Throwable t) {
last = t;
Thread.sleep(100);
}
}
throw new IllegalStateException("Local proxy did not start" + (last != null ? ": " + last.getMessage() : ""));
}
private void handleCoreExit(Process p,int code){boolean owns; synchronized(lifecycleLock){owns=process==p;if(owns)process=null;}if(!owns)return;appendLog("DragonTCP core exited: "+code);running=false;active=false;state="Core exited ("+code+")";closeVpn();stopForeground(true);stopSelf();}
private Notification buildNotification(String msg){Intent open=new Intent(this,MainActivity.class);open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP|Intent.FLAG_ACTIVITY_CLEAR_TOP);PendingIntent op=PendingIntent.getActivity(this,0,open,PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE);Intent stop=new Intent(this,DragonService.class);stop.setAction(ACTION_STOP);PendingIntent sp=PendingIntent.getService(this,2,stop,PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE);Notification.Builder nb=Build.VERSION.SDK_INT>=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();}
private ParcelFileDescriptor establishVpn() throws Exception {
Builder builder = new Builder()
.setSession("DragonTCP Lite")
.setMtu(TUN_MTU)
.addAddress("10.77.0.2", 32)
.addRoute("0.0.0.0", 0)
.addDnsServer("1.1.1.1")
// The embedded userspace adapter is intentionally IPv4-only.
// Capturing ::/0 blocks IPv6 instead of leaking it outside the VPN.
.addAddress("fd77:6472:6167:6f6e::2", 128)
.addRoute("::", 0);
try {
builder.addDisallowedApplication(getPackageName());
} catch (PackageManager.NameNotFoundException ignored) {
}
if (Build.VERSION.SDK_INT >= 29) {
builder.setBlocking(true);
builder.setMetered(false);
}
return builder.establish();
}
private void failStart(String message) {
AppLog.append("CONNECT failed: " + message);
stopEverything("Failed");
}
private void stopEverything(String logMessage) {
synchronized (stateLock) {
stopEverythingLocked(logMessage);
}
}
private void cleanupComponentsLocked() {
connected = false;
TunnelEngine engine = tunnelEngine;
tunnelEngine = null;
if (engine != null) {
try { engine.stop(); } catch (Throwable ignored) {}
}
ParcelFileDescriptor fd = tunFd;
tunFd = null;
if (fd != null) {
try { fd.close(); } catch (Throwable ignored) {}
}
Process process = coreProcess;
coreProcess = null;
if (process != null) {
try {
process.destroy();
if (!process.waitFor(1200, TimeUnit.MILLISECONDS)) {
process.destroyForcibly();
process.waitFor(800, TimeUnit.MILLISECONDS);
}
} catch (Throwable ignored) {
try { process.destroyForcibly(); } catch (Throwable ignored2) {}
}
}
}
private void stopEverythingLocked(String logMessage) {
if (stopping) return;
stopping = true;
cleanupComponentsLocked();
if (logMessage != null) AppLog.append(logMessage);
try { stopForeground(true); } catch (Throwable ignored) {}
stopSelf();
stopping = false;
}
@Override
public void onRevoke() {
stopEverything("VPN permission revoked");
super.onRevoke();
}
@Override
public void onDestroy() {
stopEverything(null);
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return super.onBind(intent);
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= 26) {
NotificationManager nm = getSystemService(NotificationManager.class);
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"DragonTCP VPN",
NotificationManager.IMPORTANCE_LOW
);
nm.createNotificationChannel(channel);
}
}
private Notification buildNotification(String status) {
Intent open = new Intent(this, MainActivity.class);
PendingIntent contentIntent = PendingIntent.getActivity(
this, 0, open,
PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)
);
Intent stopIntent = new Intent(this, DragonService.class).setAction(ACTION_STOP);
PendingIntent stopPending = PendingIntent.getService(
this, 1, stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)
);
Notification.Builder b = Build.VERSION.SDK_INT >= 26
? new Notification.Builder(this, CHANNEL_ID)
: new Notification.Builder(this);
return b.setContentTitle("DragonTCP Lite")
.setContentText(status)
.setSmallIcon(android.R.drawable.stat_sys_upload_done)
.setContentIntent(contentIntent)
.setOngoing(true)
.addAction(new Notification.Action.Builder(
android.R.drawable.ic_menu_close_clear_cancel,
"STOP",
stopPending
).build())
.build();
}
private void updateNotification(String status) {
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
nm.notify(NOTIFICATION_ID, buildNotification(status));
}
}
@@ -1,14 +1,13 @@
package com.dragontcp.client;
import android.app.Activity;
import android.content.Context;
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;
@@ -17,117 +16,254 @@ import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TableLayout;
import android.widget.TableRow;
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 static final int VPN_REQUEST = 100;
private static final String PREFS = "dragontcp";
private EditText server, port, token, chunkMax, chunkMin, timeout;
private TextView status, logs;
private EditText server;
private EditText port;
private EditText token;
private EditText chunkMax;
private EditText chunkMin;
private EditText reconnect;
private EditText timeout;
private TextView logText;
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);
}
};
private final AppLog.Listener logListener = line -> runOnUiThread(() -> appendLogLine(line));
@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
prefs = getSharedPreferences("dragontcp", MODE_PRIVATE);
setTitle("DragonTCP VPN");
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
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;
@Override
protected void onStart() {
super.onStart();
logText.setText(AppLog.history());
AppLog.addListener(logListener);
logScroll.post(() -> logScroll.fullScroll(View.FOCUS_DOWN));
}
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;
@Override
protected void onStop() {
AppLog.removeListener(logListener);
super.onStop();
}
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);
int pad = dp(14);
ScrollView outer = new ScrollView(this);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setPadding(pad, pad, pad, pad);
outer.addView(root, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
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 title = new TextView(this);
title.setText("DragonTCP Lite VPN");
title.setTextSize(24);
title.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
root.addView(title);
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);
TextView subtitle = new TextView(this);
subtitle.setText("Android VPN → local HTTP CONNECT proxy → adaptive XOR over TCP/53");
subtitle.setTextSize(13);
subtitle.setPadding(0, dp(4), 0, dp(12));
root.addView(subtitle);
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());
TableLayout table = new TableLayout(this);
table.setStretchAllColumns(false);
table.setColumnStretchable(1, true);
root.addView(table, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
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);
server = addField(table, "Server", "", false, false);
port = addField(table, "Port", "53", true, false);
token = addField(table, "Token", "", false, true);
chunkMax = addField(table, "Max chunk", "1048576", true, false);
chunkMin = addField(table, "Min chunk", "32", true, false);
reconnect = addField(table, "Reconnect every", "1", true, false);
timeout = addField(table, "Timeout (s)", "2", true, false);
TextView fixed = new TextView(this);
fixed.setText("Pollers: 1 (fixed) • Start chunk = Max chunk • XOR 0xAD always on");
fixed.setTextSize(12);
fixed.setPadding(0, dp(8), 0, dp(8));
root.addView(fixed);
LinearLayout buttons = new LinearLayout(this);
buttons.setOrientation(LinearLayout.HORIZONTAL);
buttons.setGravity(Gravity.CENTER);
Button connect = new Button(this);
connect.setText("CONNECT");
Button stop = new Button(this);
stop.setText("STOP");
buttons.addView(connect, new LinearLayout.LayoutParams(0, dp(52), 1f));
buttons.addView(stop, new LinearLayout.LayoutParams(0, dp(52), 1f));
root.addView(buttons);
connect.setOnClickListener(v -> requestConnect());
stop.setOnClickListener(v -> {
Intent i = new Intent(this, DragonService.class).setAction(DragonService.ACTION_STOP);
startService(i);
});
LinearLayout logHeader = new LinearLayout(this);
logHeader.setOrientation(LinearLayout.HORIZONTAL);
logHeader.setGravity(Gravity.CENTER_VERTICAL);
TextView logLabel = new TextView(this);
logLabel.setText("Live log");
logLabel.setTextSize(16);
logLabel.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
Button clear = new Button(this);
clear.setText("CLEAR");
logHeader.addView(logLabel, new LinearLayout.LayoutParams(0, dp(48), 1f));
logHeader.addView(clear, new LinearLayout.LayoutParams(dp(100), dp(48)));
root.addView(logHeader);
logScroll = new ScrollView(this);
logText = new TextView(this);
logText.setTextSize(12);
logText.setTypeface(Typeface.MONOSPACE);
logText.setTextIsSelectable(true);
logText.setPadding(dp(8), dp(8), dp(8), dp(8));
logScroll.addView(logText, new ScrollView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
));
root.addView(logScroll, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(280)
));
clear.setOnClickListener(v -> {
AppLog.clear();
logText.setText("");
});
TextView note = new TextView(this);
note.setText("IPv4 is tunneled. IPv6 is captured and blocked so it cannot bypass the proxy. DNS is sent to 1.1.1.1 through DragonTCP using DNS-over-TCP.");
note.setTextSize(11);
note.setPadding(0, dp(8), 0, dp(12));
root.addView(note);
setContentView(outer);
}
private void loadSettings(){server.setText(prefs.getString("server",""));port.setText(prefs.getString("port","53"));token.setText(prefs.getString("token",""));chunkMax.setText(prefs.getString("chunkMax","1048576"));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,1048576);
if(p<1||p>65535){toast("Port must be 1-65535");return false;}
if(min<32||max>1048576||min>max){toast("Chunks must satisfy 32 <= Min <= Max <= 1048576");return false;}
if(timeout.getText().toString().trim().isEmpty()){toast("Enter a timeout such as 2s");return false;}
return true;
private EditText addField(TableLayout table, String label, String defaultValue, boolean numeric, boolean password) {
TableRow row = new TableRow(this);
row.setPadding(0, dp(2), 0, dp(2));
TextView name = new TextView(this);
name.setText(label);
name.setGravity(Gravity.CENTER_VERTICAL);
name.setPadding(0, 0, dp(10), 0);
EditText value = new EditText(this);
value.setSingleLine(true);
value.setText(defaultValue);
if (numeric) value.setInputType(InputType.TYPE_CLASS_NUMBER);
if (password) value.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
row.addView(name, new TableRow.LayoutParams(dp(125), dp(50)));
row.addView(value, new TableRow.LayoutParams(0, dp(50), 1f));
table.addView(row);
return value;
}
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 void requestConnect() {
try {
validateAndSave();
} catch (Exception e) {
AppLog.append("CONFIG: " + e.getMessage());
return;
}
Intent prepare = VpnService.prepare(this);
if (prepare != null) {
startActivityForResult(prepare, VPN_REQUEST);
} else {
startDragonService();
}
}
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,1048576),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;
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == VPN_REQUEST) {
if (resultCode == RESULT_OK) startDragonService();
else AppLog.append("VPN permission was not granted");
}
}
private void startDragonService() {
SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
Intent i = new Intent(this, DragonService.class).setAction(DragonService.ACTION_CONNECT);
i.putExtra(DragonService.EXTRA_SERVER, p.getString("server", ""));
i.putExtra(DragonService.EXTRA_PORT, p.getInt("port", 53));
i.putExtra(DragonService.EXTRA_TOKEN, p.getString("token", ""));
i.putExtra(DragonService.EXTRA_CHUNK_MAX, p.getInt("max", 1048576));
i.putExtra(DragonService.EXTRA_CHUNK_MIN, p.getInt("min", 32));
i.putExtra(DragonService.EXTRA_RECONNECT, p.getInt("reconnect", 1));
i.putExtra(DragonService.EXTRA_TIMEOUT, p.getInt("timeout", 2));
if (Build.VERSION.SDK_INT >= 26) startForegroundService(i); else startService(i);
}
private void validateAndSave() {
String h = server.getText().toString().trim();
if (h.isEmpty()) throw new IllegalArgumentException("Server is required");
int p = parse(port, 1, 65535, "Port");
int max = parse(chunkMax, 32, 1048576, "Max chunk");
int min = parse(chunkMin, 32, max, "Min chunk");
int rec = parse(reconnect, 1, 1000000, "Reconnect every");
int tout = parse(timeout, 1, 120, "Timeout");
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
.putString("server", h)
.putInt("port", p)
.putString("token", token.getText().toString())
.putInt("max", max)
.putInt("min", min)
.putInt("reconnect", rec)
.putInt("timeout", tout)
.apply();
}
private int parse(EditText field, int min, int max, String name) {
int v;
try { v = Integer.parseInt(field.getText().toString().trim()); }
catch (Exception e) { throw new IllegalArgumentException(name + " is invalid"); }
if (v < min || v > max) throw new IllegalArgumentException(name + " must be " + min + "-" + max);
return v;
}
private void loadSettings() {
SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
server.setText(p.getString("server", ""));
port.setText(Integer.toString(p.getInt("port", 53)));
token.setText(p.getString("token", ""));
chunkMax.setText(Integer.toString(p.getInt("max", 1048576)));
chunkMin.setText(Integer.toString(p.getInt("min", 32)));
reconnect.setText(Integer.toString(p.getInt("reconnect", 1)));
timeout.setText(Integer.toString(p.getInt("timeout", 2)));
}
private void appendLogLine(String line) {
// Only auto-scroll when the user was already near the bottom.
View child = logScroll.getChildAt(0);
int gap = child == null ? 0 : child.getBottom() - (logScroll.getScrollY() + logScroll.getHeight());
boolean follow = gap < dp(48);
logText.append(line + "\n");
if (follow) logScroll.post(() -> logScroll.fullScroll(View.FOCUS_DOWN));
}
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
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(off<n){int r=in.read(buf,off,n-off);if(r<0)break;off+=r;}return new String(buf,0,off,"UTF-8");}catch(Exception e){return "log error: "+e.getMessage();}}
private void refreshStatus(){boolean a=DragonService.active,r=DragonService.running;status.setText((r?"":a?"":"")+DragonService.state);status.setTextColor(r?Color.rgb(115,235,145):a?Color.rgb(255,205,95):Color.rgb(232,236,241));connectButton.setEnabled(!a);stopButton.setEnabled(a);String current=readTail(new File(getFilesDir(),"dragontcp.log"),131072);if(!current.equals(lastLogText)){lastLogText=current;logs.setText(current);logScroll.post(()->logScroll.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();}
}
@@ -0,0 +1,10 @@
package tech.xvanturing.freeproxy.data.model
enum class ProxyType { HTTP, SOCKS5 }
enum class DnsMode { PROXY, DIRECT }
data class ProxyProfile(
val type: ProxyType = ProxyType.HTTP,
val dnsMode: DnsMode = DnsMode.PROXY,
val udpOverSocks: Boolean = false,
)
@@ -0,0 +1,8 @@
package tech.xvanturing.freeproxy.vpn
import tech.xvanturing.freeproxy.vpn.net.SessionKey
/** App attribution is intentionally disabled in the lightweight build. */
class AppResolver {
fun resolve(protocol: Int, key: SessionKey): String? = null
}
@@ -0,0 +1,452 @@
package tech.xvanturing.freeproxy.vpn
// Modified for DragonTCP Lite compatibility with Kotlin 1.9 (ArrayDeque API).
import android.os.SystemClock
import android.system.OsConstants
import android.util.Log
import tech.xvanturing.freeproxy.vpn.log.LogLevel
import tech.xvanturing.freeproxy.vpn.log.TunnelLog
import tech.xvanturing.freeproxy.vpn.net.HostRegistry
import tech.xvanturing.freeproxy.vpn.net.PacketBuilder
import tech.xvanturing.freeproxy.vpn.net.SessionKey
import tech.xvanturing.freeproxy.vpn.net.TcpHeader
import tech.xvanturing.freeproxy.vpn.net.seqAdvance
import tech.xvanturing.freeproxy.vpn.net.seqLessOrEqual
import tech.xvanturing.freeproxy.vpn.net.seqLessThan
import tech.xvanturing.freeproxy.vpn.net.toInetAddress
import tech.xvanturing.freeproxy.vpn.proxy.ProxyClient
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.io.IOException
import java.net.Socket
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.max
import kotlin.math.min
import kotlin.random.Random
/**
* 一条 TCP 连接的用户态终结点。
*
* 对本机内核而言,这个对象扮演目标服务器:它回 SYN-ACK、确认数据、发 FIN
* 真实流量则通过 [ProxyClient] 建立的隧道往返。
*
* 关于可靠性的一个重要简化:写向 TUN 的数据是交给本机内核的,不经过任何有损链路,
* 因此不需要拥塞控制。只要严格遵守对端宣告的接收窗口就不会丢包;
* 超时重传仅作为极端情况下的兜底。
*/
class TcpSession(
val key: SessionKey,
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher,
private val proxyClient: ProxyClient,
private val tun: TunWriter,
mtu: Int,
private val appResolver: AppResolver?,
private val onFinished: (SessionKey) -> Unit,
) {
private enum class State { CONNECTING, ESTABLISHED, CLOSED }
private val mss = (mtu - IPV4_TCP_HEADER_SIZE).coerceIn(536, 1460)
private val lock = Object()
private val outputBuffer = ByteArray(mtu + 80)
private val closed = AtomicBoolean(false)
/** 上行数据队列;有界,队列压力通过 TCP 接收窗口反馈给应用。 */
private val upstream = Channel<ByteArray>(capacity = UPSTREAM_QUEUE_SIZE)
@Volatile
private var state = State.CONNECTING
@Volatile
private var socket: Socket? = null
@Volatile
private var job: Job? = null
@Volatile
var lastActivity: Long = SystemClock.elapsedRealtime()
private set
// ---- 发送方向(我们 → 内核)
private val initialSequence = Random.nextLong(0, 0xFFFF_FFFFL)
private var sendUnacked = initialSequence
private var sendNext = initialSequence
private var peerWindow = 65535
private val retransmitQueue = ArrayDeque<Segment>()
private var finSent = false
// ---- 接收方向(内核 → 我们)
private var receiveNext = 0L
private var pendingUpstreamBytes = 0
private var upstreamClosed = false
private class Segment(val sequence: Long, val data: ByteArray)
/** 收到 SYN:登记序列号并开始异步连接代理。 */
fun open(syn: TcpHeader) {
synchronized(lock) {
receiveNext = seqAdvance(syn.sequence, 1)
peerWindow = syn.window
}
job = scope.launch(ioDispatcher) {
// UID 反查要趁 socket 还在,因此放在建立隧道之前
val packageName = appResolver?.resolve(OsConstants.IPPROTO_TCP, key)
val target = HostRegistry.describe(key.destIp, key.destPort)
// 目标是主机名(而非 IP 字面量)时,允许从日志把它加入 DNS 拦截
val targetHost = target.substringBeforeLast(':')
val targetDomain = targetHost.takeIf { host -> host.any { it.isLetter() } }
val connected = try {
proxyClient.connectTcp(key.destIp.toInetAddress(), key.destPort)
} catch (e: Exception) {
Log.d(TAG, "连接失败 $key${e.message}")
TunnelLog.connect(
target = target,
packageName = packageName,
status = e.message?.take(48) ?: "失败",
level = LogLevel.FAILURE,
domain = targetDomain,
)
// 立刻回 RST,让应用马上得到"连接被拒绝"而不是干等超时
sendReset()
finish()
return@launch
}
TunnelLog.connect(target, packageName, "OK", LogLevel.SUCCESS, targetDomain)
val accepted = synchronized(lock) {
if (state != State.CONNECTING) {
false
} else {
socket = connected
state = State.ESTABLISHED
sendSynAck()
true
}
}
if (!accepted) {
runCatching { connected.close() }
return@launch
}
VpnStateHolder.sessionCounter.incrementAndGet()
launch(ioDispatcher) { pumpUpstream(connected) }
pumpDownstream(connected)
}
}
/** 处理来自内核的一个 TCP 报文段。 */
fun onPacket(header: TcpHeader, buffer: ByteArray, payloadOffset: Int, payloadLength: Int) {
lastActivity = SystemClock.elapsedRealtime()
if (header.isRst) {
finish()
return
}
synchronized(lock) {
peerWindow = header.window
if (header.isAck) releaseAcknowledged(header.acknowledgment)
lock.notifyAll()
}
// 重复的 SYN 说明我们的 SYN-ACK 丢了(或那时还没连上代理),补发一次
if (header.isSyn) {
synchronized(lock) {
if (state == State.ESTABLISHED) sendSynAck()
}
return
}
if (state == State.CLOSED) {
sendReset()
return
}
val accepted = if (payloadLength > 0) {
acceptData(header.sequence, buffer, payloadOffset, payloadLength)
} else {
0
}
if (header.isFin) {
acceptFin(seqAdvance(header.sequence, accepted))
}
}
/** @return 实际被接收的字节数,用于定位随行 FIN 的序列号。 */
private fun acceptData(sequence: Long, buffer: ByteArray, offset: Int, length: Int): Int {
val chunk = synchronized(lock) {
when {
sequence == receiveNext -> buffer.copyOfRange(offset, offset + length)
// 重传的老数据,或 TUN 上本不该出现的乱序:都用一个 ACK 应答
else -> {
sendAck()
return 0
}
}
}
// trySend 失败意味着上行积压:不推进 receiveNext,对端会因零窗口暂停,
// 等队列腾出空间后由 pumpUpstream 主动通告新窗口。
if (!upstream.trySend(chunk).isSuccess) {
synchronized(lock) { sendAck() }
return 0
}
synchronized(lock) {
receiveNext = seqAdvance(receiveNext, length)
pendingUpstreamBytes += length
sendAck()
}
return length
}
private fun acceptFin(finSequence: Long) {
synchronized(lock) {
if (upstreamClosed) {
sendAck()
return
}
if (finSequence != receiveNext) return
receiveNext = seqAdvance(receiveNext, 1)
upstreamClosed = true
sendAck()
}
// 关闭上行队列,写协程排空后会 shutdownOutput,让代理知道请求已结束
upstream.close()
}
// ------------------------------------------------------------ 数据泵
private suspend fun pumpUpstream(socket: Socket) {
try {
val output = socket.getOutputStream()
for (chunk in upstream) {
output.write(chunk)
output.flush()
VpnStateHolder.uploadCounter.addAndGet(chunk.size.toLong())
synchronized(lock) {
val before = advertisedWindow()
pendingUpstreamBytes = max(0, pendingUpstreamBytes - chunk.size)
// 只在窗口刚从"不足一个 MSS"恢复时通告,避免每块数据都回一个冗余 ACK
if (state == State.ESTABLISHED && before < mss && advertisedWindow() >= mss) {
sendAck()
}
}
}
runCatching { socket.shutdownOutput() }
} catch (e: Exception) {
Log.d(TAG, "上行结束 $key${e.message}")
finish()
}
}
private fun pumpDownstream(socket: Socket) {
try {
val input = socket.getInputStream()
val buffer = ByteArray(mss)
while (true) {
val read = input.read(buffer)
if (read < 0) break
VpnStateHolder.downloadCounter.addAndGet(read.toLong())
sendData(buffer, read)
}
sendFin()
} catch (e: Exception) {
Log.d(TAG, "下行结束 $key${e.message}")
if (state == State.ESTABLISHED) sendReset()
finish()
}
}
/** 把代理返回的数据切成 MSS 大小写回 TUN,并按对端窗口节流。 */
private fun sendData(data: ByteArray, length: Int) {
var offset = 0
while (offset < length) {
val chunk = min(mss, length - offset)
if (!awaitSendWindow(chunk)) throw IOException("会话已关闭")
synchronized(lock) {
if (state != State.ESTABLISHED) throw IOException("会话已关闭")
val sequence = sendNext
val size = PacketBuilder.writeTcp(
output = outputBuffer,
sourceIp = key.destIp,
sourcePort = key.destPort,
destIp = key.sourceIp,
destPort = key.sourcePort,
sequence = sequence,
acknowledgment = receiveNext,
flags = TcpHeader.ACK or TcpHeader.PSH,
window = advertisedWindow(),
payload = data,
payloadOffset = offset,
payloadLength = chunk,
)
tun.enqueue(outputBuffer, size)
sendNext = seqAdvance(sequence, chunk)
retransmitQueue.add(Segment(sequence, data.copyOfRange(offset, offset + chunk)))
}
offset += chunk
}
}
/** 等到窗口能容下 [needed] 字节;久等不到 ACK 就重传队首。@return false 表示会话已关闭。 */
private fun awaitSendWindow(needed: Int): Boolean {
synchronized(lock) {
var lastRetransmit = SystemClock.elapsedRealtime()
while (state == State.ESTABLISHED) {
val inflight = (sendNext - sendUnacked).toInt()
val allowed = min(max(peerWindow, mss), MAX_INFLIGHT)
if (inflight + needed <= allowed) return true
lock.wait(WINDOW_POLL_MS)
val now = SystemClock.elapsedRealtime()
if (now - lastRetransmit >= RETRANSMIT_TIMEOUT_MS) {
retransmitUnacknowledged()
lastRetransmit = now
}
}
return false
}
}
// --------------------------------------------------------- 报文发送
private fun sendSynAck() {
val size = PacketBuilder.writeTcp(
output = outputBuffer,
sourceIp = key.destIp,
sourcePort = key.destPort,
destIp = key.sourceIp,
destPort = key.sourcePort,
sequence = initialSequence,
acknowledgment = receiveNext,
flags = TcpHeader.SYN or TcpHeader.ACK,
window = advertisedWindow(),
mss = mss,
)
tun.enqueue(outputBuffer, size)
// SYN 自身占用一个序列号
if (sendNext == initialSequence) sendNext = seqAdvance(initialSequence, 1)
}
private fun sendAck() {
val size = PacketBuilder.writeTcp(
output = outputBuffer,
sourceIp = key.destIp,
sourcePort = key.destPort,
destIp = key.sourceIp,
destPort = key.sourcePort,
sequence = sendNext,
acknowledgment = receiveNext,
flags = TcpHeader.ACK,
window = advertisedWindow(),
)
tun.enqueue(outputBuffer, size)
}
private fun sendFin() {
synchronized(lock) {
if (finSent || state != State.ESTABLISHED) return
finSent = true
val size = PacketBuilder.writeTcp(
output = outputBuffer,
sourceIp = key.destIp,
sourcePort = key.destPort,
destIp = key.sourceIp,
destPort = key.sourcePort,
sequence = sendNext,
acknowledgment = receiveNext,
flags = TcpHeader.FIN or TcpHeader.ACK,
window = advertisedWindow(),
)
tun.enqueue(outputBuffer, size)
sendNext = seqAdvance(sendNext, 1)
}
}
private fun sendReset() {
// 独立缓冲区:这个方法可能在别的线程正操作 outputBuffer 时被调用
val buffer = ByteArray(IPV4_TCP_HEADER_SIZE)
val size = synchronized(lock) {
PacketBuilder.writeTcp(
output = buffer,
sourceIp = key.destIp,
sourcePort = key.destPort,
destIp = key.sourceIp,
destPort = key.sourcePort,
sequence = sendNext,
acknowledgment = receiveNext,
flags = TcpHeader.RST or TcpHeader.ACK,
window = 0,
)
}
tun.enqueue(buffer, size)
}
/** 调用方必须持有 [lock]。 */
private fun retransmitUnacknowledged() {
val first = retransmitQueue.firstOrNull() ?: return
val size = PacketBuilder.writeTcp(
output = outputBuffer,
sourceIp = key.destIp,
sourcePort = key.destPort,
destIp = key.sourceIp,
destPort = key.sourcePort,
sequence = first.sequence,
acknowledgment = receiveNext,
flags = TcpHeader.ACK or TcpHeader.PSH,
window = advertisedWindow(),
payload = first.data,
payloadOffset = 0,
payloadLength = first.data.size,
)
tun.enqueue(outputBuffer, size)
}
/** 丢弃已被确认的段。调用方必须持有 [lock]。 */
private fun releaseAcknowledged(acknowledgment: Long) {
if (!seqLessThan(sendUnacked, acknowledgment)) return
if (!seqLessOrEqual(acknowledgment, sendNext)) return
sendUnacked = acknowledgment
while (true) {
val segment = retransmitQueue.firstOrNull() ?: break
val end = seqAdvance(segment.sequence, segment.data.size)
if (seqLessOrEqual(end, acknowledgment)) retransmitQueue.removeFirst() else break
}
}
/** 剩余可用的接收窗口;上行积压时收缩,必要时通告零窗口让应用暂停发送。 */
private fun advertisedWindow(): Int =
(RECEIVE_WINDOW - pendingUpstreamBytes).coerceIn(0, RECEIVE_WINDOW)
fun finish() {
if (!closed.compareAndSet(false, true)) return
val wasEstablished = synchronized(lock) {
val established = state == State.ESTABLISHED
state = State.CLOSED
lock.notifyAll()
established
}
if (wasEstablished) VpnStateHolder.sessionCounter.decrementAndGet()
upstream.close()
runCatching { socket?.close() }
job?.cancel()
onFinished(key)
}
private companion object {
const val TAG = "TcpSession"
const val IPV4_TCP_HEADER_SIZE = 40
const val RECEIVE_WINDOW = 65535
const val MAX_INFLIGHT = 65535
const val UPSTREAM_QUEUE_SIZE = 64
const val RETRANSMIT_TIMEOUT_MS = 400L
const val WINDOW_POLL_MS = 100L
}
}
@@ -0,0 +1,6 @@
package tech.xvanturing.freeproxy.vpn
/** 把构造好的 IP 包送回 TUN 设备。实现方负责拷贝数据,调用后缓冲区即可复用。 */
interface TunWriter {
fun enqueue(packet: ByteArray, length: Int)
}
@@ -0,0 +1,249 @@
package tech.xvanturing.freeproxy.vpn
import android.os.ParcelFileDescriptor
import android.os.SystemClock
import android.util.Log
import tech.xvanturing.freeproxy.data.model.ProxyProfile
import tech.xvanturing.freeproxy.vpn.dns.DnsBlocker
import tech.xvanturing.freeproxy.vpn.net.Ipv4Header
import tech.xvanturing.freeproxy.vpn.net.PROTO_TCP
import tech.xvanturing.freeproxy.vpn.net.PROTO_UDP
import tech.xvanturing.freeproxy.vpn.net.PacketBuilder
import tech.xvanturing.freeproxy.vpn.net.SessionKey
import tech.xvanturing.freeproxy.vpn.net.TcpHeader
import tech.xvanturing.freeproxy.vpn.net.UdpHeader
import tech.xvanturing.freeproxy.vpn.net.seqAdvance
import tech.xvanturing.freeproxy.vpn.proxy.ProxyClient
import tech.xvanturing.freeproxy.vpn.proxy.SocketProtector
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.InetSocketAddress
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
* 隧道主循环:从 TUN 读 IP 包,按协议分发给会话,再把响应写回 TUN。
*
* 读、写各占一个专用线程;每条会话的阻塞式代理 IO 跑在一个可伸缩线程池上。
*/
class TunnelEngine(
private val tunInterface: ParcelFileDescriptor,
private val profile: ProxyProfile,
proxyAddress: InetSocketAddress,
private val mtu: Int,
private val protector: SocketProtector,
private val appResolver: AppResolver?,
private val dnsBlocker: DnsBlocker?,
) : TunWriter {
private val running = AtomicBoolean(false)
private val executor = Executors.newCachedThreadPool { runnable ->
Thread(runnable, "freeproxy-io").apply { isDaemon = true }
}
private val ioDispatcher = executor.asCoroutineDispatcher()
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
private val proxyClient = ProxyClient(profile, proxyAddress, protector)
private val tcpSessions = ConcurrentHashMap<SessionKey, TcpSession>()
private val udpSessions = ConcurrentHashMap<SessionKey, UdpSession>()
private val writeQueue = ArrayBlockingQueue<ByteArray>(WRITE_QUEUE_SIZE)
private var readerThread: Thread? = null
private var writerThread: Thread? = null
fun start() {
if (!running.compareAndSet(false, true)) return
readerThread = Thread(::readLoop, "freeproxy-tun-read").apply { start() }
writerThread = Thread(::writeLoop, "freeproxy-tun-write").apply { start() }
scope.launch { housekeepingLoop() }
}
fun stop() {
if (!running.compareAndSet(true, false)) return
tcpSessions.values.toList().forEach { it.finish() }
udpSessions.values.toList().forEach { it.finish() }
tcpSessions.clear()
udpSessions.clear()
scope.cancel()
readerThread?.interrupt()
writerThread?.interrupt()
executor.shutdownNow()
runCatching { tunInterface.close() }
}
// ------------------------------------------------------------- 读
private fun readLoop() {
val input = FileInputStream(tunInterface.fileDescriptor)
val buffer = ByteArray(mtu + HEADROOM)
try {
while (running.get()) {
val length = input.read(buffer)
if (length <= 0) continue
dispatch(buffer, length)
}
} catch (e: Exception) {
if (running.get()) Log.w(TAG, "TUN 读取中断:${e.message}")
} finally {
runCatching { input.close() }
}
}
private fun dispatch(buffer: ByteArray, length: Int) {
// 解析失败的包(含 IPv6、分片包)直接丢弃
val ip = Ipv4Header.parse(buffer, length) ?: return
when (ip.protocol) {
PROTO_TCP -> handleTcp(ip, buffer)
PROTO_UDP -> handleUdp(ip, buffer)
else -> Unit // ICMP 等不做处理:代理协议本身也承载不了
}
}
private fun handleTcp(ip: Ipv4Header, buffer: ByteArray) {
val tcp = TcpHeader.parse(buffer, ip.headerLength, ip.payloadLength) ?: return
val key = SessionKey(ip.sourceIp, tcp.sourcePort, ip.destIp, tcp.destPort)
val payloadOffset = ip.headerLength + tcp.dataOffset
val payloadLength = ip.totalLength - payloadOffset
if (payloadLength < 0) return
val existing = tcpSessions[key]
if (existing != null) {
existing.onPacket(tcp, buffer, payloadOffset, payloadLength)
return
}
// 新连接只能由 SYN 发起;其余情况说明会话已过期,回 RST 让对端立即放弃
if (!tcp.isSyn) {
if (!tcp.isRst) sendReset(key, tcp)
return
}
if (tcpSessions.size >= MAX_TCP_SESSIONS) {
Log.w(TAG, "TCP 会话数达到上限,拒绝新连接")
sendReset(key, tcp)
return
}
val session = TcpSession(
key = key,
scope = scope,
ioDispatcher = ioDispatcher,
proxyClient = proxyClient,
tun = this,
mtu = mtu,
appResolver = appResolver,
onFinished = { tcpSessions.remove(it) },
)
// putIfAbsent 防止 SYN 重传时并发建两条会话
val raced = tcpSessions.putIfAbsent(key, session)
if (raced != null) {
raced.onPacket(tcp, buffer, payloadOffset, payloadLength)
} else {
session.open(tcp)
}
}
private fun handleUdp(ip: Ipv4Header, buffer: ByteArray) {
val udp = UdpHeader.parse(buffer, ip.headerLength, ip.payloadLength) ?: return
val key = SessionKey(ip.sourceIp, udp.sourcePort, ip.destIp, udp.destPort)
val payloadOffset = ip.headerLength + UdpHeader.SIZE
val payloadLength = udp.payloadLength
if (payloadLength <= 0) return
val session = udpSessions[key] ?: run {
if (udpSessions.size >= MAX_UDP_SESSIONS) return
val created = UdpSession(
key = key,
scope = scope,
ioDispatcher = ioDispatcher,
proxyClient = proxyClient,
profile = profile,
protector = protector,
tun = this,
appResolver = appResolver,
dnsBlocker = dnsBlocker,
onFinished = { udpSessions.remove(it) },
)
udpSessions.putIfAbsent(key, created) ?: created
}
session.send(buffer.copyOfRange(payloadOffset, payloadOffset + payloadLength))
}
/** 对没有会话的报文回 RST,避免应用一直卡在连接超时上。 */
private fun sendReset(key: SessionKey, tcp: TcpHeader) {
val buffer = ByteArray(40)
val payloadEnd = if (tcp.isSyn) 1 else 0
val size = PacketBuilder.writeTcp(
output = buffer,
sourceIp = key.destIp,
sourcePort = key.destPort,
destIp = key.sourceIp,
destPort = key.sourcePort,
sequence = tcp.acknowledgment,
acknowledgment = seqAdvance(tcp.sequence, payloadEnd),
flags = TcpHeader.RST or TcpHeader.ACK,
window = 0,
)
enqueue(buffer, size)
}
// ------------------------------------------------------------- 写
override fun enqueue(packet: ByteArray, length: Int) {
if (!running.get()) return
// 队列满说明内核侧已经跟不上,丢弃比阻塞会话线程更好
if (!writeQueue.offer(packet.copyOf(length))) {
Log.w(TAG, "TUN 写队列已满,丢弃 1 个包")
}
}
private fun writeLoop() {
val output = FileOutputStream(tunInterface.fileDescriptor)
try {
while (running.get()) {
val packet = writeQueue.poll(500, TimeUnit.MILLISECONDS) ?: continue
output.write(packet)
}
} catch (e: Exception) {
if (running.get()) Log.w(TAG, "TUN 写入中断:${e.message}")
} finally {
runCatching { output.close() }
}
}
// --------------------------------------------------------- 定时维护
private suspend fun housekeepingLoop() {
while (scope.isActive && running.get()) {
delay(HOUSEKEEPING_INTERVAL_MS)
val now = SystemClock.elapsedRealtime()
tcpSessions.values.toList()
.filter { now - it.lastActivity > TCP_IDLE_TIMEOUT_MS }
.forEach { it.finish() }
udpSessions.values.toList()
.filter { now - it.lastActivity > it.idleTimeoutMs }
.forEach { it.finish() }
}
}
private companion object {
const val TAG = "TunnelEngine"
const val HEADROOM = 80
const val WRITE_QUEUE_SIZE = 1024
const val MAX_TCP_SESSIONS = 512
const val MAX_UDP_SESSIONS = 256
const val TCP_IDLE_TIMEOUT_MS = 300_000L
const val HOUSEKEEPING_INTERVAL_MS = 5_000L
}
}
@@ -0,0 +1,353 @@
package tech.xvanturing.freeproxy.vpn
// Modified for DragonTCP Lite: HTTP-proxy DNS is forced to Cloudflare 1.1.1.1 over TCP.
import android.os.SystemClock
import android.system.OsConstants
import android.util.Log
import tech.xvanturing.freeproxy.data.model.DnsMode
import tech.xvanturing.freeproxy.data.model.ProxyProfile
import tech.xvanturing.freeproxy.data.model.ProxyType
import tech.xvanturing.freeproxy.vpn.dns.DnsBlocker
import tech.xvanturing.freeproxy.vpn.log.TunnelLog
import tech.xvanturing.freeproxy.vpn.net.DnsMessage
import tech.xvanturing.freeproxy.vpn.net.DnsResponse
import tech.xvanturing.freeproxy.vpn.net.HostRegistry
import tech.xvanturing.freeproxy.vpn.net.PacketBuilder
import tech.xvanturing.freeproxy.vpn.net.SessionKey
import tech.xvanturing.freeproxy.vpn.net.toInetAddress
import tech.xvanturing.freeproxy.vpn.net.toIpv4Bytes
import tech.xvanturing.freeproxy.vpn.net.u8
import tech.xvanturing.freeproxy.vpn.proxy.ProxyClient
import tech.xvanturing.freeproxy.vpn.proxy.SocketProtector
import tech.xvanturing.freeproxy.vpn.proxy.UdpAssociation
import tech.xvanturing.freeproxy.vpn.proxy.readExactly
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.util.concurrent.atomic.AtomicBoolean
/**
* 一条 UDP "流"(四元组)的转发通道。
*
* 转发方式取决于配置:
* - SOCKS5 且开启 UDP:走 UDP ASSOCIATE,全协议支持;
* - 其余情况:只放行 DNS,并自动降级为 DNS over TCPRFC 7766)经代理查询,
* 这样即使上游只有 HTTP CONNECT,域名解析依然可用。
*
* 每个四元组独占一条转发通道 —— 共享一个中继 socket 会让回程包无法区分本地源端口。
*/
class UdpSession(
val key: SessionKey,
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher,
private val proxyClient: ProxyClient,
private val profile: ProxyProfile,
private val protector: SocketProtector,
private val tun: TunWriter,
private val appResolver: AppResolver?,
private val dnsBlocker: DnsBlocker?,
private val onFinished: (SessionKey) -> Unit,
) {
private val closed = AtomicBoolean(false)
private val useSocksUdp = profile.type == ProxyType.SOCKS5 && profile.udpOverSocks
private val isDns = key.destPort == DNS_PORT
private var packageResolved = false
private var cachedPackage: String? = null
@Volatile
private var association: UdpAssociation? = null
@Volatile
private var relaySocket: DatagramSocket? = null
@Volatile
private var started = false
@Volatile
var lastActivity: Long = SystemClock.elapsedRealtime()
private set
val idleTimeoutMs: Long get() = if (isDns) DNS_IDLE_MS else UDP_IDLE_MS
/** 转发一个从 TUN 收到的 UDP 载荷。 */
fun send(payload: ByteArray) {
lastActivity = SystemClock.elapsedRealtime()
if (isDns) {
logDnsQuery(payload)
if (dnsBlocker != null && tryBlockDns(payload)) return
}
when {
// 直连解析优先判断:这条路径完全不碰代理
isDns && profile.dnsMode == DnsMode.DIRECT -> sendDnsDirect(payload)
useSocksUdp -> sendOverSocks(payload)
isDns -> sendDnsOverTcp(payload)
else -> {
// 代理不支持 UDP:静默丢弃。应用侧通常会自行回退到 TCP。
Log.d(TAG, "丢弃 UDP(代理未启用 UDP 转发):$key")
finish()
}
}
}
// ------------------------------------------------------- SOCKS5 UDP
private fun sendOverSocks(payload: ByteArray) {
if (!started) {
started = true
scope.launch(ioDispatcher) {
if (!openAssociation()) {
finish()
return@launch
}
forward(payload)
receiveLoop()
}
} else {
scope.launch(ioDispatcher) { forward(payload) }
}
}
private fun openAssociation(): Boolean = try {
val assoc = proxyClient.openUdpAssociate()
val socket = DatagramSocket()
if (!protector.protect(socket)) {
socket.close()
assoc.close()
false
} else {
association = assoc
relaySocket = socket
true
}
} catch (e: Exception) {
Log.d(TAG, "UDP ASSOCIATE 失败 $key${e.message}")
false
}
private fun forward(payload: ByteArray) {
val socket = relaySocket ?: return
val relay = association?.relayAddress ?: return
try {
// SOCKS5 UDP 请求头:RSV(2) FRAG(1) ATYP ADDR PORT
val framed = ByteArrayOutputStream(payload.size + 10).apply {
write(0)
write(0)
write(0)
write(ProxyClient.ATYP_IPV4)
write(key.destIp.toIpv4Bytes())
write((key.destPort ushr 8) and 0xFF)
write(key.destPort and 0xFF)
write(payload)
}.toByteArray()
socket.send(DatagramPacket(framed, framed.size, relay))
VpnStateHolder.uploadCounter.addAndGet(payload.size.toLong())
} catch (e: Exception) {
Log.d(TAG, "UDP 发送失败 $key${e.message}")
finish()
}
}
private fun receiveLoop() {
val socket = relaySocket ?: return
val buffer = ByteArray(MAX_DATAGRAM)
val packet = DatagramPacket(buffer, buffer.size)
try {
while (!closed.get()) {
packet.setData(buffer, 0, buffer.size)
socket.receive(packet)
lastActivity = SystemClock.elapsedRealtime()
val payloadOffset = socksPayloadOffset(buffer, packet.length) ?: continue
val payloadLength = packet.length - payloadOffset
if (payloadLength <= 0) continue
VpnStateHolder.downloadCounter.addAndGet(payloadLength.toLong())
writeBackToTun(buffer, payloadOffset, payloadLength)
}
} catch (e: Exception) {
if (!closed.get()) Log.d(TAG, "UDP 接收结束 $key${e.message}")
} finally {
finish()
}
}
/** 跳过 SOCKS5 UDP 应答头,返回真实载荷的起始下标。 */
private fun socksPayloadOffset(buffer: ByteArray, length: Int): Int? {
if (length < 10) return null
var offset = 3 // RSV(2) + FRAG(1)
val addressType = buffer.u8(offset)
offset += 1
offset += when (addressType) {
ProxyClient.ATYP_IPV4 -> 4
ProxyClient.ATYP_IPV6 -> 16
ProxyClient.ATYP_DOMAIN -> {
if (offset >= length) return null
1 + buffer.u8(offset)
}
else -> return null
}
offset += 2 // 端口
return if (offset < length) offset else null
}
// -------------------------------------------------------- DNS / TCP
/**
* DNS 是一问一答,直接为每次查询开一条隧道:
* TCP 承载的 DNS 报文前面多两个字节的长度前缀。
*/
private fun sendDnsOverTcp(payload: ByteArray) {
scope.launch(ioDispatcher) {
try {
proxyClient.connectTcp(CLOUDFLARE_DNS, DNS_PORT).use { socket ->
socket.soTimeout = DNS_TIMEOUT_MS
socket.getOutputStream().apply {
write((payload.size ushr 8) and 0xFF)
write(payload.size and 0xFF)
write(payload)
flush()
}
VpnStateHolder.uploadCounter.addAndGet(payload.size.toLong())
val input = socket.getInputStream()
val header = input.readExactly(2)
val length = ((header[0].toInt() and 0xFF) shl 8) or (header[1].toInt() and 0xFF)
if (length in 1..MAX_DATAGRAM) {
val response = input.readExactly(length)
VpnStateHolder.downloadCounter.addAndGet(length.toLong())
writeBackToTun(response, 0, length)
}
}
} catch (e: Exception) {
Log.d(TAG, "DNS over TCP 失败 $key${e.message}")
} finally {
finish()
}
}
}
/**
* 本地直连解析:用一个 protect 过的 socket 直接问 DNS 服务器。
* 快,但查询内容对所在网络可见 —— 这是用户在配置里明确选择的取舍。
*/
private fun sendDnsDirect(payload: ByteArray) {
scope.launch(ioDispatcher) {
try {
DatagramSocket().use { socket ->
if (!protector.protect(socket)) return@launch
socket.soTimeout = DNS_TIMEOUT_MS
socket.send(
DatagramPacket(
payload,
payload.size,
key.destIp.toInetAddress(),
key.destPort,
),
)
VpnStateHolder.uploadCounter.addAndGet(payload.size.toLong())
val buffer = ByteArray(MAX_DATAGRAM)
val response = DatagramPacket(buffer, buffer.size)
socket.receive(response)
VpnStateHolder.downloadCounter.addAndGet(response.length.toLong())
writeBackToTun(buffer, 0, response.length)
}
} catch (e: Exception) {
Log.d(TAG, "直连 DNS 失败 $key${e.message}")
} finally {
finish()
}
}
}
// ------------------------------------------------------------ 日志
/**
* 命中拦截规则的查询不再转发,直接伪造一个应答回给应用。
*
* @return true 表示已拦截并作答,本次会话到此结束。
*/
private fun tryBlockDns(payload: ByteArray): Boolean {
val blocker = dnsBlocker ?: return false
val question = DnsMessage.readQuestion(payload, 0, payload.size) ?: return false
val packageName = resolvePackage()
val ip = blocker.resolve(question, packageName) ?: return false
val response = DnsResponse.buildBlockedResponse(payload, payload.size, ip) ?: return false
writeBackToTun(response, 0, response.size)
val rule = if (blocker.isAppBlocked(packageName)) "应用" else "域名"
TunnelLog.dns(
"DNS ${question.typeName} ${question.name}$ip(按$rule 拦截)",
packageName,
question.name,
)
finish()
return true
}
private fun logDnsQuery(payload: ByteArray) {
val question = DnsMessage.readQuestion(payload, 0, payload.size) ?: return
TunnelLog.dns(
"DNS ${question.typeName} ${question.name}",
resolvePackage(),
question.name,
)
}
/** UID 反查要跨进程,一条会话只做一次。 */
private fun resolvePackage(): String? {
if (!packageResolved) {
cachedPackage = appResolver?.resolve(OsConstants.IPPROTO_UDP, key)
packageResolved = true
}
return cachedPackage
}
/** 把 DNS 应答里的 A 记录喂给反查表,好让后续的连接日志显示域名。 */
private fun rememberDnsAnswers(payload: ByteArray, offset: Int, length: Int) {
if (!isDns) return
DnsMessage.readAnswers(payload, offset, length).forEach { (name, address) ->
HostRegistry.remember(address, name)
}
}
// ------------------------------------------------------------ 回写
private fun writeBackToTun(payload: ByteArray, offset: Int, length: Int) {
rememberDnsAnswers(payload, offset, length)
val output = ByteArray(28 + length)
val size = PacketBuilder.writeUdp(
output = output,
sourceIp = key.destIp,
sourcePort = key.destPort,
destIp = key.sourceIp,
destPort = key.sourcePort,
payload = payload,
payloadOffset = offset,
payloadLength = length,
)
tun.enqueue(output, size)
}
fun finish() {
if (!closed.compareAndSet(false, true)) return
// 关闭 socket 即可让阻塞中的 receive() 抛异常退出,无需再取消协程
runCatching { relaySocket?.close() }
runCatching { association?.close() }
onFinished(key)
}
private companion object {
const val TAG = "UdpSession"
val CLOUDFLARE_DNS: InetAddress = InetAddress.getByAddress(byteArrayOf(1, 1, 1, 1))
const val DNS_PORT = 53
const val DNS_TIMEOUT_MS = 10_000
const val MAX_DATAGRAM = 65507
const val DNS_IDLE_MS = 20_000L
const val UDP_IDLE_MS = 120_000L
}
}
@@ -0,0 +1,10 @@
package tech.xvanturing.freeproxy.vpn
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
object VpnStateHolder {
val uploadCounter = AtomicLong(0)
val downloadCounter = AtomicLong(0)
val sessionCounter = AtomicInteger(0)
}
@@ -0,0 +1,9 @@
package tech.xvanturing.freeproxy.vpn.dns
import tech.xvanturing.freeproxy.vpn.net.DnsQuestion
/** DNS blocking is not used by DragonTCP Lite; this stub preserves the stack API. */
class DnsBlocker {
fun resolve(question: DnsQuestion, packageName: String?): String? = null
fun isAppBlocked(packageName: String?): Boolean = false
}
@@ -0,0 +1,19 @@
package tech.xvanturing.freeproxy.vpn.log
enum class LogLevel { SUCCESS, FAILURE }
/**
* DragonTCP Lite intentionally keeps the Android UI log focused on DragonTCP
* adaptive chunk changes. Per-connection and per-DNS logs are no-ops here.
*/
object TunnelLog {
fun connect(
target: String,
packageName: String?,
status: String,
level: LogLevel,
domain: String? = null,
) = Unit
fun dns(message: String, packageName: String?, domain: String? = null) = Unit
}
@@ -0,0 +1,49 @@
package tech.xvanturing.freeproxy.vpn.net
import java.net.InetAddress
/** 网络字节序(大端)读写辅助。 */
internal fun ByteArray.u8(index: Int): Int = this[index].toInt() and 0xFF
internal fun ByteArray.u16(index: Int): Int = (u8(index) shl 8) or u8(index + 1)
/** 读 32 位无符号量;用 Long 承载以避开 Kotlin Int 的符号问题。 */
internal fun ByteArray.u32(index: Int): Long =
(u16(index).toLong() shl 16) or u16(index + 2).toLong()
/** IPv4 地址按 32 位整数读出,用作会话表的键既快又省内存。 */
internal fun ByteArray.ipv4(index: Int): Int =
(u8(index) shl 24) or (u8(index + 1) shl 16) or (u8(index + 2) shl 8) or u8(index + 3)
internal fun ByteArray.putU8(index: Int, value: Int) {
this[index] = (value and 0xFF).toByte()
}
internal fun ByteArray.putU16(index: Int, value: Int) {
this[index] = ((value ushr 8) and 0xFF).toByte()
this[index + 1] = (value and 0xFF).toByte()
}
internal fun ByteArray.putU32(index: Int, value: Long) {
this[index] = ((value ushr 24) and 0xFF).toByte()
this[index + 1] = ((value ushr 16) and 0xFF).toByte()
this[index + 2] = ((value ushr 8) and 0xFF).toByte()
this[index + 3] = (value and 0xFF).toByte()
}
internal fun ByteArray.putIpv4(index: Int, value: Int) {
putU32(index, value.toLong() and 0xFFFFFFFFL)
}
internal fun Int.toIpv4Bytes(): ByteArray = byteArrayOf(
((this ushr 24) and 0xFF).toByte(),
((this ushr 16) and 0xFF).toByte(),
((this ushr 8) and 0xFF).toByte(),
(this and 0xFF).toByte(),
)
internal fun Int.toInetAddress(): InetAddress = InetAddress.getByAddress(toIpv4Bytes())
internal fun Int.toIpv4String(): String =
"${(this ushr 24) and 0xFF}.${(this ushr 16) and 0xFF}.${(this ushr 8) and 0xFF}.${this and 0xFF}"
@@ -0,0 +1,32 @@
package tech.xvanturing.freeproxy.vpn.net
/** RFC 1071 定义的 16 位反码和。 */
object Checksum {
/** 对 [length] 字节做反码求和,[initial] 用于把伪头部的和接续进来。 */
fun compute(data: ByteArray, offset: Int, length: Int, initial: Long = 0L): Int {
var sum = initial
var index = offset
val end = offset + length
while (index + 1 < end) {
sum += data.u16(index)
index += 2
}
// 奇数长度时最后一字节按高位对齐补零
if (index < end) sum += data.u8(index) shl 8
while ((sum ushr 16) != 0L) sum = (sum and 0xFFFF) + (sum ushr 16)
return (sum.inv() and 0xFFFF).toInt()
}
/** TCP/UDP 校验和覆盖的伪头部:源地址、目的地址、协议号与传输层长度。 */
fun pseudoHeaderSum(sourceIp: Int, destIp: Int, protocol: Int, transportLength: Int): Long {
var sum = 0L
sum += ((sourceIp ushr 16) and 0xFFFF).toLong()
sum += (sourceIp and 0xFFFF).toLong()
sum += ((destIp ushr 16) and 0xFFFF).toLong()
sum += (destIp and 0xFFFF).toLong()
sum += protocol.toLong()
sum += transportLength.toLong()
return sum
}
}
@@ -0,0 +1,133 @@
package tech.xvanturing.freeproxy.vpn.net
/** DNS 查询的问题段。 */
data class DnsQuestion(val name: String, val type: Int) {
val typeName: String
get() = when (type) {
TYPE_A -> "A"
TYPE_AAAA -> "AAAA"
TYPE_CNAME -> "CNAME"
TYPE_HTTPS -> "HTTPS"
TYPE_TXT -> "TXT"
TYPE_PTR -> "PTR"
else -> "TYPE$type"
}
companion object {
const val TYPE_A = 1
const val TYPE_CNAME = 5
const val TYPE_PTR = 12
const val TYPE_TXT = 16
const val TYPE_AAAA = 28
const val TYPE_HTTPS = 65
}
}
/**
* 极简 DNS 报文读取器。
*
* 只取两样东西:查询里问的域名,以及应答里的 A 记录。
* 后者用来建立 IP → 域名的反查表,好让连接日志显示域名而不是一串裸 IP。
*/
object DnsMessage {
private const val HEADER_SIZE = 12
private const val MAX_POINTER_JUMPS = 16
/** 读取第一个 Question;不是合法查询时返回 null。 */
fun readQuestion(data: ByteArray, offset: Int, length: Int): DnsQuestion? {
if (length < HEADER_SIZE + 5) return null
val end = offset + length
val questionCount = data.u16(offset + 4)
if (questionCount < 1) return null
val (name, afterName) = readName(data, offset + HEADER_SIZE, offset, end) ?: return null
if (afterName + 4 > end) return null
return DnsQuestion(name, data.u16(afterName))
}
/** 问题段的结束位置(QCLASS 之后);伪造应答时在此截断并续写 Answer。 */
internal fun questionEnd(data: ByteArray, offset: Int, length: Int): Int? {
if (length < HEADER_SIZE + 5) return null
val end = offset + length
if (data.u16(offset + 4) < 1) return null
val (_, afterName) = readName(data, offset + HEADER_SIZE, offset, end) ?: return null
val afterQuestion = afterName + 4 // QTYPE + QCLASS
return if (afterQuestion <= end) afterQuestion else null
}
/** 读取应答里的全部 A 记录,返回 域名 → IPv4 的配对。 */
fun readAnswers(data: ByteArray, offset: Int, length: Int): List<Pair<String, Int>> {
if (length < HEADER_SIZE) return emptyList()
val end = offset + length
val questionCount = data.u16(offset + 4)
val answerCount = data.u16(offset + 6)
if (answerCount < 1) return emptyList()
var cursor = offset + HEADER_SIZE
repeat(questionCount) {
val (_, next) = readName(data, cursor, offset, end) ?: return emptyList()
cursor = next + 4 // QTYPE + QCLASS
if (cursor > end) return emptyList()
}
val results = mutableListOf<Pair<String, Int>>()
repeat(answerCount) {
val (name, afterName) = readName(data, cursor, offset, end) ?: return results
if (afterName + 10 > end) return results
val type = data.u16(afterName)
val dataLength = data.u16(afterName + 8)
val recordStart = afterName + 10
if (recordStart + dataLength > end) return results
if (type == DnsQuestion.TYPE_A && dataLength == 4) {
results += name to data.ipv4(recordStart)
}
cursor = recordStart + dataLength
}
return results
}
/**
* 读取一个可能被压缩的域名。
*
* @return 域名与"名字之后的位置";遇到压缩指针时,后者指向指针本身之后而不是跳转目标。
*/
private fun readName(
data: ByteArray,
start: Int,
messageStart: Int,
end: Int,
): Pair<String, Int>? {
val builder = StringBuilder()
var cursor = start
var afterName = -1
var jumps = 0
while (cursor < end) {
val labelLength = data.u8(cursor)
when {
labelLength == 0 -> {
if (afterName < 0) afterName = cursor + 1
return builder.toString() to afterName
}
// 高两位为 11 表示这是一个指向报文别处的压缩指针
(labelLength and 0xC0) == 0xC0 -> {
if (cursor + 1 >= end) return null
if (++jumps > MAX_POINTER_JUMPS) return null // 防御环形指针
if (afterName < 0) afterName = cursor + 2
cursor = messageStart + (((labelLength and 0x3F) shl 8) or data.u8(cursor + 1))
if (cursor < messageStart || cursor >= end) return null
}
else -> {
val labelStart = cursor + 1
if (labelStart + labelLength > end) return null
if (builder.isNotEmpty()) builder.append('.')
builder.append(String(data, labelStart, labelLength, Charsets.US_ASCII))
cursor = labelStart + labelLength
}
}
}
return null
}
}
@@ -0,0 +1,70 @@
package tech.xvanturing.freeproxy.vpn.net
/** 伪造 DNS 应答:拦截命中的查询不再转发,直接在隧道内作答。 */
object DnsResponse {
private const val HEADER_SIZE = 12
private const val ANSWER_TTL = 60
/** 应答 NAME 用压缩指针指回问题段开头(偏移 12)。 */
private const val NAME_POINTER_HI = 0xC0
private const val NAME_POINTER_LO = 0x0C
private const val FLAG_QR = 0x8000
private const val FLAG_RD = 0x0100
private const val FLAG_RA = 0x0080
/**
* 用 [ip] 给 [query] 造一个 NOERROR 应答。
*
* A 查询回一条指向 [ip] 的 A 记录;其余类型(AAAA、HTTPS 等)回空应答,
* 让查询方立即得到"没有记录",而不是等超时或改走别的解析通道漏出去。
*
* @return 报文不合法时返回 null,调用方应回退到正常转发。
*/
fun buildBlockedResponse(query: ByteArray, queryLen: Int, ip: String): ByteArray? {
if (queryLen < HEADER_SIZE || queryLen > query.size) return null
val question = DnsMessage.readQuestion(query, 0, queryLen) ?: return null
val questionEnd = DnsMessage.questionEnd(query, 0, queryLen) ?: return null
val address = parseIpv4(ip) ?: return null
val isA = question.type == DnsQuestion.TYPE_A
val answerSize = if (isA) 16 else 0 // NAME(2) TYPE CLASS TTL RDLENGTH(各2) RDATA(4)
// 只保留头 + 问题段:查询可能带 EDNS 等附加记录,直接续写 Answer 会把它们挤出原位
val response = query.copyOf(questionEnd + answerSize)
// 标志位:QR=1、OPCODE 与 RD 沿用查询、RA=1、RCODE=0
val flags = FLAG_QR or (query.u16(2) and (0x7800 or FLAG_RD)) or FLAG_RA
response.putU16(2, flags)
response.putU16(4, 1) // QDCOUNT
response.putU16(6, if (isA) 1 else 0) // ANCOUNT
response.putU16(8, 0) // NSCOUNT
response.putU16(10, 0) // ARCOUNT
if (isA) {
var cursor = questionEnd
response.putU8(cursor, NAME_POINTER_HI)
response.putU8(cursor + 1, NAME_POINTER_LO)
cursor += 2
response.putU16(cursor, DnsQuestion.TYPE_A)
response.putU16(cursor + 2, 1) // CLASS IN
response.putU32(cursor + 4, ANSWER_TTL.toLong())
response.putU16(cursor + 8, 4)
response.putIpv4(cursor + 10, address)
}
return response
}
/** 解析点分四段 IPv4;任何一段非法都视为不可用。 */
private fun parseIpv4(ip: String): Int? {
val parts = ip.trim().split('.')
if (parts.size != 4) return null
var address = 0
parts.forEach { part ->
val octet = part.toIntOrNull() ?: return null
if (octet !in 0..255) return null
address = (address shl 8) or octet
}
return address
}
}
@@ -0,0 +1,34 @@
package tech.xvanturing.freeproxy.vpn.net
/**
* IP → 域名的反查表,数据来自流经隧道的 DNS 应答。
*
* 隧道里看到的目标只有 IP,有了这张表,连接日志才能显示 `github.com:443`
* 而不是让人无从判断的 `140.82.121.4:443`。
*/
object HostRegistry {
private const val CAPACITY = 512
private val lock = Any()
// accessOrder = true 让 LinkedHashMap 按访问顺序淘汰,即最近用过的域名留得更久
private val names = object : LinkedHashMap<Int, String>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, String>): Boolean =
size > CAPACITY
}
fun remember(address: Int, name: String) {
if (name.isEmpty()) return
synchronized(lock) { names[address] = name }
}
fun lookup(address: Int): String? = synchronized(lock) { names[address] }
/** 有域名就用域名,没有就退回点分十进制。 */
fun describe(address: Int, port: Int): String = "${lookup(address) ?: address.toIpv4String()}:$port"
fun clear() {
synchronized(lock) { names.clear() }
}
}
@@ -0,0 +1,122 @@
package tech.xvanturing.freeproxy.vpn.net
const val PROTO_ICMP = 1
const val PROTO_TCP = 6
const val PROTO_UDP = 17
/** IPv4 首部。选项字段不解析,但 [headerLength] 已把它算在内。 */
class Ipv4Header(
val headerLength: Int,
val totalLength: Int,
val protocol: Int,
val sourceIp: Int,
val destIp: Int,
) {
val payloadLength: Int get() = totalLength - headerLength
companion object {
const val MIN_SIZE = 20
/** 解析失败返回 null(畸形包直接丢弃,不抛异常 —— 转发热路径上异常代价太高)。 */
fun parse(buffer: ByteArray, length: Int): Ipv4Header? {
if (length < MIN_SIZE) return null
val versionAndIhl = buffer.u8(0)
if ((versionAndIhl ushr 4) != 4) return null
val headerLength = (versionAndIhl and 0x0F) * 4
if (headerLength < MIN_SIZE || headerLength > length) return null
val totalLength = buffer.u16(2)
if (totalLength < headerLength || totalLength > length) return null
// 本栈不做分片重组:MF 置位或分片偏移非零的包一律丢弃。
// TUN 的 MTU 由我们自己设定,正常流量不会走到这里。
val fragmentField = buffer.u16(6)
val moreFragments = (fragmentField and 0x2000) != 0
val fragmentOffset = fragmentField and 0x1FFF
if (moreFragments || fragmentOffset != 0) return null
return Ipv4Header(
headerLength = headerLength,
totalLength = totalLength,
protocol = buffer.u8(9),
sourceIp = buffer.ipv4(12),
destIp = buffer.ipv4(16),
)
}
}
}
/** TCP 首部。 */
class TcpHeader(
val sourcePort: Int,
val destPort: Int,
val sequence: Long,
val acknowledgment: Long,
val dataOffset: Int,
val flags: Int,
val window: Int,
) {
val isFin: Boolean get() = (flags and FIN) != 0
val isSyn: Boolean get() = (flags and SYN) != 0
val isRst: Boolean get() = (flags and RST) != 0
val isAck: Boolean get() = (flags and ACK) != 0
override fun toString(): String = buildString {
if (isSyn) append("SYN ")
if (isAck) append("ACK ")
if (isFin) append("FIN ")
if (isRst) append("RST ")
append("seq=").append(sequence).append(" ack=").append(acknowledgment)
}
companion object {
const val MIN_SIZE = 20
const val FIN = 0x01
const val SYN = 0x02
const val RST = 0x04
const val PSH = 0x08
const val ACK = 0x10
const val URG = 0x20
fun parse(buffer: ByteArray, offset: Int, length: Int): TcpHeader? {
if (length < MIN_SIZE) return null
val dataOffset = ((buffer.u8(offset + 12) ushr 4) and 0x0F) * 4
if (dataOffset < MIN_SIZE || dataOffset > length) return null
return TcpHeader(
sourcePort = buffer.u16(offset),
destPort = buffer.u16(offset + 2),
sequence = buffer.u32(offset + 4),
acknowledgment = buffer.u32(offset + 8),
dataOffset = dataOffset,
flags = buffer.u8(offset + 13),
window = buffer.u16(offset + 14),
)
}
}
}
/** UDP 首部。 */
class UdpHeader(
val sourcePort: Int,
val destPort: Int,
val length: Int,
) {
val payloadLength: Int get() = length - SIZE
companion object {
const val SIZE = 8
fun parse(buffer: ByteArray, offset: Int, available: Int): UdpHeader? {
if (available < SIZE) return null
val length = buffer.u16(offset + 4)
if (length < SIZE || length > available) return null
return UdpHeader(
sourcePort = buffer.u16(offset),
destPort = buffer.u16(offset + 2),
length = length,
)
}
}
}
@@ -0,0 +1,125 @@
package tech.xvanturing.freeproxy.vpn.net
import java.util.concurrent.atomic.AtomicInteger
/**
* 构造写回 TUN 的 IPv4 数据包。
*
* 所有方法都把结果写进调用方提供的缓冲区并返回包长度,热路径上不额外分配。
*/
object PacketBuilder {
private const val DEFAULT_TTL = 64
private const val FLAG_DONT_FRAGMENT = 0x4000
private val identification = AtomicInteger(1)
/**
* 写入一个 IPv4 + TCP 包。
*
* [mss] 大于 0 时附加 MSS 选项 —— 只在 SYN-ACK 里需要,用于告诉本机内核
* 单个报文段的上限,避免它发出超过隧道 MTU 的数据。
*/
fun writeTcp(
output: ByteArray,
sourceIp: Int,
sourcePort: Int,
destIp: Int,
destPort: Int,
sequence: Long,
acknowledgment: Long,
flags: Int,
window: Int,
payload: ByteArray? = null,
payloadOffset: Int = 0,
payloadLength: Int = 0,
mss: Int = 0,
): Int {
val optionsLength = if (mss > 0) 4 else 0
val tcpLength = TcpHeader.MIN_SIZE + optionsLength + payloadLength
val totalLength = Ipv4Header.MIN_SIZE + tcpLength
writeIpv4Header(output, totalLength, PROTO_TCP, sourceIp, destIp)
val tcp = Ipv4Header.MIN_SIZE
output.putU16(tcp, sourcePort)
output.putU16(tcp + 2, destPort)
output.putU32(tcp + 4, sequence and 0xFFFFFFFFL)
output.putU32(tcp + 8, acknowledgment and 0xFFFFFFFFL)
output.putU8(tcp + 12, ((TcpHeader.MIN_SIZE + optionsLength) / 4) shl 4)
output.putU8(tcp + 13, flags)
output.putU16(tcp + 14, window)
output.putU16(tcp + 16, 0) // 校验和占位
output.putU16(tcp + 18, 0) // 紧急指针
if (optionsLength > 0) {
output.putU8(tcp + 20, 2) // kind = MSS
output.putU8(tcp + 21, 4) // length
output.putU16(tcp + 22, mss)
}
if (payload != null && payloadLength > 0) {
System.arraycopy(
payload,
payloadOffset,
output,
tcp + TcpHeader.MIN_SIZE + optionsLength,
payloadLength,
)
}
val pseudo = Checksum.pseudoHeaderSum(sourceIp, destIp, PROTO_TCP, tcpLength)
output.putU16(tcp + 16, Checksum.compute(output, tcp, tcpLength, pseudo))
return totalLength
}
/** 写入一个 IPv4 + UDP 包。 */
fun writeUdp(
output: ByteArray,
sourceIp: Int,
sourcePort: Int,
destIp: Int,
destPort: Int,
payload: ByteArray,
payloadOffset: Int,
payloadLength: Int,
): Int {
val udpLength = UdpHeader.SIZE + payloadLength
val totalLength = Ipv4Header.MIN_SIZE + udpLength
writeIpv4Header(output, totalLength, PROTO_UDP, sourceIp, destIp)
val udp = Ipv4Header.MIN_SIZE
output.putU16(udp, sourcePort)
output.putU16(udp + 2, destPort)
output.putU16(udp + 4, udpLength)
output.putU16(udp + 6, 0) // 校验和占位
System.arraycopy(payload, payloadOffset, output, udp + UdpHeader.SIZE, payloadLength)
val pseudo = Checksum.pseudoHeaderSum(sourceIp, destIp, PROTO_UDP, udpLength)
val checksum = Checksum.compute(output, udp, udpLength, pseudo)
// UDP 校验和为 0 表示"未计算",真值为 0 时按 RFC 768 写全 1
output.putU16(udp + 6, if (checksum == 0) 0xFFFF else checksum)
return totalLength
}
private fun writeIpv4Header(
output: ByteArray,
totalLength: Int,
protocol: Int,
sourceIp: Int,
destIp: Int,
) {
output.putU8(0, 0x45) // 版本 4,首部 5 个 32 位字
output.putU8(1, 0) // DSCP / ECN
output.putU16(2, totalLength)
output.putU16(4, identification.getAndIncrement() and 0xFFFF)
output.putU16(6, FLAG_DONT_FRAGMENT)
output.putU8(8, DEFAULT_TTL)
output.putU8(9, protocol)
output.putU16(10, 0) // 校验和占位
output.putIpv4(12, sourceIp)
output.putIpv4(16, destIp)
output.putU16(10, Checksum.compute(output, 0, Ipv4Header.MIN_SIZE))
}
}
@@ -0,0 +1,23 @@
package tech.xvanturing.freeproxy.vpn.net
/** 四元组,用作会话表的键。 */
data class SessionKey(
val sourceIp: Int,
val sourcePort: Int,
val destIp: Int,
val destPort: Int,
) {
override fun toString(): String =
"${sourceIp.toIpv4String()}:$sourcePort${destIp.toIpv4String()}:$destPort"
}
/**
* 序列号是模 2^32 的循环量,不能直接比大小。
* 这里用 32 位有符号差判断先后,正确处理回绕。
*/
internal fun seqLessThan(a: Long, b: Long): Boolean = (a - b).toInt() < 0
internal fun seqLessOrEqual(a: Long, b: Long): Boolean = (a - b).toInt() <= 0
/** 序列号前进 [delta] 字节,保持在 32 位范围内。 */
internal fun seqAdvance(seq: Long, delta: Int): Long = (seq + delta) and 0xFFFFFFFFL
@@ -0,0 +1,121 @@
package tech.xvanturing.freeproxy.vpn.proxy
import tech.xvanturing.freeproxy.data.model.ProxyProfile
import java.io.IOException
import java.io.InputStream
import java.net.DatagramSocket
import java.net.Inet6Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
/**
* Minimal HTTP CONNECT upstream client used by DragonTCP Lite.
*
* The upstream proxy is always the local DragonTCP Go process on
* 127.0.0.1:8080. DragonTCP then carries the stream over adaptive XOR-framed
* TCP/53 to the remote server.
*/
class ProxyClient(
private val profile: ProxyProfile,
private val proxyAddress: InetSocketAddress,
private val protector: SocketProtector,
) {
@Throws(IOException::class)
fun connectTcp(destination: InetAddress, destinationPort: Int): Socket {
val socket = Socket()
try {
socket.bind(InetSocketAddress(0))
if (!protector.protect(socket)) {
throw IOException("Unable to protect local proxy socket from VPN")
}
socket.connect(proxyAddress, CONNECT_TIMEOUT_MS)
socket.soTimeout = HANDSHAKE_TIMEOUT_MS
socket.tcpNoDelay = true
val literal = if (destination is Inet6Address) {
"[${destination.hostAddress}]:$destinationPort"
} else {
"${destination.hostAddress}:$destinationPort"
}
val request = buildString {
append("CONNECT ").append(literal).append(" HTTP/1.1\r\n")
append("Host: ").append(literal).append("\r\n")
append("Proxy-Connection: Keep-Alive\r\n")
append("\r\n")
}
socket.getOutputStream().apply {
write(request.toByteArray(Charsets.ISO_8859_1))
flush()
}
val input = socket.getInputStream()
val status = input.readLineCrLf()
while (true) {
val line = input.readLineCrLf()
if (line.isEmpty()) break
}
val code = status.split(' ').getOrNull(1)?.toIntOrNull()
?: throw IOException("Local DragonTCP proxy returned invalid response: $status")
if (code !in 200..299) {
throw IOException("Local DragonTCP CONNECT failed: $status")
}
socket.soTimeout = 0
return socket
} catch (t: Throwable) {
runCatching { socket.close() }
throw t
}
}
/** HTTP CONNECT does not support SOCKS5 UDP ASSOCIATE. */
@Throws(IOException::class)
fun openUdpAssociate(): UdpAssociation {
throw IOException("UDP ASSOCIATE unavailable with local HTTP CONNECT proxy")
}
companion object {
const val ATYP_IPV4 = 0x01
const val ATYP_DOMAIN = 0x03
const val ATYP_IPV6 = 0x04
private const val CONNECT_TIMEOUT_MS = 10_000
private const val HANDSHAKE_TIMEOUT_MS = 15_000
private const val MAX_HEADER_LINE = 8192
private fun InputStream.readLineCrLf(): String {
val out = StringBuilder()
while (true) {
val b = read()
if (b < 0) {
if (out.isEmpty()) throw IOException("Proxy closed connection during handshake")
break
}
if (b == '\n'.code) break
if (b != '\r'.code) out.append(b.toChar())
if (out.length > MAX_HEADER_LINE) throw IOException("Proxy response header too long")
}
return out.toString()
}
}
}
class UdpAssociation(
private val controlSocket: Socket = Socket(),
val relayAddress: InetSocketAddress = InetSocketAddress("127.0.0.1", 0),
) : AutoCloseable {
val isAlive: Boolean get() = !controlSocket.isClosed && controlSocket.isConnected
override fun close() { runCatching { controlSocket.close() } }
}
@Throws(IOException::class)
internal fun InputStream.readExactly(count: Int): ByteArray {
val buffer = ByteArray(count)
var offset = 0
while (offset < count) {
val n = read(buffer, offset, count - offset)
if (n < 0) throw IOException("Connection closed with ${count - offset} bytes remaining")
offset += n
}
return buffer
}
@@ -0,0 +1,15 @@
package tech.xvanturing.freeproxy.vpn.proxy
import java.net.DatagramSocket
import java.net.Socket
/**
* 把 socket 排除出隧道。
*
* 隧道建立后,本应用发往代理服务器的连接如果不加保护,会被系统重新路由回 TUN,
* 形成自我循环。[android.net.VpnService.protect] 就是用来打破这个循环的。
*/
interface SocketProtector {
fun protect(socket: Socket): Boolean
fun protect(socket: DatagramSocket): Boolean
}