fix(client): harden control plane and SOCKS
This commit is contained in:
1 parent
0ed7bf27b5
commit
0692bb83e1
4 files changed
+356
-153
No files matched your search
+221
-31
@@ -13,6 +13,7 @@ class HivemindService {
|
|||||||
|
|
||||||
static const _ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 '
|
static const _ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 '
|
||||||
'(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
|
'(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
|
||||||
|
static const int _maxControlResponseBytes = 256 * 1024;
|
||||||
static const _pollBackoff = <Duration>[
|
static const _pollBackoff = <Duration>[
|
||||||
Duration(seconds: 1),
|
Duration(seconds: 1),
|
||||||
Duration(seconds: 2),
|
Duration(seconds: 2),
|
||||||
@@ -27,7 +28,58 @@ class HivemindService {
|
|||||||
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
|
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void cancel() => _currentCallId++;
|
static Future<http.Response> controlGet(
|
||||||
|
Uri uri, {
|
||||||
|
Duration timeout = const Duration(seconds: 5),
|
||||||
|
}) async {
|
||||||
|
final client = http.Client();
|
||||||
|
final request = http.Request('GET', uri)
|
||||||
|
..headers['User-Agent'] = _ua
|
||||||
|
..followRedirects = false;
|
||||||
|
|
||||||
|
Future<http.Response> read() async {
|
||||||
|
final streamed = await client.send(request);
|
||||||
|
final declaredLength = streamed.contentLength;
|
||||||
|
if (declaredLength != null && declaredLength > _maxControlResponseBytes) {
|
||||||
|
throw const FormatException('Control response is too large.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final bytes = <int>[];
|
||||||
|
await for (final chunk in streamed.stream) {
|
||||||
|
if (bytes.length + chunk.length > _maxControlResponseBytes) {
|
||||||
|
throw const FormatException('Control response is too large.');
|
||||||
|
}
|
||||||
|
bytes.addAll(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.Response.bytes(
|
||||||
|
bytes,
|
||||||
|
streamed.statusCode,
|
||||||
|
headers: streamed.headers,
|
||||||
|
isRedirect: streamed.isRedirect,
|
||||||
|
persistentConnection: streamed.persistentConnection,
|
||||||
|
reasonPhrase: streamed.reasonPhrase,
|
||||||
|
request: request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await read().timeout(
|
||||||
|
timeout,
|
||||||
|
onTimeout: () {
|
||||||
|
client.close();
|
||||||
|
throw TimeoutException('Control request timed out.', timeout);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void cancel() {
|
||||||
|
_currentCallId++;
|
||||||
|
_expectedNonce = null;
|
||||||
|
}
|
||||||
|
|
||||||
static void setExpectedNonce(String nonce) {
|
static void setExpectedNonce(String nonce) {
|
||||||
_expectedNonce = nonce;
|
_expectedNonce = nonce;
|
||||||
@@ -37,13 +89,19 @@ class HivemindService {
|
|||||||
void Function(int attempt, int total)? onAttempt,
|
void Function(int attempt, int total)? onAttempt,
|
||||||
bool skipAdBypass = false,
|
bool skipAdBypass = false,
|
||||||
}) async {
|
}) async {
|
||||||
final deviceId = await CryptoService.getDeviceId();
|
// Capture the generation before the first await. Otherwise a disconnect
|
||||||
|
// that lands while secure storage is resolving the device ID can be lost
|
||||||
|
// and the cancelled call can resume network work with a fresh generation.
|
||||||
final callId = ++_currentCallId;
|
final callId = ++_currentCallId;
|
||||||
|
final deviceId = await CryptoService.getDeviceId();
|
||||||
|
_throwIfCancelled(callId);
|
||||||
|
|
||||||
final nonce = _newNonce();
|
final nonce = _newNonce();
|
||||||
_expectedNonce = nonce;
|
_expectedNonce = nonce;
|
||||||
|
|
||||||
if (!skipAdBypass) {
|
if (!skipAdBypass) {
|
||||||
await _runConfiguredBypass(deviceId, nonce);
|
await _runConfiguredBypass(deviceId, nonce);
|
||||||
|
_throwIfCancelled(callId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxAttempts = 5;
|
const maxAttempts = 5;
|
||||||
@@ -53,6 +111,7 @@ class HivemindService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final session = await _fetchActiveSession(deviceId, nonce);
|
final session = await _fetchActiveSession(deviceId, nonce);
|
||||||
|
_throwIfCancelled(callId);
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
_expectedNonce = null;
|
_expectedNonce = null;
|
||||||
return session.toVlessUrl();
|
return session.toVlessUrl();
|
||||||
@@ -73,7 +132,7 @@ class HivemindService {
|
|||||||
|
|
||||||
static Future<bool> checkHealth() async {
|
static Future<bool> checkHealth() async {
|
||||||
try {
|
try {
|
||||||
final response = await directGet(
|
final response = await controlGet(
|
||||||
_publicUrl('/health'),
|
_publicUrl('/health'),
|
||||||
timeout: const Duration(seconds: 3),
|
timeout: const Duration(seconds: 3),
|
||||||
);
|
);
|
||||||
@@ -109,15 +168,15 @@ class HivemindService {
|
|||||||
final statusUri = _publicUrl('/session/status').replace(
|
final statusUri = _publicUrl('/session/status').replace(
|
||||||
queryParameters: {'device_id': deviceId},
|
queryParameters: {'device_id': deviceId},
|
||||||
);
|
);
|
||||||
final response = await directGet(statusUri);
|
final response = await controlGet(statusUri);
|
||||||
if (response.statusCode != 200) return null;
|
if (response.statusCode != 200) return null;
|
||||||
|
|
||||||
final decoded = jsonDecode(response.body);
|
final decoded = jsonDecode(response.body);
|
||||||
if (decoded is! Map<String, dynamic>) return null;
|
if (decoded is! Map<String, dynamic>) return null;
|
||||||
|
|
||||||
final serverNonce = decoded['nonce'];
|
final serverNonce = decoded['nonce'];
|
||||||
if (_expectedNonce != null &&
|
if (_expectedNonce != nonce ||
|
||||||
serverNonce is String &&
|
serverNonce is! String ||
|
||||||
serverNonce != nonce) {
|
serverNonce != nonce) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -141,6 +200,13 @@ class HivemindService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HivemindSessionConfig {
|
class _HivemindSessionConfig {
|
||||||
|
static final RegExp _uuidPattern = RegExp(
|
||||||
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
static final RegExp _shortIdPattern =
|
||||||
|
RegExp(r'^[0-9a-f]{0,16}$', caseSensitive: false);
|
||||||
|
|
||||||
final String uuid;
|
final String uuid;
|
||||||
final String host;
|
final String host;
|
||||||
final int port;
|
final int port;
|
||||||
@@ -162,50 +228,174 @@ class _HivemindSessionConfig {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory _HivemindSessionConfig.fromJson(Map<String, dynamic> json) {
|
factory _HivemindSessionConfig.fromJson(Map<String, dynamic> json) {
|
||||||
final uuid = _requiredString(json, 'vless_uuid');
|
final uuid = _requiredString(json, 'vless_uuid', maxLength: 64);
|
||||||
final host = _stringOr(json['vless_ip'], AppConfig.serverIp);
|
if (!_uuidPattern.hasMatch(uuid)) {
|
||||||
final port = _portOr(json['vless_port'], 443);
|
throw const FormatException('Invalid VLESS UUID');
|
||||||
final sni = _requiredString(json, 'reality_sni');
|
}
|
||||||
|
|
||||||
|
// The API may rotate credentials and Reality parameters, but it must never
|
||||||
|
// choose the tunnel destination. Keep that trust anchor compiled into the
|
||||||
|
// app so an API/domain compromise can cause denial of service, not redirect
|
||||||
|
// VPN traffic to an attacker-controlled server.
|
||||||
|
final pinnedHost = _validatedPublicIp(AppConfig.serverIp, 'serverIp');
|
||||||
|
final advertisedHost = json['vless_ip'];
|
||||||
|
if (advertisedHost != null) {
|
||||||
|
if (advertisedHost is! String || advertisedHost.trim() != pinnedHost) {
|
||||||
|
throw const FormatException(
|
||||||
|
'Session VLESS host does not match compiled server pin',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final host = pinnedHost;
|
||||||
|
|
||||||
|
final advertisedPort = json['vless_port'];
|
||||||
|
if (advertisedPort != null &&
|
||||||
|
(advertisedPort is! num || advertisedPort.toInt() != 443)) {
|
||||||
|
throw const FormatException('Session VLESS port does not match compiled pin');
|
||||||
|
}
|
||||||
|
const port = 443;
|
||||||
|
final sni = _validatedHost(
|
||||||
|
_requiredString(json, 'reality_sni', maxLength: 253),
|
||||||
|
'reality_sni',
|
||||||
|
);
|
||||||
|
final publicKey = _requiredString(json, 'reality_pbk', maxLength: 128);
|
||||||
|
final shortId = _stringOr(json['reality_sid'], '').trim();
|
||||||
|
if (!_shortIdPattern.hasMatch(shortId)) {
|
||||||
|
throw const FormatException('Invalid Reality short ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
final fingerprint =
|
||||||
|
_boundedString(json['reality_fp'], AppConfig.realityFp, 32, 'reality_fp');
|
||||||
|
final path =
|
||||||
|
_boundedString(json['xhttp_path'], AppConfig.vlessPath, 2048, 'xhttp_path');
|
||||||
|
if (!path.startsWith('/')) {
|
||||||
|
throw const FormatException('Invalid XHTTP path');
|
||||||
|
}
|
||||||
|
|
||||||
return _HivemindSessionConfig(
|
return _HivemindSessionConfig(
|
||||||
uuid: uuid,
|
uuid: uuid,
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
publicKey: _stringOr(json['reality_pbk'], ''),
|
publicKey: publicKey,
|
||||||
shortId: _stringOr(json['reality_sid'], ''),
|
shortId: shortId,
|
||||||
sni: sni,
|
sni: sni,
|
||||||
fingerprint: _stringOr(json['reality_fp'], AppConfig.realityFp),
|
fingerprint: fingerprint,
|
||||||
path: _stringOr(json['xhttp_path'], AppConfig.vlessPath),
|
path: path,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String toVlessUrl() {
|
String toVlessUrl() {
|
||||||
return 'vless://$uuid@$host:$port'
|
return Uri(
|
||||||
'?security=${AppConfig.vlessSecurity}'
|
scheme: 'vless',
|
||||||
'&type=${AppConfig.vlessType}'
|
userInfo: uuid,
|
||||||
'&path=$path'
|
host: host,
|
||||||
'&pbk=${Uri.encodeComponent(publicKey)}'
|
port: port,
|
||||||
'&sni=${Uri.encodeComponent(sni)}'
|
queryParameters: <String, String>{
|
||||||
'&sid=${Uri.encodeComponent(shortId)}'
|
'security': AppConfig.vlessSecurity,
|
||||||
'&fp=${Uri.encodeComponent(fingerprint)}'
|
'type': AppConfig.vlessType,
|
||||||
'#Revolt VPN';
|
'path': path,
|
||||||
|
'pbk': publicKey,
|
||||||
|
'sni': sni,
|
||||||
|
'sid': shortId,
|
||||||
|
'fp': fingerprint,
|
||||||
|
},
|
||||||
|
fragment: 'Revolt VPN',
|
||||||
|
).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _requiredString(Map<String, dynamic> json, String key) {
|
static String _requiredString(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
String key, {
|
||||||
|
int maxLength = 4096,
|
||||||
|
}) {
|
||||||
final value = json[key];
|
final value = json[key];
|
||||||
if (value is String && value.trim().isNotEmpty) return value.trim();
|
if (value is! String) {
|
||||||
throw FormatException('Missing required session field: $key');
|
throw FormatException('Missing required session field: $key');
|
||||||
}
|
}
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty || trimmed.length > maxLength) {
|
||||||
|
throw FormatException('Invalid session field: $key');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
static String _stringOr(Object? value, String fallback) {
|
static String _stringOr(Object? value, String fallback) {
|
||||||
return value is String && value.isNotEmpty ? value : fallback;
|
return value is String && value.trim().isNotEmpty ? value.trim() : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int _portOr(Object? value, int fallback) {
|
static String _boundedString(
|
||||||
final parsed = value is num ? value.toInt() : fallback;
|
Object? value,
|
||||||
if (parsed <= 0 || parsed > 65535) {
|
String fallback,
|
||||||
throw const FormatException('Invalid VLESS port');
|
int maxLength,
|
||||||
|
String field,
|
||||||
|
) {
|
||||||
|
final result = _stringOr(value, fallback);
|
||||||
|
if (result.isEmpty || result.length > maxLength) {
|
||||||
|
throw FormatException('Invalid session field: $field');
|
||||||
}
|
}
|
||||||
return parsed;
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _validatedHost(String input, String field) {
|
||||||
|
final value = input.trim();
|
||||||
|
if (value.isEmpty ||
|
||||||
|
value.length > 253 ||
|
||||||
|
value.contains(RegExp(r'[\s/@?#]'))) {
|
||||||
|
throw FormatException('Invalid session host: $field');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final probe = Uri(scheme: 'https', host: value);
|
||||||
|
if (probe.host.isEmpty) {
|
||||||
|
throw FormatException('Invalid session host: $field');
|
||||||
|
}
|
||||||
|
} on FormatException {
|
||||||
|
throw FormatException('Invalid session host: $field');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _validatedPublicIp(String input, String field) {
|
||||||
|
final value = input.trim().toLowerCase();
|
||||||
|
if (_isPublicIpv4(value) || _isPublicIpv6(value)) return value;
|
||||||
|
throw FormatException('Invalid public IP pin: $field');
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isPublicIpv4(String value) {
|
||||||
|
final parts = value.split('.');
|
||||||
|
if (parts.length != 4) return false;
|
||||||
|
final octets = <int>[];
|
||||||
|
for (final part in parts) {
|
||||||
|
if (part.isEmpty || (part.length > 1 && part.startsWith('0'))) return false;
|
||||||
|
final parsed = int.tryParse(part);
|
||||||
|
if (parsed == null || parsed < 0 || parsed > 255) return false;
|
||||||
|
octets.add(parsed);
|
||||||
|
}
|
||||||
|
final a = octets[0];
|
||||||
|
final b = octets[1];
|
||||||
|
if (a == 0 || a == 10 || a == 127 || a >= 224) return false;
|
||||||
|
if (a == 100 && b >= 64 && b <= 127) return false;
|
||||||
|
if (a == 169 && b == 254) return false;
|
||||||
|
if (a == 172 && b >= 16 && b <= 31) return false;
|
||||||
|
if (a == 192 && b == 168) return false;
|
||||||
|
if (a == 192 && b == 0 && octets[2] == 2) return false;
|
||||||
|
if (a == 198 && b == 51 && octets[2] == 100) return false;
|
||||||
|
if (a == 203 && b == 0 && octets[2] == 113) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isPublicIpv6(String value) {
|
||||||
|
if (!value.contains(':') || value.contains('.')) return false;
|
||||||
|
try {
|
||||||
|
final parsed = Uri.parse('https://[$value]/');
|
||||||
|
if (parsed.host.isEmpty || !parsed.host.contains(':')) return false;
|
||||||
|
} on FormatException {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value == '::' || value == '::1') return false;
|
||||||
|
if (value.startsWith('fc') || value.startsWith('fd')) return false;
|
||||||
|
if (value.startsWith('fe8') || value.startsWith('fe9') || value.startsWith('fea') || value.startsWith('feb')) return false;
|
||||||
|
if (value.startsWith('ff')) return false;
|
||||||
|
if (value.startsWith('2001:db8:') || value == '2001:db8::') return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,13 +20,13 @@ abstract final class LocalSocksTester {
|
|||||||
|
|
||||||
/// Fast local readiness probe used while starting the VPN.
|
/// Fast local readiness probe used while starting the VPN.
|
||||||
///
|
///
|
||||||
/// This stops after SOCKS5 authentication. Startup must not depend on an
|
/// This stops after authenticated SOCKS5 negotiation. Startup must not depend
|
||||||
/// unrelated external host being reachable.
|
/// on an unrelated external host being reachable.
|
||||||
static Future<LocalSocksTestResult> testListener({
|
static Future<LocalSocksTestResult> testListener({
|
||||||
String host = '127.0.0.1',
|
String host = '127.0.0.1',
|
||||||
int port = 10807,
|
required int port,
|
||||||
String? username,
|
required String username,
|
||||||
String? password,
|
required String password,
|
||||||
}) async {
|
}) async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
Socket? socket;
|
Socket? socket;
|
||||||
@@ -86,26 +86,40 @@ abstract final class LocalSocksTester {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full user-facing test: authenticated local SOCKS handshake plus outbound
|
/// Full user-facing test: authenticated local SOCKS handshake plus an
|
||||||
/// CONNECT. Credentials are optional so the helper remains usable for legacy
|
/// outbound TCP CONNECT through the active ReVolt route.
|
||||||
/// no-auth diagnostic builds.
|
|
||||||
static Future<LocalSocksTestResult> test({
|
static Future<LocalSocksTestResult> test({
|
||||||
String host = '127.0.0.1',
|
String host = '127.0.0.1',
|
||||||
int port = 10807,
|
required int port,
|
||||||
String? username,
|
required String username,
|
||||||
String? password,
|
required String password,
|
||||||
String targetHost = 'paladinvpn.duckdns.org',
|
String targetHost = 'example.com',
|
||||||
int targetPort = 443,
|
int targetPort = 443,
|
||||||
}) async {
|
}) async {
|
||||||
|
const totalTimeout = Duration(seconds: 8);
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
Socket? socket;
|
Socket? socket;
|
||||||
_SocketReader? reader;
|
_SocketReader? reader;
|
||||||
|
|
||||||
|
Duration remaining() {
|
||||||
|
final micros = totalTimeout.inMicroseconds - stopwatch.elapsedMicroseconds;
|
||||||
|
if (micros <= 0) throw TimeoutException('SOCKS5 test timed out.', totalTimeout);
|
||||||
|
return Duration(microseconds: micros);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (targetPort <= 0 || targetPort > 65535) {
|
||||||
|
return const LocalSocksTestResult(
|
||||||
|
ok: false,
|
||||||
|
latencyMs: null,
|
||||||
|
message: 'SOCKS5 test target is invalid.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
socket = await Socket.connect(
|
socket = await Socket.connect(
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
timeout: const Duration(seconds: 3),
|
timeout: remaining(),
|
||||||
);
|
);
|
||||||
socket.setOption(SocketOption.tcpNoDelay, true);
|
socket.setOption(SocketOption.tcpNoDelay, true);
|
||||||
reader = _SocketReader(socket);
|
reader = _SocketReader(socket);
|
||||||
@@ -115,6 +129,7 @@ abstract final class LocalSocksTester {
|
|||||||
socket,
|
socket,
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
|
timeout: remaining(),
|
||||||
);
|
);
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
return const LocalSocksTestResult(
|
return const LocalSocksTestResult(
|
||||||
@@ -145,7 +160,7 @@ abstract final class LocalSocksTester {
|
|||||||
]);
|
]);
|
||||||
await socket.flush();
|
await socket.flush();
|
||||||
|
|
||||||
final replyHead = await reader.readExactly(4);
|
final replyHead = await reader.readExactly(4, timeout: remaining());
|
||||||
if (replyHead[0] != 0x05 || replyHead[1] != 0x00) {
|
if (replyHead[0] != 0x05 || replyHead[1] != 0x00) {
|
||||||
return const LocalSocksTestResult(
|
return const LocalSocksTestResult(
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -155,7 +170,7 @@ abstract final class LocalSocksTester {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _consumeAddress(reader, replyHead[3]);
|
await _consumeAddress(reader, replyHead[3], timeout: remaining());
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
return LocalSocksTestResult(
|
return LocalSocksTestResult(
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -189,23 +204,10 @@ abstract final class LocalSocksTester {
|
|||||||
static Future<bool> _authenticate(
|
static Future<bool> _authenticate(
|
||||||
_SocketReader reader,
|
_SocketReader reader,
|
||||||
Socket socket, {
|
Socket socket, {
|
||||||
required String? username,
|
required String username,
|
||||||
required String? password,
|
required String password,
|
||||||
Duration timeout = const Duration(seconds: 4),
|
Duration timeout = const Duration(seconds: 4),
|
||||||
}) async {
|
}) async {
|
||||||
final passwordAuth = username != null && password != null;
|
|
||||||
if ((username == null) != (password == null)) return false;
|
|
||||||
|
|
||||||
socket.add(passwordAuth
|
|
||||||
? const <int>[0x05, 0x01, 0x02]
|
|
||||||
: const <int>[0x05, 0x01, 0x00]);
|
|
||||||
await socket.flush();
|
|
||||||
|
|
||||||
final greeting = await reader.readExactly(2, timeout: timeout);
|
|
||||||
if (greeting[0] != 0x05) return false;
|
|
||||||
if (!passwordAuth) return greeting[1] == 0x00;
|
|
||||||
if (greeting[1] != 0x02) return false;
|
|
||||||
|
|
||||||
final userBytes = utf8.encode(username);
|
final userBytes = utf8.encode(username);
|
||||||
final passBytes = utf8.encode(password);
|
final passBytes = utf8.encode(password);
|
||||||
if (userBytes.isEmpty ||
|
if (userBytes.isEmpty ||
|
||||||
@@ -215,6 +217,15 @@ abstract final class LocalSocksTester {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReVolt never accepts unauthenticated local SOCKS. Advertising only RFC
|
||||||
|
// 1929 username/password auth makes a future credential-plumbing regression
|
||||||
|
// fail closed instead of silently falling back to method 0x00.
|
||||||
|
socket.add(const <int>[0x05, 0x01, 0x02]);
|
||||||
|
await socket.flush();
|
||||||
|
|
||||||
|
final greeting = await reader.readExactly(2, timeout: timeout);
|
||||||
|
if (greeting[0] != 0x05 || greeting[1] != 0x02) return false;
|
||||||
|
|
||||||
socket.add(<int>[
|
socket.add(<int>[
|
||||||
0x01,
|
0x01,
|
||||||
userBytes.length,
|
userBytes.length,
|
||||||
@@ -229,18 +240,24 @@ abstract final class LocalSocksTester {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> _consumeAddress(
|
static Future<void> _consumeAddress(
|
||||||
_SocketReader reader, int addressType) async {
|
_SocketReader reader,
|
||||||
|
int addressType, {
|
||||||
|
required Duration timeout,
|
||||||
|
}) async {
|
||||||
if (addressType == 0x01) {
|
if (addressType == 0x01) {
|
||||||
await reader.readExactly(4 + 2);
|
await reader.readExactly(4 + 2, timeout: timeout);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (addressType == 0x04) {
|
if (addressType == 0x04) {
|
||||||
await reader.readExactly(16 + 2);
|
await reader.readExactly(16 + 2, timeout: timeout);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (addressType == 0x03) {
|
if (addressType == 0x03) {
|
||||||
final length = (await reader.readExactly(1)).first;
|
final length = (await reader.readExactly(1, timeout: timeout)).first;
|
||||||
await reader.readExactly(length + 2);
|
if (length == 0) {
|
||||||
|
throw const FormatException('Invalid SOCKS5 domain length');
|
||||||
|
}
|
||||||
|
await reader.readExactly(length + 2, timeout: timeout);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw const FormatException('Unsupported SOCKS5 address type');
|
throw const FormatException('Unsupported SOCKS5 address type');
|
||||||
@@ -257,8 +274,15 @@ class _SocketReader {
|
|||||||
int count, {
|
int count, {
|
||||||
Duration timeout = const Duration(seconds: 4),
|
Duration timeout = const Duration(seconds: 4),
|
||||||
}) async {
|
}) async {
|
||||||
|
final elapsed = Stopwatch()..start();
|
||||||
while (_buffer.length < count) {
|
while (_buffer.length < count) {
|
||||||
final hasNext = await _iterator.moveNext().timeout(timeout);
|
final remainingMicros = timeout.inMicroseconds - elapsed.elapsedMicroseconds;
|
||||||
|
if (remainingMicros <= 0) {
|
||||||
|
throw TimeoutException('Socket read timed out', timeout);
|
||||||
|
}
|
||||||
|
final hasNext = await _iterator.moveNext().timeout(
|
||||||
|
Duration(microseconds: remainingMicros),
|
||||||
|
);
|
||||||
if (!hasNext) {
|
if (!hasNext) {
|
||||||
throw const SocketException('Socket closed early');
|
throw const SocketException('Socket closed early');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,22 +39,11 @@ class SecureSocksSession {
|
|||||||
final username = _token(16);
|
final username = _token(16);
|
||||||
final password = _token(32);
|
final password = _token(32);
|
||||||
|
|
||||||
final sourceInbounds = decoded['inbounds'];
|
// ReVolt owns local ingress. Never accept listeners supplied by a remote
|
||||||
final inbounds = <dynamic>[];
|
// or imported Xray config; the client exposes exactly one authenticated
|
||||||
if (sourceInbounds is List) {
|
// loopback SOCKS5 listener for the current ephemeral session.
|
||||||
for (final value in sourceInbounds) {
|
decoded['inbounds'] = <Map<String, dynamic>>[
|
||||||
if (value is Map) {
|
<String, dynamic>{
|
||||||
final protocol = value['protocol']?.toString().toLowerCase();
|
|
||||||
// ReVolt owns local proxy ingress on Android. Drop imported SOCKS/HTTP
|
|
||||||
// listeners so a stale no-auth proxy cannot remain reachable beside
|
|
||||||
// the authenticated session listener.
|
|
||||||
if (protocol == 'socks' || protocol == 'http') continue;
|
|
||||||
}
|
|
||||||
inbounds.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inbounds.add(<String, dynamic>{
|
|
||||||
'tag': inboundTag,
|
'tag': inboundTag,
|
||||||
'port': port,
|
'port': port,
|
||||||
'listen': '127.0.0.1',
|
'listen': '127.0.0.1',
|
||||||
@@ -71,8 +60,8 @@ class SecureSocksSession {
|
|||||||
'enabled': true,
|
'enabled': true,
|
||||||
'destOverride': <String>['http', 'tls'],
|
'destOverride': <String>['http', 'tls'],
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
decoded['inbounds'] = inbounds;
|
];
|
||||||
|
|
||||||
return SecureSocksSession._(
|
return SecureSocksSession._(
|
||||||
port: port,
|
port: port,
|
||||||
|
|||||||
+68
-68
@@ -1,15 +1,14 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
import 'package:revoltvpn/logic/app_config.dart';
|
|
||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
|
import 'package:revoltvpn/logic/app_config.dart';
|
||||||
enum UpdateStatus { upToDate, updateAvailable, checkFailed }
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
enum InstallSource { playStore, sideload }
|
enum InstallSource { playStore, sideload }
|
||||||
|
|
||||||
@@ -40,10 +39,12 @@ class _ParsedVersion {
|
|||||||
if (rawCore.isEmpty || rawCore.any((p) => int.tryParse(p) == null)) {
|
if (rawCore.isEmpty || rawCore.any((p) => int.tryParse(p) == null)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final core = rawCore.map(int.parse).toList(growable: false);
|
|
||||||
|
final core = rawCore.map(int.parse).toList();
|
||||||
while (core.length < 3) {
|
while (core.length < 3) {
|
||||||
core.add(0);
|
core.add(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
final pre = prePart.isEmpty
|
final pre = prePart.isEmpty
|
||||||
? const <String>[]
|
? const <String>[]
|
||||||
: prePart.split('.').where((e) => e.isNotEmpty).toList(growable: false);
|
: prePart.split('.').where((e) => e.isNotEmpty).toList(growable: false);
|
||||||
@@ -93,10 +94,7 @@ class PlayStoreUpdater {
|
|||||||
|
|
||||||
static Future<bool> open() async {
|
static Future<bool> open() async {
|
||||||
final uri = _trustedHttpsUri(_url, const {'play.google.com'});
|
final uri = _trustedHttpsUri(_url, const {'play.google.com'});
|
||||||
if (uri == null) {
|
if (uri == null) return false;
|
||||||
debugPrint('[Updater] Refusing invalid Play Store URL.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -121,38 +119,24 @@ class GitHubUpdater {
|
|||||||
final response = await http
|
final response = await http
|
||||||
.get(apiUri, headers: {'Accept': 'application/vnd.github+json'})
|
.get(apiUri, headers: {'Accept': 'application/vnd.github+json'})
|
||||||
.timeout(const Duration(seconds: 8));
|
.timeout(const Duration(seconds: 8));
|
||||||
|
if (response.statusCode != 200) return null;
|
||||||
if (response.statusCode != 200) {
|
|
||||||
debugPrint('[Updater] GitHub API returned ${response.statusCode}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(response.body);
|
final decoded = jsonDecode(response.body);
|
||||||
if (decoded is! Map<String, dynamic>) {
|
if (decoded is! Map<String, dynamic>) return null;
|
||||||
debugPrint('[Updater] Invalid GitHub release payload.');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final htmlUrl = decoded['html_url'];
|
final htmlUrl = decoded['html_url'];
|
||||||
if (htmlUrl is String &&
|
_cachedDownloadUrl = htmlUrl is String &&
|
||||||
_trustedHttpsUri(htmlUrl, const {'github.com'}) != null) {
|
_trustedHttpsUri(htmlUrl, const {'github.com'}) != null
|
||||||
_cachedDownloadUrl = htmlUrl;
|
? htmlUrl
|
||||||
} else {
|
: null;
|
||||||
_cachedDownloadUrl = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final rawTag = decoded['tag_name'];
|
final rawTag = decoded['tag_name'];
|
||||||
if (rawTag is! String || rawTag.isEmpty || rawTag.length > 64) {
|
if (rawTag is! String || rawTag.isEmpty || rawTag.length > 64) {
|
||||||
debugPrint('[Updater] Invalid release tag.');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final normalized = rawTag.startsWith('v') ? rawTag.substring(1) : rawTag;
|
final normalized = rawTag.startsWith('v') ? rawTag.substring(1) : rawTag;
|
||||||
if (_ParsedVersion.tryParse(normalized) == null) {
|
return _ParsedVersion.tryParse(normalized) == null ? null : normalized;
|
||||||
debugPrint('[Updater] Release tag is not a supported version: $rawTag');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Updater] GitHub API error: $e');
|
debugPrint('[Updater] GitHub API error: $e');
|
||||||
return null;
|
return null;
|
||||||
@@ -162,11 +146,7 @@ class GitHubUpdater {
|
|||||||
static Future<bool> open() async {
|
static Future<bool> open() async {
|
||||||
final raw = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl;
|
final raw = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl;
|
||||||
final uri = _trustedHttpsUri(raw, const {'github.com'});
|
final uri = _trustedHttpsUri(raw, const {'github.com'});
|
||||||
if (uri == null) {
|
if (uri == null) return false;
|
||||||
debugPrint('[Updater] Refusing untrusted GitHub update URL.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -179,36 +159,32 @@ class GitHubUpdater {
|
|||||||
class Updater {
|
class Updater {
|
||||||
Updater._();
|
Updater._();
|
||||||
|
|
||||||
|
static const MethodChannel _installerChannel =
|
||||||
|
MethodChannel('com.revoltvpn.app/installer');
|
||||||
static InstallSource? _cachedSource;
|
static InstallSource? _cachedSource;
|
||||||
|
|
||||||
static Future<InstallSource> get installSource async {
|
static Future<InstallSource> get installSource async {
|
||||||
if (_cachedSource != null) return _cachedSource!;
|
if (_cachedSource != null) return _cachedSource!;
|
||||||
if (kIsWeb || !Platform.isAndroid) {
|
if (kIsWeb || !Platform.isAndroid) {
|
||||||
_cachedSource = InstallSource.sideload;
|
return _cachedSource = InstallSource.sideload;
|
||||||
return _cachedSource!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await MethodChannel('com.revoltvpn.app/installer')
|
final result =
|
||||||
.invokeMethod<String>('getInstallerPackage');
|
await _installerChannel.invokeMethod<String>('getInstallerPackage');
|
||||||
_cachedSource = (result == 'com.android.vending')
|
return _cachedSource = result == 'com.android.vending'
|
||||||
? InstallSource.playStore
|
? InstallSource.playStore
|
||||||
: InstallSource.sideload;
|
: InstallSource.sideload;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Updater] Installer check failed: $e');
|
debugPrint('[Updater] Installer check failed: $e');
|
||||||
_cachedSource = InstallSource.sideload;
|
return _cachedSource = InstallSource.sideload;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _cachedSource!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static int _compareVersions(String a, String b) {
|
static int _compareVersions(String a, String b) {
|
||||||
final left = _ParsedVersion.tryParse(a);
|
final left = _ParsedVersion.tryParse(a);
|
||||||
final right = _ParsedVersion.tryParse(b);
|
final right = _ParsedVersion.tryParse(b);
|
||||||
if (left == null || right == null) {
|
if (left == null || right == null) return 0;
|
||||||
// Invalid local metadata should not invent an ordering.
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return left.compareTo(right);
|
return left.compareTo(right);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,11 +194,9 @@ class Updater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> check(BuildContext context) {
|
static Future<void> check(BuildContext context) {
|
||||||
final navigator = Navigator.of(context, rootNavigator: true);
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
return checkWithHandles(
|
return checkWithHandles(
|
||||||
navigator: navigator,
|
navigator: Navigator.of(context, rootNavigator: true),
|
||||||
messenger: messenger,
|
messenger: ScaffoldMessenger.of(context),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,14 +204,46 @@ class Updater {
|
|||||||
required NavigatorState navigator,
|
required NavigatorState navigator,
|
||||||
required ScaffoldMessengerState messenger,
|
required ScaffoldMessengerState messenger,
|
||||||
}) async {
|
}) async {
|
||||||
|
final source = await installSource;
|
||||||
|
if (source == InstallSource.playStore) {
|
||||||
|
if (!navigator.mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: navigator.context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
backgroundColor: AppColors.bgCard,
|
||||||
|
title: const Text(
|
||||||
|
'Updates are managed by Play Store',
|
||||||
|
style: TextStyle(color: AppColors.textWhite),
|
||||||
|
),
|
||||||
|
content: const Text(
|
||||||
|
'Open Play Store to check whether an update is available for this install.',
|
||||||
|
style: TextStyle(color: AppColors.slate70),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
PlayStoreUpdater.open();
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'Open Play Store',
|
||||||
|
style: TextStyle(color: AppColors.accent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final info = await PackageInfo.fromPlatform();
|
final info = await PackageInfo.fromPlatform();
|
||||||
final localVersion = info.version;
|
final localVersion = info.version;
|
||||||
if (localVersion.isEmpty || _ParsedVersion.tryParse(localVersion) == null) {
|
if (localVersion.isEmpty || _ParsedVersion.tryParse(localVersion) == null) {
|
||||||
_showSnackBar(
|
_showSnackBar(messenger, 'Could not determine app version', isError: true);
|
||||||
messenger,
|
|
||||||
'Could not determine app version',
|
|
||||||
isError: true,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,12 +258,8 @@ class Updater {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final source = await installSource;
|
|
||||||
final storeName =
|
|
||||||
source == InstallSource.playStore ? 'Play Store' : 'GitHub';
|
|
||||||
if (!navigator.mounted) return;
|
if (!navigator.mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
showDialog(
|
|
||||||
context: navigator.context,
|
context: navigator.context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
backgroundColor: AppColors.bgCard,
|
backgroundColor: AppColors.bgCard,
|
||||||
@@ -266,7 +268,7 @@ class Updater {
|
|||||||
style: TextStyle(color: AppColors.textWhite),
|
style: TextStyle(color: AppColors.textWhite),
|
||||||
),
|
),
|
||||||
content: Text(
|
content: Text(
|
||||||
'${_versionLabel(latestVersion, false)} is available on $storeName.\n'
|
'${_versionLabel(latestVersion, false)} is available on GitHub.\n'
|
||||||
'You have ${_versionLabel(localVersion, true)}.',
|
'You have ${_versionLabel(localVersion, true)}.',
|
||||||
style: const TextStyle(color: AppColors.slate70),
|
style: const TextStyle(color: AppColors.slate70),
|
||||||
),
|
),
|
||||||
@@ -281,13 +283,11 @@ class Updater {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
source == InstallSource.playStore
|
GitHubUpdater.open();
|
||||||
? PlayStoreUpdater.open()
|
|
||||||
: GitHubUpdater.open();
|
|
||||||
},
|
},
|
||||||
child: Text(
|
child: const Text(
|
||||||
'Open $storeName',
|
'Open GitHub',
|
||||||
style: const TextStyle(color: AppColors.accent),
|
style: TextStyle(color: AppColors.accent),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user