V14
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); }
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package com.dragontcp.client;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.VpnService;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.InputStreamReader;
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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 ACTION_STATE = "com.dragontcp.client.STATE";
|
||||
public static final String EXTRA_ACTIVE = "active";
|
||||
public static final String EXTRA_STATUS = "status";
|
||||
|
||||
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";
|
||||
/** Maximum download records per request. A transport setting, not a thread count. */
|
||||
public static final String EXTRA_BATCH_MAX = "batchMax";
|
||||
/** Minimum download records per request; equal to the maximum pins the depth. */
|
||||
public static final String EXTRA_BATCH_MIN = "batchMin";
|
||||
public static final String EXTRA_RECONNECT = "reconnect";
|
||||
public static final String EXTRA_TIMEOUT = "timeout";
|
||||
|
||||
private static final int BATCH_LIMIT = 256;
|
||||
|
||||
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 static volatile boolean appActive;
|
||||
private static volatile String appStatus = "DISCONNECTED";
|
||||
|
||||
public static boolean isActive() { return appActive; }
|
||||
public static String currentStatus() { return appStatus; }
|
||||
|
||||
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;
|
||||
private volatile boolean cancelRequested;
|
||||
|
||||
@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)) {
|
||||
cancelRequested = true;
|
||||
publishState("STOPPING", true);
|
||||
new Thread(() -> stopEverything("Stopped"), "dragontcp-stop").start();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
if (ACTION_CONNECT.equals(action)) {
|
||||
synchronized (stateLock) {
|
||||
// UI disabling is backed by a service-side guard too. A duplicate
|
||||
// CONNECT can no longer tear down and restart a healthy tunnel.
|
||||
if (appActive || connected || coreProcess != null || tunnelEngine != null) {
|
||||
publishState(connected ? "CONNECTED" : "CONNECTING", true);
|
||||
return START_STICKY;
|
||||
}
|
||||
cancelRequested = false;
|
||||
publishState("CONNECTING", true);
|
||||
}
|
||||
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 startEverything(Intent intent) {
|
||||
synchronized (stateLock) {
|
||||
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 batchMax = intent.getIntExtra(EXTRA_BATCH_MAX, 1);
|
||||
int batchMin = intent.getIntExtra(EXTRA_BATCH_MIN, 1);
|
||||
int reconnect = intent.getIntExtra(EXTRA_RECONNECT, 0);
|
||||
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));
|
||||
batchMax = Math.max(1, Math.min(BATCH_LIMIT, batchMax));
|
||||
batchMin = Math.max(1, Math.min(batchMax, batchMin));
|
||||
reconnect = Math.max(0, reconnect);
|
||||
timeout = Math.max(1, timeout);
|
||||
|
||||
try {
|
||||
AppLog.append("Starting DragonTCP → " + server + ":" + port);
|
||||
AppLog.append(describeBatch(batchMin, batchMax));
|
||||
Process process = startDragonCore(server, port, token, chunkMax, chunkMin, batchMax, batchMin, reconnect, timeout);
|
||||
synchronized (stateLock) { coreProcess = process; }
|
||||
|
||||
startCoreLogReader(process);
|
||||
waitForLocalProxy(process);
|
||||
if (cancelRequested) throw new InterruptedException("Stop requested");
|
||||
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) {
|
||||
if (cancelRequested) {
|
||||
try { pfd.close(); } catch (Throwable ignored) {}
|
||||
throw new InterruptedException("Stop requested");
|
||||
}
|
||||
tunFd = pfd;
|
||||
tunnelEngine = engine;
|
||||
connected = true;
|
||||
}
|
||||
engine.start();
|
||||
publishState("CONNECTED", true);
|
||||
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 (InterruptedException stopped) {
|
||||
Thread.currentThread().interrupt();
|
||||
stopEverything(null);
|
||||
} catch (Throwable t) {
|
||||
failStart(t.getMessage() != null ? t.getMessage() : t.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private Process startDragonCore(
|
||||
String server,
|
||||
int port,
|
||||
String token,
|
||||
int chunkMax,
|
||||
int chunkMin,
|
||||
int batchMax,
|
||||
int batchMin,
|
||||
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-concurrency"); cmd.add(Integer.toString(batchMax));
|
||||
cmd.add("--chunk-concurrency-min"); cmd.add(Integer.toString(batchMin));
|
||||
cmd.add("--chunk-reconnect-every"); cmd.add(Integer.toString(reconnect));
|
||||
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();
|
||||
}
|
||||
|
||||
/** Human-readable summary of the download batch configuration, for the log screen. */
|
||||
private static String describeBatch(int min, int max) {
|
||||
if (min == max) {
|
||||
return max == 1
|
||||
? "Download batch: fixed at 1 record per request"
|
||||
: "Download batch: pinned at " + max + " records per request (never adapts)";
|
||||
}
|
||||
return "Download batch: adaptive " + min + "-" + max + " records per request";
|
||||
}
|
||||
|
||||
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 ") || line.startsWith("path probe:") || 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 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 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);
|
||||
publishState("DISCONNECTED", false);
|
||||
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 publishState(String status, boolean active) {
|
||||
appStatus = status == null ? (active ? "CONNECTED" : "DISCONNECTED") : status;
|
||||
appActive = active;
|
||||
Intent state = new Intent(ACTION_STATE);
|
||||
state.setPackage(getPackageName());
|
||||
state.putExtra(EXTRA_ACTIVE, active);
|
||||
state.putExtra(EXTRA_STATUS, appStatus);
|
||||
try { sendBroadcast(state); } catch (Throwable ignored) {}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.dragontcp.client;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.Window;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
|
||||
public class LogActivity extends Activity {
|
||||
private static final int BG = Color.rgb(11, 15, 20);
|
||||
private static final int CARD = Color.rgb(22, 28, 36);
|
||||
private static final int BORDER = Color.rgb(43, 53, 66);
|
||||
private static final int TEXT = Color.rgb(244, 247, 251);
|
||||
private static final int MUTED = Color.rgb(139, 151, 168);
|
||||
private static final int LOG_BG = Color.rgb(7, 10, 14);
|
||||
private static final int LOG_TEXT = Color.rgb(179, 194, 214);
|
||||
|
||||
private TextView logText;
|
||||
private ScrollView logScroll;
|
||||
|
||||
private final AppLog.Listener logListener = line -> runOnUiThread(() -> appendLogLine(line));
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
forceDarkSystemUi();
|
||||
buildUi();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
logText.setText(AppLog.history());
|
||||
AppLog.addListener(logListener);
|
||||
logScroll.post(this::scrollLogToBottom);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
AppLog.removeListener(logListener);
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
private void forceDarkSystemUi() {
|
||||
Window w = getWindow();
|
||||
w.setStatusBarColor(BG);
|
||||
w.setNavigationBarColor(Color.rgb(7, 10, 14));
|
||||
if (Build.VERSION.SDK_INT >= 26) w.getDecorView().setSystemUiVisibility(0);
|
||||
}
|
||||
|
||||
private void buildUi() {
|
||||
LinearLayout root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setBackgroundColor(BG);
|
||||
root.setPadding(dp(16), dp(14), dp(16), dp(12));
|
||||
|
||||
LinearLayout header = new LinearLayout(this);
|
||||
header.setOrientation(LinearLayout.HORIZONTAL);
|
||||
header.setGravity(Gravity.CENTER_VERTICAL);
|
||||
header.setPadding(dp(2), dp(2), dp(2), dp(12));
|
||||
|
||||
LinearLayout heading = new LinearLayout(this);
|
||||
heading.setOrientation(LinearLayout.VERTICAL);
|
||||
TextView title = text("Logs", 25, TEXT, true);
|
||||
TextView subtitle = text("DragonTCP Lite", 12, MUTED, false);
|
||||
subtitle.setPadding(0, dp(2), 0, 0);
|
||||
heading.addView(title);
|
||||
heading.addView(subtitle);
|
||||
header.addView(heading, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
|
||||
|
||||
Button back = smallButton("BACK");
|
||||
Button clear = smallButton("CLEAR");
|
||||
LinearLayout.LayoutParams bp = new LinearLayout.LayoutParams(dp(76), dp(42));
|
||||
bp.setMarginEnd(dp(8));
|
||||
header.addView(back, bp);
|
||||
header.addView(clear, new LinearLayout.LayoutParams(dp(82), dp(42)));
|
||||
root.addView(header);
|
||||
|
||||
LinearLayout panel = new LinearLayout(this);
|
||||
panel.setOrientation(LinearLayout.VERTICAL);
|
||||
panel.setPadding(dp(10), dp(10), dp(10), dp(10));
|
||||
panel.setBackground(roundRect(CARD, 16, BORDER, 1));
|
||||
|
||||
logScroll = new ScrollView(this);
|
||||
logScroll.setFillViewport(true);
|
||||
logScroll.setFocusable(false);
|
||||
logScroll.setOverScrollMode(View.OVER_SCROLL_IF_CONTENT_SCROLLS);
|
||||
logScroll.setBackground(roundRect(LOG_BG, 11, Color.rgb(30, 38, 49), 1));
|
||||
|
||||
logText = new TextView(this);
|
||||
logText.setTextSize(12f);
|
||||
logText.setTextColor(LOG_TEXT);
|
||||
logText.setTypeface(Typeface.MONOSPACE);
|
||||
logText.setTextIsSelectable(true);
|
||||
logText.setPadding(dp(11), dp(10), dp(11), dp(10));
|
||||
logText.setLineSpacing(0, 1.10f);
|
||||
logScroll.addView(logText, new ScrollView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
));
|
||||
|
||||
panel.addView(logScroll, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f
|
||||
));
|
||||
root.addView(panel, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f
|
||||
));
|
||||
|
||||
back.setOnClickListener(v -> finish());
|
||||
clear.setOnClickListener(v -> {
|
||||
AppLog.clear();
|
||||
logText.setText("");
|
||||
logScroll.scrollTo(0, 0);
|
||||
});
|
||||
|
||||
setContentView(root);
|
||||
}
|
||||
|
||||
private void appendLogLine(String line) {
|
||||
View child = logScroll.getChildAt(0);
|
||||
int contentHeight = child == null ? 0 : child.getHeight();
|
||||
int maxScroll = Math.max(0, contentHeight - logScroll.getHeight());
|
||||
boolean follow = maxScroll - logScroll.getScrollY() < dp(48);
|
||||
|
||||
logText.append(line + "\n");
|
||||
if (follow) logScroll.post(this::scrollLogToBottom);
|
||||
}
|
||||
|
||||
private void scrollLogToBottom() {
|
||||
View child = logScroll.getChildAt(0);
|
||||
if (child == null) return;
|
||||
int y = Math.max(0, child.getHeight() - logScroll.getHeight());
|
||||
logScroll.scrollTo(0, y);
|
||||
}
|
||||
|
||||
private TextView text(String value, float size, int color, boolean bold) {
|
||||
TextView t = new TextView(this);
|
||||
t.setText(value);
|
||||
t.setTextSize(size);
|
||||
t.setTextColor(color);
|
||||
if (bold) t.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
|
||||
return t;
|
||||
}
|
||||
|
||||
private Button smallButton(String value) {
|
||||
Button b = new Button(this);
|
||||
b.setText(value);
|
||||
b.setAllCaps(false);
|
||||
b.setTextSize(11);
|
||||
b.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
|
||||
b.setTextColor(TEXT);
|
||||
b.setBackground(roundRect(Color.rgb(32, 40, 51), 10, BORDER, 1));
|
||||
return b;
|
||||
}
|
||||
|
||||
private GradientDrawable roundRect(int color, int radiusDp, int strokeColor, int strokeDp) {
|
||||
GradientDrawable d = new GradientDrawable();
|
||||
d.setColor(color);
|
||||
d.setCornerRadius(dp(radiusDp));
|
||||
if (strokeDp > 0) d.setStroke(dp(strokeDp), strokeColor);
|
||||
return d;
|
||||
}
|
||||
|
||||
private int dp(int value) {
|
||||
return Math.round(value * getResources().getDisplayMetrics().density);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
package com.dragontcp.client;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.net.VpnService;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.InputType;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.Window;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
public class MainActivity extends Activity {
|
||||
private static final int VPN_REQUEST = 100;
|
||||
private static final String PREFS = "dragontcp";
|
||||
|
||||
private static final int BATCH_LIMIT = 256;
|
||||
|
||||
private static final int BG = Color.rgb(11, 15, 20);
|
||||
private static final int CARD = Color.rgb(22, 28, 36);
|
||||
private static final int FIELD = Color.rgb(14, 19, 26);
|
||||
private static final int BORDER = Color.rgb(43, 53, 66);
|
||||
private static final int TEXT = Color.rgb(244, 247, 251);
|
||||
private static final int MUTED = Color.rgb(139, 151, 168);
|
||||
private static final int ACCENT = Color.rgb(92, 149, 255);
|
||||
private static final int STOP = Color.rgb(218, 83, 83);
|
||||
private static final int DISABLED = Color.rgb(48, 56, 68);
|
||||
private static final int OK = Color.rgb(82, 201, 143);
|
||||
private static final int WARN = Color.rgb(240, 177, 83);
|
||||
private static final int BAD = Color.rgb(232, 116, 116);
|
||||
|
||||
private EditText server;
|
||||
private EditText port;
|
||||
private EditText token;
|
||||
private EditText chunkMax;
|
||||
private EditText chunkMin;
|
||||
private EditText batchMax;
|
||||
private EditText batchMin;
|
||||
private EditText reconnect;
|
||||
private EditText timeout;
|
||||
|
||||
private TextView batchHint;
|
||||
private Button connectButton;
|
||||
private Button stopButton;
|
||||
private Button logsButton;
|
||||
private TextView statusChip;
|
||||
|
||||
private boolean receiverRegistered;
|
||||
private boolean connectPending;
|
||||
|
||||
private final BroadcastReceiver stateReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (!DragonService.ACTION_STATE.equals(intent.getAction())) return;
|
||||
boolean active = intent.getBooleanExtra(DragonService.EXTRA_ACTIVE, false);
|
||||
String status = intent.getStringExtra(DragonService.EXTRA_STATUS);
|
||||
connectPending = false;
|
||||
updateConnectionUi(active, status == null ? (active ? "CONNECTED" : "DISCONNECTED") : status);
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
forceDarkSystemUi();
|
||||
buildUi();
|
||||
loadSettings();
|
||||
updateBatchHint();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
if (!receiverRegistered) {
|
||||
registerReceiver(stateReceiver, new IntentFilter(DragonService.ACTION_STATE));
|
||||
receiverRegistered = true;
|
||||
}
|
||||
updateConnectionUi(DragonService.isActive(), DragonService.currentStatus());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
if (receiverRegistered) {
|
||||
try { unregisterReceiver(stateReceiver); } catch (Throwable ignored) {}
|
||||
receiverRegistered = false;
|
||||
}
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
private void forceDarkSystemUi() {
|
||||
Window w = getWindow();
|
||||
w.setStatusBarColor(BG);
|
||||
w.setNavigationBarColor(Color.rgb(7, 10, 14));
|
||||
if (Build.VERSION.SDK_INT >= 26) w.getDecorView().setSystemUiVisibility(0);
|
||||
}
|
||||
|
||||
private void buildUi() {
|
||||
LinearLayout root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setBackgroundColor(BG);
|
||||
root.setPadding(dp(16), dp(14), dp(16), dp(12));
|
||||
|
||||
LinearLayout header = new LinearLayout(this);
|
||||
header.setOrientation(LinearLayout.HORIZONTAL);
|
||||
header.setGravity(Gravity.CENTER_VERTICAL);
|
||||
header.setPadding(dp(2), dp(2), dp(2), dp(12));
|
||||
|
||||
LinearLayout heading = new LinearLayout(this);
|
||||
heading.setOrientation(LinearLayout.VERTICAL);
|
||||
TextView title = text("DragonTCP Lite", 25, TEXT, true);
|
||||
TextView subtitle = text("Adaptive binary tunnel over TCP/53", 12, MUTED, false);
|
||||
subtitle.setPadding(0, dp(2), 0, 0);
|
||||
heading.addView(title);
|
||||
heading.addView(subtitle);
|
||||
header.addView(heading, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
|
||||
|
||||
statusChip = text("DISCONNECTED", 11, MUTED, true);
|
||||
statusChip.setGravity(Gravity.CENTER);
|
||||
statusChip.setPadding(dp(12), dp(7), dp(12), dp(7));
|
||||
statusChip.setBackground(roundRect(CARD, 999, BORDER, 1));
|
||||
header.addView(statusChip);
|
||||
root.addView(header);
|
||||
|
||||
ScrollView scroll = new ScrollView(this);
|
||||
scroll.setFillViewport(false);
|
||||
scroll.setOverScrollMode(View.OVER_SCROLL_IF_CONTENT_SCROLLS);
|
||||
scroll.setVerticalScrollBarEnabled(false);
|
||||
|
||||
LinearLayout settings = new LinearLayout(this);
|
||||
settings.setOrientation(LinearLayout.VERTICAL);
|
||||
settings.setPadding(0, 0, 0, dp(12));
|
||||
scroll.addView(settings, new ScrollView.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
));
|
||||
|
||||
// ---------------------------------------------------------- connection
|
||||
LinearLayout connectionCard = card("CONNECTION");
|
||||
server = addField(connectionCard, "Server", "Server IP or hostname", "", false, false);
|
||||
LinearLayout connectionRow = row();
|
||||
port = addFieldToRow(connectionRow, "Port", "53", "53", true, false, 0.34f);
|
||||
token = addFieldToRow(connectionRow, "Token", "Optional", "", false, true, 0.66f);
|
||||
connectionCard.addView(connectionRow);
|
||||
settings.addView(connectionCard, cardParams());
|
||||
|
||||
// --------------------------------------------------------- record size
|
||||
LinearLayout sizeCard = card("RECORD SIZE (BYTES)");
|
||||
LinearLayout chunks = row();
|
||||
chunkMax = addFieldToRow(chunks, "Max chunk", "1048576", "1048576", true, false, 0.58f);
|
||||
chunkMin = addFieldToRow(chunks, "Min chunk", "32", "32", true, false, 0.42f);
|
||||
sizeCard.addView(chunks);
|
||||
sizeCard.addView(hint("Probed automatically on connect, then adapted if the path changes."));
|
||||
settings.addView(sizeCard, cardParams());
|
||||
|
||||
// ------------------------------------------------------- download batch
|
||||
LinearLayout batchCard = card("DOWNLOAD BATCH");
|
||||
batchCard.addView(hint(
|
||||
"How many records one download request may return. This is a transport "
|
||||
+ "setting, not a thread count."
|
||||
));
|
||||
LinearLayout batch = row();
|
||||
batchMax = addFieldToRow(batch, "Batch max", "1", "1", true, false, 0.5f);
|
||||
batchMin = addFieldToRow(batch, "Batch min", "1", "1", true, false, 0.5f);
|
||||
batchCard.addView(batch);
|
||||
|
||||
batchHint = text("", 11, MUTED, true);
|
||||
batchHint.setLineSpacing(0, 1.08f);
|
||||
batchHint.setPadding(dp(2), dp(2), dp(2), dp(2));
|
||||
batchCard.addView(batchHint);
|
||||
batchCard.addView(hint(
|
||||
"Set both to the same number to pin the batch: the depth never grows or "
|
||||
+ "shrinks, which is what paths that only work at one specific size need."
|
||||
));
|
||||
settings.addView(batchCard, cardParams());
|
||||
|
||||
TextWatcher batchWatcher = new TextWatcher() {
|
||||
@Override public void beforeTextChanged(CharSequence s, int a, int b, int c) {}
|
||||
@Override public void onTextChanged(CharSequence s, int a, int b, int c) {}
|
||||
@Override public void afterTextChanged(Editable s) { updateBatchHint(); }
|
||||
};
|
||||
batchMax.addTextChangedListener(batchWatcher);
|
||||
batchMin.addTextChangedListener(batchWatcher);
|
||||
|
||||
// ------------------------------------------------------------ advanced
|
||||
LinearLayout advancedCard = card("ADVANCED");
|
||||
LinearLayout timing = row();
|
||||
reconnect = addFieldToRow(timing, "Reconnect every", "0 = persistent", "0", true, false, 0.58f);
|
||||
timeout = addFieldToRow(timing, "Timeout (s)", "2", "2", true, false, 0.42f);
|
||||
advancedCard.addView(timing);
|
||||
advancedCard.addView(hint("0 reconnect = persistent • 1 = auto • N = rotate every N requests"));
|
||||
settings.addView(advancedCard, cardParams());
|
||||
|
||||
// ------------------------------------------------------------- buttons
|
||||
LinearLayout buttons = row();
|
||||
buttons.setPadding(0, dp(4), 0, dp(8));
|
||||
connectButton = actionButton("CONNECT");
|
||||
stopButton = actionButton("STOP");
|
||||
LinearLayout.LayoutParams left = new LinearLayout.LayoutParams(0, dp(52), 1f);
|
||||
left.setMarginEnd(dp(6));
|
||||
LinearLayout.LayoutParams right = new LinearLayout.LayoutParams(0, dp(52), 1f);
|
||||
right.setMarginStart(dp(6));
|
||||
buttons.addView(connectButton, left);
|
||||
buttons.addView(stopButton, right);
|
||||
settings.addView(buttons);
|
||||
|
||||
logsButton = actionButton("OPEN LOGS");
|
||||
logsButton.setTextColor(TEXT);
|
||||
logsButton.setBackground(roundRect(Color.rgb(32, 40, 51), 14, BORDER, 1));
|
||||
LinearLayout.LayoutParams logsParams = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(50));
|
||||
logsParams.setMargins(0, 0, 0, dp(10));
|
||||
settings.addView(logsButton, logsParams);
|
||||
|
||||
TextView note = text(
|
||||
"IPv4 uses the Android VPN adapter. IPv6 is captured and blocked to prevent bypass. DNS is tunneled to 1.1.1.1 over DragonTCP.",
|
||||
11, MUTED, false
|
||||
);
|
||||
note.setLineSpacing(0, 1.08f);
|
||||
note.setPadding(dp(4), dp(2), dp(4), dp(6));
|
||||
settings.addView(note);
|
||||
|
||||
root.addView(scroll, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0,
|
||||
1f
|
||||
));
|
||||
|
||||
connectButton.setOnClickListener(v -> requestConnect());
|
||||
stopButton.setOnClickListener(v -> requestStop());
|
||||
logsButton.setOnClickListener(v -> startActivity(new Intent(this, LogActivity.class)));
|
||||
|
||||
setContentView(root);
|
||||
updateConnectionUi(false, "DISCONNECTED");
|
||||
}
|
||||
|
||||
/** Describes the batch configuration in words, live, as the user types. */
|
||||
private void updateBatchHint() {
|
||||
if (batchHint == null) return;
|
||||
Integer max = readInt(batchMax);
|
||||
Integer min = readInt(batchMin);
|
||||
|
||||
if (max == null || min == null) {
|
||||
batchHint.setTextColor(MUTED);
|
||||
batchHint.setText("Enter a value from 1 to " + BATCH_LIMIT + ".");
|
||||
return;
|
||||
}
|
||||
if (max < 1 || max > BATCH_LIMIT || min < 1 || min > BATCH_LIMIT) {
|
||||
batchHint.setTextColor(BAD);
|
||||
batchHint.setText("Batch values must be 1-" + BATCH_LIMIT + ".");
|
||||
return;
|
||||
}
|
||||
if (min > max) {
|
||||
batchHint.setTextColor(BAD);
|
||||
batchHint.setText("Batch min must not be greater than batch max.");
|
||||
return;
|
||||
}
|
||||
if (min == max) {
|
||||
batchHint.setTextColor(OK);
|
||||
if (max == 1) {
|
||||
batchHint.setText("Fixed: 1 record per request, never adapts.");
|
||||
} else {
|
||||
batchHint.setText("Pinned: exactly " + max + " records per request, never adapts.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
batchHint.setTextColor(ACCENT);
|
||||
batchHint.setText("Adaptive: starts at " + max + ", falls back toward " + min
|
||||
+ " on errors, recovers to " + max + ".");
|
||||
}
|
||||
|
||||
private Integer readInt(EditText field) {
|
||||
if (field == null) return null;
|
||||
String raw = field.getText().toString().trim();
|
||||
if (raw.isEmpty()) return null;
|
||||
try { return Integer.valueOf(Integer.parseInt(raw)); }
|
||||
catch (Exception e) { return null; }
|
||||
}
|
||||
|
||||
private LinearLayout card(String title) {
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.setPadding(dp(13), dp(11), dp(13), dp(12));
|
||||
card.setBackground(roundRect(CARD, 16, BORDER, 1));
|
||||
TextView label = text(title, 11, MUTED, true);
|
||||
label.setLetterSpacing(0.08f);
|
||||
label.setPadding(dp(2), 0, 0, dp(7));
|
||||
card.addView(label);
|
||||
return card;
|
||||
}
|
||||
|
||||
private TextView hint(String value) {
|
||||
TextView t = text(value, 11, MUTED, false);
|
||||
t.setLineSpacing(0, 1.08f);
|
||||
t.setPadding(dp(2), dp(2), dp(2), dp(6));
|
||||
return t;
|
||||
}
|
||||
|
||||
private LinearLayout.LayoutParams cardParams() {
|
||||
LinearLayout.LayoutParams p = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
p.setMargins(0, 0, 0, dp(10));
|
||||
return p;
|
||||
}
|
||||
|
||||
private EditText addField(LinearLayout parent, String label, String hint, String value, boolean numeric, boolean password) {
|
||||
LinearLayout block = fieldBlock(label);
|
||||
EditText edit = createEdit(hint, value, numeric, password);
|
||||
block.addView(edit, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(46)));
|
||||
parent.addView(block, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
return edit;
|
||||
}
|
||||
|
||||
private EditText addFieldToRow(LinearLayout parent, String label, String hint, String value, boolean numeric, boolean password, float weight) {
|
||||
LinearLayout block = fieldBlock(label);
|
||||
EditText edit = createEdit(hint, value, numeric, password);
|
||||
block.addView(edit, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(46)));
|
||||
LinearLayout.LayoutParams p = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, weight);
|
||||
if (parent.getChildCount() > 0) p.setMarginStart(dp(8));
|
||||
parent.addView(block, p);
|
||||
return edit;
|
||||
}
|
||||
|
||||
private LinearLayout fieldBlock(String label) {
|
||||
LinearLayout block = new LinearLayout(this);
|
||||
block.setOrientation(LinearLayout.VERTICAL);
|
||||
block.setPadding(0, 0, 0, dp(8));
|
||||
TextView name = text(label, 11, MUTED, false);
|
||||
name.setPadding(dp(2), 0, 0, dp(4));
|
||||
block.addView(name);
|
||||
return block;
|
||||
}
|
||||
|
||||
private EditText createEdit(String hint, String value, boolean numeric, boolean password) {
|
||||
EditText edit = new EditText(this);
|
||||
edit.setSingleLine(true);
|
||||
edit.setText(value);
|
||||
edit.setHint(hint);
|
||||
edit.setTextColor(TEXT);
|
||||
edit.setHintTextColor(Color.rgb(91, 103, 120));
|
||||
edit.setTextSize(15);
|
||||
edit.setPadding(dp(12), 0, dp(12), 0);
|
||||
edit.setBackground(roundRect(FIELD, 11, BORDER, 1));
|
||||
edit.setSelectAllOnFocus(false);
|
||||
if (numeric) edit.setInputType(InputType.TYPE_CLASS_NUMBER);
|
||||
if (password) edit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
return edit;
|
||||
}
|
||||
|
||||
private LinearLayout row() {
|
||||
LinearLayout row = new LinearLayout(this);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
return row;
|
||||
}
|
||||
|
||||
private TextView text(String value, float size, int color, boolean bold) {
|
||||
TextView t = new TextView(this);
|
||||
t.setText(value);
|
||||
t.setTextSize(size);
|
||||
t.setTextColor(color);
|
||||
if (bold) t.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
|
||||
return t;
|
||||
}
|
||||
|
||||
private Button actionButton(String value) {
|
||||
Button b = new Button(this);
|
||||
b.setText(value);
|
||||
b.setTextSize(14);
|
||||
b.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
|
||||
b.setTextColor(Color.WHITE);
|
||||
b.setAllCaps(false);
|
||||
return b;
|
||||
}
|
||||
|
||||
private GradientDrawable roundRect(int color, int radiusDp, int strokeColor, int strokeDp) {
|
||||
GradientDrawable d = new GradientDrawable();
|
||||
d.setColor(color);
|
||||
d.setCornerRadius(dp(radiusDp));
|
||||
if (strokeDp > 0) d.setStroke(dp(strokeDp), strokeColor);
|
||||
return d;
|
||||
}
|
||||
|
||||
private void requestConnect() {
|
||||
if (connectPending || DragonService.isActive()) return;
|
||||
try {
|
||||
validateAndSave();
|
||||
} catch (Exception e) {
|
||||
String msg = e.getMessage() == null ? "Invalid configuration" : e.getMessage();
|
||||
AppLog.append("CONFIG: " + msg);
|
||||
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
connectPending = true;
|
||||
updateConnectionUi(true, "CONNECTING");
|
||||
|
||||
Intent prepare = VpnService.prepare(this);
|
||||
if (prepare != null) startActivityForResult(prepare, VPN_REQUEST);
|
||||
else startDragonService();
|
||||
}
|
||||
|
||||
private void requestStop() {
|
||||
if (!DragonService.isActive() && !connectPending) return;
|
||||
connectPending = false;
|
||||
updateConnectionUi(true, "STOPPING");
|
||||
connectButton.setEnabled(false);
|
||||
stopButton.setEnabled(false);
|
||||
Intent i = new Intent(this, DragonService.class).setAction(DragonService.ACTION_STOP);
|
||||
startService(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 {
|
||||
connectPending = false;
|
||||
updateConnectionUi(false, "DISCONNECTED");
|
||||
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_BATCH_MAX, p.getInt("batchMax", 1));
|
||||
i.putExtra(DragonService.EXTRA_BATCH_MIN, p.getInt("batchMin", 1));
|
||||
i.putExtra(DragonService.EXTRA_RECONNECT, p.getInt("reconnect", 0));
|
||||
i.putExtra(DragonService.EXTRA_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 bMax = parse(batchMax, 1, BATCH_LIMIT, "Batch max");
|
||||
int bMin = parse(batchMin, 1, BATCH_LIMIT, "Batch min");
|
||||
if (bMin > bMax) throw new IllegalArgumentException("Batch min must not exceed batch max");
|
||||
int rec = parse(reconnect, 0, 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("batchMax", bMax)
|
||||
.putInt("batchMin", bMin)
|
||||
.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)));
|
||||
batchMax.setText(Integer.toString(p.getInt("batchMax", 1)));
|
||||
batchMin.setText(Integer.toString(p.getInt("batchMin", 1)));
|
||||
reconnect.setText(Integer.toString(p.getInt("reconnect", 0)));
|
||||
timeout.setText(Integer.toString(p.getInt("timeout", 2)));
|
||||
}
|
||||
|
||||
private void updateConnectionUi(boolean active, String rawStatus) {
|
||||
String status = rawStatus == null ? (active ? "CONNECTED" : "DISCONNECTED") : rawStatus.toUpperCase();
|
||||
boolean stopping = status.contains("STOPPING");
|
||||
boolean connected = status.contains("CONNECTED") && !status.contains("DISCONNECTED");
|
||||
boolean starting = active && !connected && !stopping;
|
||||
|
||||
statusChip.setText(connected ? "CONNECTED" : starting ? "CONNECTING" : stopping ? "STOPPING" : "DISCONNECTED");
|
||||
statusChip.setTextColor(connected ? OK : starting || stopping ? WARN : MUTED);
|
||||
statusChip.setBackground(roundRect(
|
||||
connected ? Color.rgb(20, 54, 42) : starting || stopping ? Color.rgb(58, 43, 22) : CARD,
|
||||
999,
|
||||
connected ? Color.rgb(46, 105, 80) : starting || stopping ? Color.rgb(112, 78, 35) : BORDER,
|
||||
1
|
||||
));
|
||||
|
||||
boolean canConnect = !active && !connectPending && !stopping;
|
||||
boolean canStop = active && !stopping;
|
||||
connectButton.setEnabled(canConnect);
|
||||
stopButton.setEnabled(canStop);
|
||||
|
||||
connectButton.setTextColor(canConnect ? Color.WHITE : Color.rgb(132, 142, 155));
|
||||
connectButton.setBackground(roundRect(canConnect ? ACCENT : DISABLED, 14, canConnect ? ACCENT : BORDER, 1));
|
||||
stopButton.setTextColor(canStop ? Color.WHITE : Color.rgb(112, 121, 133));
|
||||
stopButton.setBackground(roundRect(canStop ? STOP : Color.rgb(32, 38, 47), 14, canStop ? STOP : BORDER, 1));
|
||||
}
|
||||
|
||||
private int dp(int value) {
|
||||
return Math.round(value * getResources().getDisplayMetrics().density);
|
||||
}
|
||||
}
|
||||
@@ -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