Skip to content

Commit 37469fb

Browse files
committed
Harden VLESS Reality profiles
1 parent a521453 commit 37469fb

7 files changed

Lines changed: 368 additions & 106 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## 1.0.83 - 2026-06-20
4+
5+
- Hardened VLESS Reality profile validation before VPN startup.
6+
- Normalized VLESS Reality TCP profiles to stable Android defaults:
7+
`xtls-rprx-vision`, `xudp`, uTLS Chrome, SNI, and Reality enabled fields.
8+
- Added clearer failures for incomplete Reality profiles instead of starting a
9+
broken tunnel.
10+
- Extended diagnostics with VLESS mode, flow, SNI, uTLS, and packet encoding.
11+
- Kept NaiveProxy, Hysteria/Hysteria2, Smart Route, and routing behavior
12+
unchanged.
13+
314
## 1.0.82 - 2026-06-20
415

516
- Reworked Android update installation to prefer `PackageInstaller` sessions

lib/src/screens/home_screen.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2905,6 +2905,10 @@ class _HomeScreenState extends State<HomeScreen>
29052905
orElse: () =>
29062906
outbounds.isEmpty ? const <String, dynamic>{} : outbounds.first,
29072907
);
2908+
final proxyTls = (proxy['tls'] as Map?)?.cast<String, dynamic>();
2909+
final proxyReality = (proxyTls?['reality'] as Map?)
2910+
?.cast<String, dynamic>();
2911+
final proxyUtls = (proxyTls?['utls'] as Map?)?.cast<String, dynamic>();
29082912
final dns = (map['dns'] as Map?)?.cast<String, dynamic>() ?? const {};
29092913
final dnsFinal = dns['final'] ?? 'unknown';
29102914
final dnsServers = ((dns['servers'] as List?) ?? const [])
@@ -2937,6 +2941,13 @@ class _HomeScreenState extends State<HomeScreen>
29372941
'transport=${proxy['quic'] == true ? 'h3/quic' : 'h2'}',
29382942
if (proxy['type'] == 'vless')
29392943
'packet=${proxy['packet_encoding'] ?? 'default'}',
2944+
if (proxy['type'] == 'vless')
2945+
'mode=${proxyReality?['enabled'] == true ? 'reality-tcp' : 'tls'}',
2946+
if (proxy['type'] == 'vless') 'flow=${proxy['flow'] ?? 'default'}',
2947+
if (proxy['type'] == 'vless')
2948+
'sni=${proxyTls?['server_name'] ?? 'unknown'}',
2949+
if (proxy['type'] == 'vless')
2950+
'utls=${proxyUtls?['fingerprint'] ?? 'default'}',
29402951
if (proxy['type'] == 'hysteria2' || proxy['type'] == 'hysteria')
29412952
'transport=udp',
29422953
'mixed_proxy=$hasMixedProxy',

lib/src/services/profile_importer.dart

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:io';
33

44
import '../models/vpn_profile.dart';
55
import 'sing_box_config_builder.dart';
6+
import 'vless_profile_validator.dart';
67

78
class ProfileImportException implements Exception {
89
const ProfileImportException(this.message);
@@ -547,17 +548,21 @@ class ProfileImporter {
547548
(reality['enabled'] == true || _truthy('${reality['enabled']}'));
548549
final transportType =
549550
(_asMap(normalized['transport'])?['type'] as String?)?.toLowerCase();
551+
final kind = _vlessKind(
552+
security: hasReality ? 'reality' : 'tls',
553+
transportType: transportType,
554+
);
555+
final outbound = kind == VpnProfileKind.vlessReality
556+
? VlessProfileValidator.normalizeRealityTcpOutbound(normalized)
557+
: normalized;
550558
return VpnProfile(
551559
id: _stableId(originalText),
552560
name: _displayName('', fallback: server),
553-
kind: _vlessKind(
554-
security: hasReality ? 'reality' : 'tls',
555-
transportType: transportType,
556-
),
561+
kind: kind,
557562
originalInput: source.isEmpty ? originalText : source,
558563
server: server,
559564
port: port,
560-
outbound: normalized,
565+
outbound: outbound,
561566
);
562567
}
563568

@@ -800,7 +805,7 @@ class ProfileImporter {
800805
}
801806
}
802807

803-
final outbound = <String, dynamic>{
808+
var outbound = <String, dynamic>{
804809
'type': 'vless',
805810
'tag': 'proxy',
806811
'server': uri.host,
@@ -821,10 +826,15 @@ class ProfileImporter {
821826
}
822827
}
823828

829+
final kind = _vlessKind(security: security, transportType: transportType);
830+
if (kind == VpnProfileKind.vlessReality) {
831+
outbound = VlessProfileValidator.normalizeRealityTcpOutbound(outbound);
832+
}
833+
824834
return VpnProfile(
825835
id: _stableId(link),
826836
name: name,
827-
kind: _vlessKind(security: security, transportType: transportType),
837+
kind: kind,
828838
originalInput: link,
829839
server: uri.host,
830840
port: port,

lib/src/services/sing_box_config_builder.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:io';
33

44
import '../models/vpn_profile.dart';
55
import 'smart_route_rules.dart';
6+
import 'vless_profile_validator.dart';
67

78
enum SingBoxConfigTarget { android }
89

@@ -201,6 +202,15 @@ class SingBoxConfigBuilder {
201202
) {
202203
if (profile.kind == VpnProfileKind.vlessReality ||
203204
profile.kind == VpnProfileKind.vlessTls) {
205+
if (profile.kind == VpnProfileKind.vlessReality) {
206+
final normalized = VlessProfileValidator.normalizeRealityTcpOutbound(
207+
proxyOutbound,
208+
);
209+
proxyOutbound
210+
..clear()
211+
..addAll(normalized);
212+
return;
213+
}
204214
if (proxyOutbound['network'] == 'tcp') {
205215
proxyOutbound.remove('network');
206216
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import '../models/vpn_profile.dart';
2+
3+
class VlessValidationResult {
4+
const VlessValidationResult({
5+
required this.ok,
6+
this.message,
7+
this.details = const {},
8+
});
9+
10+
final bool ok;
11+
final String? message;
12+
final Map<String, String> details;
13+
}
14+
15+
class VlessProfileValidator {
16+
const VlessProfileValidator._();
17+
18+
static bool get isSupportedRealityTcpOnly => true;
19+
20+
static VlessValidationResult validate(VpnProfile profile) {
21+
if (profile.kind != VpnProfileKind.vlessReality) {
22+
return const VlessValidationResult(ok: true);
23+
}
24+
final outbound = profile.outbound;
25+
if (outbound == null) {
26+
return const VlessValidationResult(
27+
ok: false,
28+
message: 'VLESS Reality профиль без outbound-конфига.',
29+
);
30+
}
31+
return validateOutbound(outbound);
32+
}
33+
34+
static VlessValidationResult validateOutbound(Map<String, dynamic> outbound) {
35+
final type = outbound['type']?.toString().toLowerCase();
36+
if (type != 'vless') {
37+
return const VlessValidationResult(
38+
ok: false,
39+
message: 'VLESS Reality профиль содержит не VLESS outbound.',
40+
);
41+
}
42+
43+
final server = outbound['server']?.toString().trim() ?? '';
44+
final uuid = outbound['uuid']?.toString().trim() ?? '';
45+
final tls = _map(outbound['tls']);
46+
final reality = _map(tls?['reality']);
47+
final transport = _transport(outbound);
48+
final serverName = tls?['server_name']?.toString().trim() ?? '';
49+
final publicKey = reality?['public_key']?.toString().trim() ?? '';
50+
final shortId = reality?['short_id']?.toString().trim() ?? '';
51+
final flow = outbound['flow']?.toString().trim() ?? '';
52+
final packetEncoding = outbound['packet_encoding']?.toString().trim() ?? '';
53+
final fingerprint =
54+
_map(tls?['utls'])?['fingerprint']?.toString().trim() ?? '';
55+
56+
final errors = <String>[];
57+
if (server.isEmpty) {
58+
errors.add('host');
59+
}
60+
if (uuid.isEmpty) {
61+
errors.add('uuid');
62+
}
63+
if (tls?['enabled'] != true) {
64+
errors.add('tls.enabled');
65+
}
66+
if (reality?['enabled'] != true) {
67+
errors.add('reality.enabled');
68+
}
69+
if (serverName.isEmpty) {
70+
errors.add('sni/server_name');
71+
}
72+
if (publicKey.isEmpty) {
73+
errors.add('pbk/public_key');
74+
}
75+
if (transport != 'tcp') {
76+
errors.add('transport=$transport');
77+
}
78+
if (flow.isNotEmpty && flow != 'xtls-rprx-vision') {
79+
errors.add('flow=$flow');
80+
}
81+
if (packetEncoding.isNotEmpty && packetEncoding != 'xudp') {
82+
errors.add('packet_encoding=$packetEncoding');
83+
}
84+
85+
if (errors.isNotEmpty) {
86+
return VlessValidationResult(
87+
ok: false,
88+
message:
89+
'VLESS Reality профиль неполный или несовместимый: ${errors.join(', ')}.',
90+
details: {
91+
'server': server,
92+
'transport': transport,
93+
'flow': flow.isEmpty ? 'default' : flow,
94+
'sni': serverName,
95+
'fingerprint': fingerprint,
96+
'short_id': shortId.isEmpty ? 'none' : shortId,
97+
},
98+
);
99+
}
100+
101+
return VlessValidationResult(
102+
ok: true,
103+
details: {
104+
'server': server,
105+
'transport': transport,
106+
'flow': flow.isEmpty ? 'xtls-rprx-vision' : flow,
107+
'sni': serverName,
108+
'fingerprint': fingerprint.isEmpty ? 'chrome' : fingerprint,
109+
'short_id': shortId.isEmpty ? 'none' : shortId,
110+
},
111+
);
112+
}
113+
114+
static Map<String, dynamic> normalizeRealityTcpOutbound(
115+
Map<String, dynamic> outbound,
116+
) {
117+
final normalized = Map<String, dynamic>.from(outbound);
118+
normalized['type'] = 'vless';
119+
normalized.remove('network');
120+
normalized['packet_encoding'] = 'xudp';
121+
normalized.putIfAbsent('flow', () => 'xtls-rprx-vision');
122+
123+
final tls = _deepMap(normalized['tls']);
124+
tls['enabled'] = true;
125+
tls.putIfAbsent('server_name', () => normalized['server']);
126+
final utls = _deepMap(tls['utls']);
127+
utls['enabled'] = true;
128+
utls.putIfAbsent('fingerprint', () => 'chrome');
129+
tls['utls'] = utls;
130+
131+
final reality = _deepMap(tls['reality']);
132+
reality['enabled'] = true;
133+
tls['reality'] = reality;
134+
normalized['tls'] = tls;
135+
136+
final result = validateOutbound(normalized);
137+
if (!result.ok) {
138+
throw StateError(result.message ?? 'Некорректный VLESS Reality профиль.');
139+
}
140+
return normalized;
141+
}
142+
143+
static String transportLabel(Map<String, dynamic>? outbound) {
144+
if (outbound == null) {
145+
return 'unknown';
146+
}
147+
return _transport(outbound);
148+
}
149+
150+
static String _transport(Map<String, dynamic> outbound) {
151+
final unsupported = outbound['unsupported_transport']?.toString().trim();
152+
if (unsupported != null && unsupported.isNotEmpty) {
153+
return unsupported.toLowerCase();
154+
}
155+
final transport = _map(outbound['transport']);
156+
if (transport != null) {
157+
return (transport['type']?.toString().trim().toLowerCase() ?? 'tcp');
158+
}
159+
return (outbound['network']?.toString().trim().toLowerCase() ?? 'tcp');
160+
}
161+
162+
static Map<String, dynamic>? _map(Object? value) =>
163+
value is Map ? value.cast<String, dynamic>() : null;
164+
165+
static Map<String, dynamic> _deepMap(Object? value) {
166+
if (value is! Map) {
167+
return <String, dynamic>{};
168+
}
169+
return Map<String, dynamic>.from(value.cast<String, dynamic>());
170+
}
171+
}

0 commit comments

Comments
 (0)