V7
This commit is contained in:
@@ -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 TCP(RFC 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
|
||||
}
|
||||
Reference in New Issue
Block a user