feat(app): add secure routing and runtime resilience
Add explicit TUN and SOCKS5 connection modes, app routing controls, authenticated per-session local SOCKS5, Android network monitoring, persistent haptics, resilient session recovery, notification hardening and client-side Hivemind cleanup. Keep the hosted flutter_vless_android 1.1.5 runtime as the base and apply the Android compatibility/security patches at build time instead of restoring the unstable vendored runtime.
This commit is contained in:
1 parent
371db8da9f
commit
8ead3ff249
30 files changed
+3959
-484
No files matched your search
@@ -0,0 +1,293 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
const packageName = 'flutter_vless_android';
|
||||
const expectedVersionFragment = 'flutter_vless_android-1.1.5';
|
||||
|
||||
Never fail(String message) {
|
||||
stderr.writeln('[flutter_vless patch] $message');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
Directory packageRoot() {
|
||||
final configFile = File('.dart_tool/package_config.json');
|
||||
if (!configFile.existsSync()) {
|
||||
fail('.dart_tool/package_config.json is missing; run flutter pub get first');
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(configFile.readAsStringSync());
|
||||
if (decoded is! Map<String, dynamic> || decoded['packages'] is! List) {
|
||||
fail('package_config.json is invalid');
|
||||
}
|
||||
|
||||
Map<String, dynamic>? package;
|
||||
for (final item in decoded['packages'] as List) {
|
||||
if (item is Map && item['name'] == packageName) {
|
||||
package = Map<String, dynamic>.from(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (package == null) fail('$packageName was not found');
|
||||
|
||||
final rootUriValue = package['rootUri'];
|
||||
if (rootUriValue is! String || rootUriValue.isEmpty) {
|
||||
fail('$packageName has no rootUri');
|
||||
}
|
||||
|
||||
final rootUri = Uri.parse(rootUriValue);
|
||||
final resolved = rootUri.hasScheme
|
||||
? rootUri
|
||||
: configFile.parent.absolute.uri.resolveUri(rootUri);
|
||||
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
|
||||
|
||||
final root = Directory.fromUri(resolved);
|
||||
final normalized = root.absolute.path.replaceAll('\\', '/');
|
||||
if (!normalized.contains(expectedVersionFragment)) {
|
||||
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
|
||||
}
|
||||
if (!root.existsSync()) fail('package root does not exist: ${root.path}');
|
||||
return root;
|
||||
}
|
||||
|
||||
void replaceOnce(
|
||||
File file,
|
||||
String oldValue,
|
||||
String newValue,
|
||||
String marker,
|
||||
) {
|
||||
if (!file.existsSync()) fail('missing upstream source: ${file.path}');
|
||||
final text = file.readAsStringSync();
|
||||
if (text.contains(marker)) {
|
||||
stdout.writeln('[flutter_vless patch] already current: ${file.path}');
|
||||
return;
|
||||
}
|
||||
if (!text.contains(oldValue)) {
|
||||
fail('upstream source changed; expected block not found in ${file.path}');
|
||||
}
|
||||
file.writeAsStringSync(text.replaceFirst(oldValue, newValue));
|
||||
stdout.writeln('[flutter_vless patch] patched: ${file.path}');
|
||||
}
|
||||
|
||||
void replaceOneOf(
|
||||
File file,
|
||||
Map<String, String> replacements,
|
||||
String marker,
|
||||
) {
|
||||
if (!file.existsSync()) fail('missing upstream source: ${file.path}');
|
||||
var text = file.readAsStringSync();
|
||||
if (text.contains(marker)) {
|
||||
stdout.writeln('[flutter_vless patch] already current: ${file.path}');
|
||||
return;
|
||||
}
|
||||
for (final entry in replacements.entries) {
|
||||
if (!text.contains(entry.key)) continue;
|
||||
text = text.replaceFirst(entry.key, entry.value);
|
||||
file.writeAsStringSync(text);
|
||||
stdout.writeln('[flutter_vless patch] patched/upgraded: ${file.path}');
|
||||
return;
|
||||
}
|
||||
fail('upstream/legacy patch shape not recognized in ${file.path}');
|
||||
}
|
||||
|
||||
void main() {
|
||||
final root = packageRoot();
|
||||
final kotlin = Directory(
|
||||
'${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless',
|
||||
);
|
||||
|
||||
final configFile = File('${kotlin.path}/xray/dto/XrayConfig.kt');
|
||||
replaceOnce(
|
||||
configFile,
|
||||
''' /** List of app package names to exclude from VPN. */
|
||||
var BLOCKED_APPS: ArrayList<String> = ArrayList(),
|
||||
''',
|
||||
''' /** List of app package names to exclude from VPN. */
|
||||
var BLOCKED_APPS: ArrayList<String> = ArrayList(),
|
||||
|
||||
/** List of app package names that are exclusively allowed into VPN. */
|
||||
var ALLOWED_APPS: ArrayList<String> = ArrayList(),
|
||||
''',
|
||||
'var ALLOWED_APPS:',
|
||||
);
|
||||
|
||||
final pluginFile = File('${kotlin.path}/FlutterVlessPlugin.kt');
|
||||
replaceOnce(
|
||||
pluginFile,
|
||||
''' private lateinit var context: Context
|
||||
''',
|
||||
''' private lateinit var context: Context
|
||||
private var allowedApps: ArrayList<String> = ArrayList()
|
||||
''',
|
||||
'private var allowedApps:',
|
||||
);
|
||||
|
||||
const currentAllowedHandler = ''' "setAllowedApps" -> {
|
||||
val requested = call.argument<ArrayList<String>>("allowed_apps") ?: ArrayList()
|
||||
allowedApps = ArrayList(
|
||||
requested
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && it != context.packageName }
|
||||
.distinct()
|
||||
.sorted()
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
''';
|
||||
const legacyAllowedHandler = ''' "setAllowedApps" -> {
|
||||
val requested = call.argument<ArrayList<String>>("allowed_apps") ?: ArrayList()
|
||||
allowedApps = ArrayList(
|
||||
requested.map { it.trim() }.filter { it.isNotEmpty() }.distinct()
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
''';
|
||||
replaceOneOf(
|
||||
pluginFile,
|
||||
<String, String>{
|
||||
legacyAllowedHandler: currentAllowedHandler,
|
||||
''' when (call.method) {
|
||||
"startVless" -> {
|
||||
''': ''' when (call.method) {
|
||||
$currentAllowedHandler "startVless" -> {
|
||||
''',
|
||||
},
|
||||
'it.isNotEmpty() && it != context.packageName',
|
||||
);
|
||||
|
||||
replaceOnce(
|
||||
pluginFile,
|
||||
''' config.BLOCKED_APPS = call.argument<ArrayList<String>>("blocked_apps") ?: ArrayList()
|
||||
config.BYPASS_SUBNETS = call.argument<ArrayList<String>>("bypass_subnets") ?: ArrayList()
|
||||
''',
|
||||
''' config.BLOCKED_APPS = call.argument<ArrayList<String>>("blocked_apps") ?: ArrayList()
|
||||
config.ALLOWED_APPS = ArrayList(allowedApps)
|
||||
config.BYPASS_SUBNETS = call.argument<ArrayList<String>>("bypass_subnets") ?: ArrayList()
|
||||
''',
|
||||
'config.ALLOWED_APPS = ArrayList(allowedApps)',
|
||||
);
|
||||
|
||||
final serviceFile = File('${kotlin.path}/xray/service/XrayVPNService.kt');
|
||||
replaceOnce(
|
||||
serviceFile,
|
||||
''' if (config != null) {
|
||||
// Ensure clean state before starting
|
||||
cleanup()
|
||||
''',
|
||||
''' if (config != null) {
|
||||
// A fast reconnect must not leave the previous Xray process
|
||||
// owning the local SOCKS/HTTP ports.
|
||||
if (XrayCoreManager.isXrayRunning()) {
|
||||
Log.w(TAG, "Stopping stale Xray core before restart")
|
||||
XrayCoreManager.stopCore(this)
|
||||
}
|
||||
cleanup()
|
||||
''',
|
||||
'Stopping stale Xray core before restart',
|
||||
);
|
||||
|
||||
replaceOnce(
|
||||
serviceFile,
|
||||
''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
|
||||
val sockPath = File(filesDir, "sock_path").absolutePath
|
||||
''',
|
||||
''' val tun2socksPath = File(applicationInfo.nativeLibraryDir, "libtun2socks.so").absolutePath
|
||||
val socketFile = File(filesDir, "sock_path")
|
||||
if (socketFile.exists() && !socketFile.delete()) {
|
||||
Log.w(TAG, "Could not delete stale tun2socks socket")
|
||||
}
|
||||
val sockPath = socketFile.absolutePath
|
||||
''',
|
||||
'Could not delete stale tun2socks socket',
|
||||
);
|
||||
|
||||
final upstreamPolicy = r''' try {
|
||||
builder.addDisallowedApplication(packageName)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, \"Failed to exclude app from VPN\", e)
|
||||
}
|
||||
|
||||
for (pkg in config.BLOCKED_APPS) {
|
||||
try {
|
||||
builder.addDisallowedApplication(pkg)
|
||||
Log.d(TAG, \"Excluded from VPN: $pkg\")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, \"Failed to exclude $pkg from VPN\", e)
|
||||
}
|
||||
}
|
||||
'''.replaceAll(r'\"', '"');
|
||||
|
||||
final legacyPatchedPolicy = r''' if (config.ALLOWED_APPS.isNotEmpty()) {
|
||||
for (pkg in config.ALLOWED_APPS) {
|
||||
try {
|
||||
builder.addAllowedApplication(pkg)
|
||||
Log.d(TAG, \"Allowed into VPN: $pkg\")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, \"Failed to allow $pkg into VPN\", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
builder.addDisallowedApplication(packageName)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, \"Failed to exclude app from VPN\", e)
|
||||
}
|
||||
|
||||
for (pkg in config.BLOCKED_APPS) {
|
||||
try {
|
||||
builder.addDisallowedApplication(pkg)
|
||||
Log.d(TAG, \"Excluded from VPN: $pkg\")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, \"Failed to exclude $pkg from VPN\", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
'''.replaceAll(r'\"', '"');
|
||||
|
||||
final hardenedPolicy = r''' if (config.ALLOWED_APPS.isNotEmpty() && config.BLOCKED_APPS.isNotEmpty()) {
|
||||
throw IllegalStateException(\"Allowed and blocked app policies cannot be mixed\")
|
||||
}
|
||||
|
||||
if (config.ALLOWED_APPS.isNotEmpty()) {
|
||||
var allowedCount = 0
|
||||
for (pkg in config.ALLOWED_APPS) {
|
||||
try {
|
||||
builder.addAllowedApplication(pkg)
|
||||
allowedCount++
|
||||
Log.d(TAG, \"Allowed into VPN: $pkg\")
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException(\"Failed to allow selected app $pkg\", e)
|
||||
}
|
||||
}
|
||||
if (allowedCount == 0) {
|
||||
throw IllegalStateException(\"Selected-only routing has no valid applications\")
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
builder.addDisallowedApplication(packageName)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, \"Failed to exclude app from VPN\", e)
|
||||
}
|
||||
|
||||
for (pkg in config.BLOCKED_APPS) {
|
||||
try {
|
||||
builder.addDisallowedApplication(pkg)
|
||||
Log.d(TAG, \"Excluded from VPN: $pkg\")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, \"Failed to exclude $pkg from VPN\", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
'''.replaceAll(r'\"', '"');
|
||||
|
||||
// Support both pristine 1.1.5 and the earlier local allowlist patch.
|
||||
replaceOneOf(
|
||||
serviceFile,
|
||||
<String, String>{
|
||||
legacyPatchedPolicy: hardenedPolicy,
|
||||
upstreamPolicy: hardenedPolicy,
|
||||
},
|
||||
'Allowed and blocked app policies cannot be mixed',
|
||||
);
|
||||
|
||||
stdout.writeln('[flutter_vless patch] allowed-app routing patch is ready');
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
const packageName = 'flutter_vless_android';
|
||||
const expectedVersionFragment = 'flutter_vless_android-1.1.5';
|
||||
|
||||
Never fail(String message) {
|
||||
stderr.writeln('[secure-socks scope fix] $message');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
Directory packageRoot() {
|
||||
final configFile = File('.dart_tool/package_config.json');
|
||||
if (!configFile.existsSync()) {
|
||||
fail('.dart_tool/package_config.json is missing; run flutter pub get first');
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(configFile.readAsStringSync());
|
||||
if (decoded is! Map<String, dynamic> || decoded['packages'] is! List) {
|
||||
fail('package_config.json is invalid');
|
||||
}
|
||||
|
||||
Map<String, dynamic>? package;
|
||||
for (final item in decoded['packages'] as List) {
|
||||
if (item is Map && item['name'] == packageName) {
|
||||
package = Map<String, dynamic>.from(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (package == null) fail('$packageName was not found');
|
||||
|
||||
final rootUriValue = package['rootUri'];
|
||||
if (rootUriValue is! String || rootUriValue.isEmpty) {
|
||||
fail('$packageName has no rootUri');
|
||||
}
|
||||
|
||||
final rootUri = Uri.parse(rootUriValue);
|
||||
final resolved = rootUri.hasScheme
|
||||
? rootUri
|
||||
: configFile.parent.absolute.uri.resolveUri(rootUri);
|
||||
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
|
||||
|
||||
final root = Directory.fromUri(resolved);
|
||||
final normalized = root.absolute.path.replaceAll('\\', '/');
|
||||
if (!normalized.contains(expectedVersionFragment)) {
|
||||
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
void patchCoreScope(File core) {
|
||||
final before = core.readAsStringSync();
|
||||
|
||||
// The primary secure patcher used a file-wide check for
|
||||
// `val process = pb.start()`. Stock 1.1.5 already contains that exact line
|
||||
// in getV2rayTraffic(), so the startCore() capture could be skipped while
|
||||
// its monitor was still rewritten to use `process`. Capture the active Xray
|
||||
// process immediately before its monitor thread instead. This is deliberately
|
||||
// a scope-only correction and does not alter the stable runtime sequence.
|
||||
const directCapture =
|
||||
' val process = pb.start()\n xrayProcess = process\n';
|
||||
const scopedCapture =
|
||||
' val process = xrayProcess ?: return false\n // Monitor process in a separate thread to detect crash\n';
|
||||
const monitorMarker =
|
||||
' // Monitor process in a separate thread to detect crash\n';
|
||||
|
||||
if (before.contains(directCapture) || before.contains(scopedCapture)) {
|
||||
stdout.writeln('[secure-socks scope fix] core already current: ${core.path}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!before.contains('process.inputStream.bufferedReader().use { reader ->') ||
|
||||
!before.contains('val exitCode = process.waitFor()') ||
|
||||
!before.contains('xrayProcess === process')) {
|
||||
fail('secure Xray monitor patch is not in the expected state');
|
||||
}
|
||||
if (!before.contains(' xrayProcess = pb.start()\n')) {
|
||||
fail('stock Xray start assignment is missing');
|
||||
}
|
||||
if (!before.contains(monitorMarker)) {
|
||||
fail('Xray monitor insertion point is missing');
|
||||
}
|
||||
|
||||
final after = before.replaceFirst(monitorMarker, scopedCapture);
|
||||
core.writeAsStringSync(after);
|
||||
stdout.writeln('[secure-socks scope fix] captured Xray process for monitor scope');
|
||||
}
|
||||
|
||||
void patchOngoingNotification(File service) {
|
||||
final before = service.readAsStringSync();
|
||||
|
||||
const marker = '.setOngoing(true)';
|
||||
if (before.contains(marker) &&
|
||||
before.contains('Notification.FLAG_NO_CLEAR') &&
|
||||
before.contains('.setAutoCancel(false)')) {
|
||||
stdout.writeln('[secure-socks scope fix] notification already ongoing: ${service.path}');
|
||||
return;
|
||||
}
|
||||
|
||||
const oldBlock = r''' return builder
|
||||
.setContentTitle("VPN Service")
|
||||
.setContentText(content)
|
||||
.setSmallIcon(icon)
|
||||
.build()
|
||||
''';
|
||||
|
||||
const newBlock = r''' val notification = builder
|
||||
.setContentTitle("VPN Service")
|
||||
.setContentText(content)
|
||||
.setSmallIcon(icon)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
|
||||
// Keep the active VPN notification attached to the foreground service
|
||||
// and resistant to bulk clearing on Android versions that honor these
|
||||
// flags. Android 14+ may still allow an explicit user swipe while the
|
||||
// device is unlocked; apps are not allowed to override that system UI.
|
||||
notification.flags = notification.flags or
|
||||
android.app.Notification.FLAG_ONGOING_EVENT or
|
||||
android.app.Notification.FLAG_NO_CLEAR
|
||||
return notification
|
||||
''';
|
||||
|
||||
if (!before.contains(oldBlock)) {
|
||||
fail('VPN notification builder is not in the expected stock 1.1.5 state');
|
||||
}
|
||||
|
||||
final after = before.replaceFirst(oldBlock, newBlock);
|
||||
if (!after.contains(marker) ||
|
||||
!after.contains('Notification.FLAG_NO_CLEAR') ||
|
||||
!after.contains('.setAutoCancel(false)')) {
|
||||
fail('ongoing VPN notification invariant failed');
|
||||
}
|
||||
|
||||
service.writeAsStringSync(after);
|
||||
stdout.writeln('[secure-socks scope fix] VPN notification marked ongoing/no-clear');
|
||||
}
|
||||
|
||||
void main() {
|
||||
final root = packageRoot();
|
||||
final kotlin =
|
||||
'${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless';
|
||||
final core = File('$kotlin/xray/core/XrayCoreManager.kt');
|
||||
final service = File('$kotlin/xray/service/XrayVPNService.kt');
|
||||
|
||||
if (!core.existsSync()) fail('XrayCoreManager.kt is missing');
|
||||
if (!service.existsSync()) fail('XrayVPNService.kt is missing');
|
||||
|
||||
patchCoreScope(core);
|
||||
patchOngoingNotification(service);
|
||||
stdout.writeln('[secure-socks scope fix] scope and notification patch ready');
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
const packageName = 'flutter_vless_android';
|
||||
const expectedVersionFragment = 'flutter_vless_android-1.1.5';
|
||||
|
||||
Never fail(String message) {
|
||||
stderr.writeln('[secure-socks patch] $message');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
Directory packageRoot() {
|
||||
final configFile = File('.dart_tool/package_config.json');
|
||||
if (!configFile.existsSync()) {
|
||||
fail('.dart_tool/package_config.json is missing; run flutter pub get first');
|
||||
}
|
||||
final decoded = jsonDecode(configFile.readAsStringSync());
|
||||
if (decoded is! Map<String, dynamic> || decoded['packages'] is! List) {
|
||||
fail('package_config.json is invalid');
|
||||
}
|
||||
|
||||
Map<String, dynamic>? package;
|
||||
for (final item in decoded['packages'] as List) {
|
||||
if (item is Map && item['name'] == packageName) {
|
||||
package = Map<String, dynamic>.from(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (package == null) fail('$packageName was not found');
|
||||
|
||||
final rootUriValue = package['rootUri'];
|
||||
if (rootUriValue is! String || rootUriValue.isEmpty) {
|
||||
fail('$packageName has no rootUri');
|
||||
}
|
||||
final rootUri = Uri.parse(rootUriValue);
|
||||
final resolved = rootUri.hasScheme
|
||||
? rootUri
|
||||
: configFile.parent.absolute.uri.resolveUri(rootUri);
|
||||
if (resolved.scheme != 'file') fail('unsupported package URI: $resolved');
|
||||
|
||||
final root = Directory.fromUri(resolved);
|
||||
final normalized = root.absolute.path.replaceAll('\\', '/');
|
||||
if (!normalized.contains(expectedVersionFragment)) {
|
||||
fail('expected flutter_vless_android 1.1.5, got ${root.path}');
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
String replaceOnce(
|
||||
String text,
|
||||
String oldValue,
|
||||
String newValue,
|
||||
String label,
|
||||
) {
|
||||
if (text.contains(newValue)) return text;
|
||||
if (!text.contains(oldValue)) fail('missing expected source block: $label');
|
||||
return text.replaceFirst(oldValue, newValue);
|
||||
}
|
||||
|
||||
void writeIfChanged(File file, String before, String after) {
|
||||
if (before == after) {
|
||||
stdout.writeln('[secure-socks patch] already current: ${file.path}');
|
||||
return;
|
||||
}
|
||||
file.writeAsStringSync(after);
|
||||
stdout.writeln('[secure-socks patch] patched: ${file.path}');
|
||||
}
|
||||
|
||||
void patchCore(File file) {
|
||||
final before = file.readAsStringSync();
|
||||
var text = before;
|
||||
|
||||
const secureValidation = r''' var secureSocksEnabled = false
|
||||
for (i in 0 until inbounds.length()) {
|
||||
val inbound = inbounds.optJSONObject(i) ?: continue
|
||||
if (inbound.optString("tag") != "revolt-secure-socks") continue
|
||||
|
||||
val settings = inbound.optJSONObject("settings")
|
||||
?: throw IllegalStateException("Secure SOCKS5 settings are missing")
|
||||
val users = settings.optJSONArray("users")
|
||||
?: throw IllegalStateException("Secure SOCKS5 users are missing")
|
||||
val account = users.optJSONObject(0)
|
||||
?: throw IllegalStateException("Secure SOCKS5 account is missing")
|
||||
val port = inbound.optInt("port", -1)
|
||||
|
||||
if (inbound.optString("protocol") != "socks" ||
|
||||
inbound.optString("listen") != "127.0.0.1" ||
|
||||
port <= 1024 || port > 65535 ||
|
||||
settings.optString("auth") != "password" ||
|
||||
account.optString("user").isEmpty() ||
|
||||
account.optString("pass").isEmpty()
|
||||
) {
|
||||
throw IllegalStateException("Secure SOCKS5 session is invalid")
|
||||
}
|
||||
|
||||
config.LOCAL_SOCKS5_PORT = port
|
||||
config.LOCAL_HTTP_PORT = 0
|
||||
secureSocksEnabled = true
|
||||
break
|
||||
}
|
||||
|
||||
''';
|
||||
|
||||
if (!text.contains('var secureSocksEnabled = false')) {
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' val usedPorts = mutableSetOf<Int>()\n',
|
||||
'$secureValidation val usedPorts = mutableSetOf<Int>()\n',
|
||||
'secure SOCKS validation insertion',
|
||||
);
|
||||
}
|
||||
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' if (!hasHttpOnLocalPort) {\n',
|
||||
' if (!secureSocksEnabled && !hasHttpOnLocalPort) {\n',
|
||||
'disable unauthenticated HTTP proxy in secure mode',
|
||||
);
|
||||
|
||||
if (!text.contains('val process = pb.start()')) {
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' xrayProcess = pb.start()\n',
|
||||
' val process = pb.start()\n xrayProcess = process\n',
|
||||
'capture Xray process identity',
|
||||
);
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' if (xrayProcess?.isAlive != true) {\n',
|
||||
' if (!process.isAlive) {\n',
|
||||
'Xray startup liveness',
|
||||
);
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' val output = xrayProcess?.inputStream?.bufferedReader()?.readText().orEmpty()\n',
|
||||
' val output = process.inputStream.bufferedReader().readText()\n',
|
||||
'Xray startup output',
|
||||
);
|
||||
}
|
||||
|
||||
if (!text.contains('Could not delete ephemeral Xray config')) {
|
||||
const connectedLine =
|
||||
' AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTED\n';
|
||||
const deleteConfig = r''' // config.json contains per-session SOCKS credentials. Xray has
|
||||
// consumed it after the startup liveness check, so remove it now.
|
||||
try {
|
||||
val ephemeralConfig = File(configFilesDir, "config.json")
|
||||
if (ephemeralConfig.exists() && !ephemeralConfig.delete()) {
|
||||
Log.w(TAG, "Could not delete ephemeral Xray config")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not delete ephemeral Xray config", e)
|
||||
}
|
||||
|
||||
''';
|
||||
text = replaceOnce(
|
||||
text,
|
||||
connectedLine,
|
||||
'$deleteConfig$connectedLine',
|
||||
'ephemeral config deletion',
|
||||
);
|
||||
}
|
||||
|
||||
if (!text.contains('context.handleXrayCoreExit(config)')) {
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' xrayProcess?.inputStream?.bufferedReader()?.use { reader ->\n',
|
||||
' process.inputStream.bufferedReader().use { reader ->\n',
|
||||
'Xray monitor process stream',
|
||||
);
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' val exitCode = xrayProcess?.waitFor()\n',
|
||||
' val exitCode = process.waitFor()\n',
|
||||
'Xray monitor process wait',
|
||||
);
|
||||
|
||||
const oldExit = r''' if (AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED) {
|
||||
// Unexpected exit
|
||||
stopCore(context)
|
||||
}
|
||||
''';
|
||||
const newExit = r''' if (xrayProcess === process &&
|
||||
AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED
|
||||
) {
|
||||
// Hold the Android TUN while the core is recovered. Killing
|
||||
// the VPN service here would allow direct-network fallback.
|
||||
xrayProcess = null
|
||||
AppConfigs.V2RAY_STATE = AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||
if (context is XrayVPNService) {
|
||||
context.handleXrayCoreExit(config)
|
||||
} else {
|
||||
stopCore(context)
|
||||
}
|
||||
}
|
||||
''';
|
||||
text = replaceOnce(text, oldExit, newExit, 'Xray fail-closed recovery');
|
||||
}
|
||||
|
||||
writeIfChanged(file, before, text);
|
||||
}
|
||||
|
||||
void patchService(File file) {
|
||||
final before = file.readAsStringSync();
|
||||
var text = before;
|
||||
|
||||
if (!text.contains('private var recoveringXray = false')) {
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' private var isRunning = false\n',
|
||||
' @Volatile private var isRunning = false\n'
|
||||
' @Volatile private var recoveringXray = false\n'
|
||||
' private var currentConfig: XrayConfig? = null\n',
|
||||
'service recovery fields',
|
||||
);
|
||||
}
|
||||
|
||||
const oldNullIntent = r''' if (intent == null) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
''';
|
||||
const newNullIntent = r''' if (intent == null) {
|
||||
if (isRunning && currentConfig != null) return START_REDELIVER_INTENT
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
''';
|
||||
text = replaceOnce(
|
||||
text,
|
||||
oldNullIntent,
|
||||
newNullIntent,
|
||||
'null intent lifecycle',
|
||||
);
|
||||
|
||||
if (!text.contains(' currentConfig = config\n')) {
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' cleanup()\n',
|
||||
' cleanup()\n currentConfig = config\n',
|
||||
'runtime config snapshot',
|
||||
);
|
||||
}
|
||||
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' return START_STICKY\n',
|
||||
' return START_REDELIVER_INTENT\n',
|
||||
'redeliver START command',
|
||||
);
|
||||
|
||||
if (!text.contains('private data class SecureSocksCredentials(')) {
|
||||
const credentialsHelper = r''' private data class SecureSocksCredentials(
|
||||
val port: Int,
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
private fun secureSocksCredentials(config: XrayConfig): SecureSocksCredentials? {
|
||||
val inbounds = try {
|
||||
JSONObject(config.V2RAY_FULL_JSON_CONFIG).optJSONArray("inbounds")
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
for (i in 0 until inbounds.length()) {
|
||||
val inbound = inbounds.optJSONObject(i) ?: continue
|
||||
if (inbound.optString("tag") != "revolt-secure-socks") continue
|
||||
if (inbound.optString("protocol") != "socks" ||
|
||||
inbound.optString("listen") != "127.0.0.1"
|
||||
) return null
|
||||
|
||||
val settings = inbound.optJSONObject("settings") ?: return null
|
||||
if (settings.optString("auth") != "password") return null
|
||||
val user = settings.optJSONArray("users")?.optJSONObject(0) ?: return null
|
||||
val port = inbound.optInt("port", -1)
|
||||
val username = user.optString("user")
|
||||
val password = user.optString("pass")
|
||||
if (port <= 1024 || port > 65535 || username.isEmpty() || password.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
return SecureSocksCredentials(port, username, password)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
''';
|
||||
const runComment = r''' /**
|
||||
* Starts the tun2socks process and initiates the FD transfer.
|
||||
*/
|
||||
private fun runTun2socks(config: XrayConfig) {
|
||||
''';
|
||||
text = replaceOnce(
|
||||
text,
|
||||
runComment,
|
||||
'$credentialsHelper$runComment',
|
||||
'secure credentials helper',
|
||||
);
|
||||
}
|
||||
|
||||
if (!text.contains('Authenticated ReVolt SOCKS5 inbound missing')) {
|
||||
const commandComment = r''' // Command to start tun2socks.
|
||||
// Note: We pass -sock-path to tell it where to listen for the FD.
|
||||
''';
|
||||
const securePrelude = r''' val secure = secureSocksCredentials(config)
|
||||
?: throw IllegalStateException("Authenticated ReVolt SOCKS5 inbound missing")
|
||||
config.LOCAL_SOCKS5_PORT = secure.port
|
||||
|
||||
''';
|
||||
text = replaceOnce(
|
||||
text,
|
||||
commandComment,
|
||||
'$securePrelude$commandComment',
|
||||
'secure tun2socks credentials',
|
||||
);
|
||||
text = replaceOnce(
|
||||
text,
|
||||
r''' "-proxy", "socks5://127.0.0.1:${config.LOCAL_SOCKS5_PORT}",
|
||||
''',
|
||||
r''' "-proxy", "socks5://${secure.username}:${secure.password}@127.0.0.1:${secure.port}",
|
||||
''',
|
||||
'authenticated tun2socks URL',
|
||||
);
|
||||
text = replaceOnce(
|
||||
text,
|
||||
r''' Log.d(TAG, "tun2socks command: ${cmd.joinToString(" ")}")
|
||||
''',
|
||||
' Log.d(TAG, "Starting tun2socks with authenticated loopback SOCKS5")\n',
|
||||
'credential-safe logging',
|
||||
);
|
||||
}
|
||||
|
||||
if (!text.contains('val process = pb.start()\n tun2socksProcess = process')) {
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' tun2socksProcess = pb.start()\n',
|
||||
' val process = pb.start()\n tun2socksProcess = process\n',
|
||||
'capture tun2socks process identity',
|
||||
);
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' tun2socksProcess?.inputStream?.bufferedReader()?.use { reader ->\n',
|
||||
' process.inputStream.bufferedReader().use { reader ->\n',
|
||||
'tun2socks monitor stream',
|
||||
);
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' tun2socksProcess?.waitFor()\n',
|
||||
' process.waitFor()\n',
|
||||
'tun2socks monitor wait',
|
||||
);
|
||||
|
||||
const oldRestart = r''' if (isRunning) {
|
||||
// Restart if crashed and still supposed to be running
|
||||
Log.e(TAG, "tun2socks exited unexpectedly, restarting...")
|
||||
runTun2socks(config)
|
||||
}
|
||||
''';
|
||||
const newRestart = r''' if (isRunning && tun2socksProcess === process && currentConfig === config) {
|
||||
Log.e(TAG, "tun2socks exited unexpectedly, recycling without dropping TUN")
|
||||
Thread.sleep(350)
|
||||
if (isRunning && currentConfig === config) runTun2socks(config)
|
||||
}
|
||||
''';
|
||||
text = replaceOnce(
|
||||
text,
|
||||
oldRestart,
|
||||
newRestart,
|
||||
'tun2socks crash recovery',
|
||||
);
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' sendFd()\n',
|
||||
' sendFd(process)\n',
|
||||
'FD handoff process identity',
|
||||
);
|
||||
}
|
||||
|
||||
const oldStartFailure = r''' } catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start tun2socks", e)
|
||||
stopAll()
|
||||
}
|
||||
''';
|
||||
const newStartFailure = r''' } catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start tun2socks; retrying with TUN held", e)
|
||||
if (isRunning && currentConfig === config) {
|
||||
Thread {
|
||||
try {
|
||||
Thread.sleep(600)
|
||||
if (isRunning && currentConfig === config) runTun2socks(config)
|
||||
} catch (_: InterruptedException) {}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
''';
|
||||
text = replaceOnce(
|
||||
text,
|
||||
oldStartFailure,
|
||||
newStartFailure,
|
||||
'tun2socks start failure recovery',
|
||||
);
|
||||
|
||||
if (!text.contains('FD handoff failed; recycling tun2socks without dropping TUN')) {
|
||||
const oldSendFd = r''' private fun sendFd() {
|
||||
val fd = mInterface?.fileDescriptor ?: return
|
||||
val sockFile = File(filesDir, "sock_path").absolutePath
|
||||
|
||||
Thread {
|
||||
var tries = 0
|
||||
while (tries < 10) {
|
||||
try {
|
||||
Thread.sleep(500)
|
||||
val localSocket = LocalSocket()
|
||||
localSocket.connect(LocalSocketAddress(sockFile, LocalSocketAddress.Namespace.FILESYSTEM))
|
||||
val out = localSocket.outputStream
|
||||
// This magic call attaches the FD to the socket message
|
||||
localSocket.setFileDescriptorsForSend(arrayOf(fd))
|
||||
out.write(32) // Send a dummy byte to trigger the transfer
|
||||
localSocket.setFileDescriptorsForSend(null)
|
||||
localSocket.shutdownOutput()
|
||||
localSocket.close()
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
tries++
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
''';
|
||||
const newSendFd = r''' private fun sendFd(process: Process) {
|
||||
val fd = mInterface?.fileDescriptor ?: return
|
||||
val sockFile = File(filesDir, "sock_path").absolutePath
|
||||
|
||||
Thread {
|
||||
var tries = 0
|
||||
while (tries < 10 && isRunning && tun2socksProcess === process && process.isAlive) {
|
||||
var localSocket: LocalSocket? = null
|
||||
try {
|
||||
Thread.sleep(500)
|
||||
localSocket = LocalSocket()
|
||||
localSocket.connect(LocalSocketAddress(sockFile, LocalSocketAddress.Namespace.FILESYSTEM))
|
||||
val out = localSocket.outputStream
|
||||
localSocket.setFileDescriptorsForSend(arrayOf(fd))
|
||||
out.write(32)
|
||||
out.flush()
|
||||
localSocket.setFileDescriptorsForSend(null)
|
||||
localSocket.shutdownOutput()
|
||||
localSocket.close()
|
||||
return@Thread
|
||||
} catch (_: Exception) {
|
||||
tries++
|
||||
try { localSocket?.close() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (isRunning && tun2socksProcess === process) {
|
||||
Log.e(TAG, "FD handoff failed; recycling tun2socks without dropping TUN")
|
||||
try { process.destroy() } catch (_: Exception) {}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
''';
|
||||
text = replaceOnce(text, oldSendFd, newSendFd, 'bounded FD recovery');
|
||||
}
|
||||
|
||||
if (!text.contains('fun handleXrayCoreExit(config: XrayConfig)')) {
|
||||
const cleanupComment = r''' /**
|
||||
* Cleans up resources (tun2socks process, VPN interface) without stopping the service completely.
|
||||
* Used when restarting or switching configurations.
|
||||
*/
|
||||
''';
|
||||
const recovery = r''' fun handleXrayCoreExit(config: XrayConfig) {
|
||||
if (!isRunning || currentConfig !== config || recoveringXray) return
|
||||
recoveringXray = true
|
||||
|
||||
Thread {
|
||||
var attempt = 0
|
||||
try {
|
||||
while (isRunning && currentConfig === config) {
|
||||
attempt++
|
||||
Thread.sleep(400L * attempt.coerceAtMost(5))
|
||||
if (!isRunning || currentConfig !== config) break
|
||||
|
||||
Log.w(TAG, "Recovering Xray core attempt $attempt")
|
||||
if (XrayCoreManager.startCore(this, config)) {
|
||||
Log.w(TAG, "Xray core recovered without dropping TUN")
|
||||
return@Thread
|
||||
}
|
||||
|
||||
if (attempt >= 3) {
|
||||
attempt = 0
|
||||
Thread.sleep(3000)
|
||||
}
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
} finally {
|
||||
recoveringXray = false
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
''';
|
||||
text = replaceOnce(
|
||||
text,
|
||||
cleanupComment,
|
||||
'$recovery$cleanupComment',
|
||||
'Xray recovery loop',
|
||||
);
|
||||
}
|
||||
|
||||
if (!text.contains(' recoveringXray = false\n tun2socksProcess?.destroy()')) {
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' isRunning = false\n tun2socksProcess?.destroy()\n',
|
||||
' isRunning = false\n recoveringXray = false\n tun2socksProcess?.destroy()\n',
|
||||
'cancel recovery on cleanup',
|
||||
);
|
||||
}
|
||||
|
||||
if (!text.contains(' currentConfig = null\n XrayCoreManager.stopCore(this)')) {
|
||||
text = replaceOnce(
|
||||
text,
|
||||
' cleanup()\n XrayCoreManager.stopCore(this)\n',
|
||||
' cleanup()\n currentConfig = null\n XrayCoreManager.stopCore(this)\n',
|
||||
'clear config on explicit stop',
|
||||
);
|
||||
}
|
||||
|
||||
writeIfChanged(file, before, text);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final root = packageRoot();
|
||||
final kotlin = Directory(
|
||||
'${root.path}/android/src/main/kotlin/com/github/tfox/flutter_vless',
|
||||
);
|
||||
final core = File('${kotlin.path}/xray/core/XrayCoreManager.kt');
|
||||
final service = File('${kotlin.path}/xray/service/XrayVPNService.kt');
|
||||
if (!core.existsSync() || !service.existsSync()) {
|
||||
fail('pinned Android runtime sources are missing');
|
||||
}
|
||||
|
||||
patchCore(core);
|
||||
patchService(service);
|
||||
stdout.writeln('[secure-socks patch] stable-core security patch is ready');
|
||||
}
|
||||
Reference in new issue
Block a user