Skip to content

Commit ab3bb72

Browse files
authored
Merge pull request #14 from oviron/byedpi-test-routing
feat(byedpi): test-driven host routing + update-407 fix
2 parents f0adad4 + 0c97122 commit ab3bb72

8 files changed

Lines changed: 293 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## v0.13.11
2+
3+
- Test-driven host routing: applying a strategy from the test now routes only the hosts it verified through ByeDPI; hosts it can't pierce fall back to the VPN (no longer broken). Apply shows the ByeDPI/VPN split
4+
5+
- Strategy test is now a dashboard: per-strategy results + timestamps persist and render from cache; the active strategy is badged
6+
7+
- Fix: updating strategies through the VPN no longer fails with 407 (answers the local proxy's inbound-auth challenge)
8+
19
## v0.13.10
210

311
- In-app ByeDPI strategy auto-test: a Strategy-test screen runs each strategy through a standalone byedpi SOCKS proxy and ranks them by how well they reach the test sites on the current network; apply the best in one tap

lib/byedpi/strategy_update.dart

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import 'dart:io';
2+
3+
import 'package:dio/dio.dart';
4+
import 'package:dio/io.dart';
15
import 'package:fl_clash/byedpi/strategy_args.dart';
2-
import 'package:fl_clash/common/request.dart';
6+
import 'package:fl_clash/common/inbound_auth.dart';
7+
import 'package:fl_clash/controller.dart';
38
import 'package:shared_preferences/shared_preferences.dart';
49

510
const kStrategiesUrl =
@@ -13,12 +18,46 @@ Future<DateTime?> strategiesLastUpdate() async {
1318
return ms == null ? null : DateTime.fromMillisecondsSinceEpoch(ms);
1419
}
1520

16-
// Fetch the strategy set from [kStrategiesUrl] (routed through mihomo when the
17-
// VPN is up, via request._clashDio), validate, atomically write the on-disk
18-
// override and stamp the update time. Returns the strategy count. Throws on
19-
// fetch/parse failure — the previous on-disk/bundled set is left untouched.
21+
// Route through the local mixed-port when the VPN is up (so a blocked release
22+
// host is reachable), else direct. The inbound carries auth (see
23+
// inbound_auth.dart), so we must answer the proxy's 407 with the stored
24+
// credentials — `request._clashDio` doesn't, which is why the generic fetch
25+
// failed. authenticateProxy matches whatever realm mihomo challenges with.
26+
Dio _buildDio() {
27+
final mixedPort = appController.config.patchClashConfig.mixedPort;
28+
final viaProxy = appController.isStart;
29+
final dio = Dio();
30+
dio.httpClientAdapter = IOHttpClientAdapter(
31+
createHttpClient: () {
32+
final client = HttpClient();
33+
client.badCertificateCallback = (_, _, _) => true;
34+
client.findProxy = (_) =>
35+
viaProxy ? 'PROXY localhost:$mixedPort' : 'DIRECT';
36+
client.authenticateProxy = (host, port, scheme, realm) async {
37+
final pwd = await inboundAuthPassword();
38+
if (pwd == null || pwd.isEmpty) return false;
39+
client.addProxyCredentials(
40+
host,
41+
port,
42+
realm ?? '',
43+
HttpClientBasicCredentials(inboundAuthUser, pwd),
44+
);
45+
return true;
46+
};
47+
return client;
48+
},
49+
);
50+
return dio;
51+
}
52+
53+
// Fetch the strategy set from [kStrategiesUrl], validate, atomically write the
54+
// on-disk override and stamp the update time. Returns the strategy count.
55+
// Throws on fetch/parse failure — the previous on-disk/bundled set is kept.
2056
Future<int> updateStrategiesFromRemote() async {
21-
final res = await request.getTextResponseForUrl(kStrategiesUrl);
57+
final res = await _buildDio().get<String>(
58+
kStrategiesUrl,
59+
options: Options(responseType: ResponseType.plain),
60+
);
2261
final raw = res.data ?? '';
2362
final list = parseStrategyList(raw);
2463
await writeStrategyList(raw);

lib/byedpi/test_store.dart

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:path/path.dart';
5+
import 'package:path_provider/path_provider.dart';
6+
7+
Future<String> _supportDir() async =>
8+
(await getApplicationSupportDirectory()).path;
9+
10+
Future<void> _atomicWrite(String path, String contents) async {
11+
final tmp = File('$path.tmp');
12+
await tmp.writeAsString(contents);
13+
await tmp.rename(path);
14+
}
15+
16+
// --- exclude list: hosts the active strategy failed in its last test.
17+
// engine routing uses readHostList() − exclude (see providers/state.dart). ---
18+
19+
Future<String> _excludePath() async =>
20+
join(await _supportDir(), 'byedpi-exclude.json');
21+
22+
Future<Set<String>> readExclude() async {
23+
final f = File(await _excludePath());
24+
if (!f.existsSync()) return const {};
25+
try {
26+
final list = jsonDecode(await f.readAsString()) as List<dynamic>;
27+
return list.map((e) => e.toString()).toSet();
28+
} catch (_) {
29+
return const {};
30+
}
31+
}
32+
33+
Future<void> writeExclude(Iterable<String> hosts) =>
34+
_excludePath().then((p) => _atomicWrite(p, jsonEncode(hosts.toList())));
35+
36+
// --- test results cache: id -> { percent, timestamp(ms), sites:[{site,ok,total}] }.
37+
// Lets the dashboard render from cache and keep per-strategy dates. ---
38+
39+
Future<String> _resultsPath() async =>
40+
join(await _supportDir(), 'byedpi-test-results.json');
41+
42+
Future<Map<String, dynamic>> readTestResults() async {
43+
final f = File(await _resultsPath());
44+
if (!f.existsSync()) return {};
45+
try {
46+
final decoded = jsonDecode(await f.readAsString());
47+
return decoded is Map<String, dynamic> ? decoded : {};
48+
} catch (_) {
49+
return {};
50+
}
51+
}
52+
53+
Future<void> writeTestResults(Map<String, dynamic> results) =>
54+
_resultsPath().then((p) => _atomicWrite(p, jsonEncode(results)));

lib/common/inbound_auth.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ const _kInboundAuthAlphabet =
77
'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz0123456789';
88
const _kInboundAuthLength = 24;
99

10+
// Exposed so app-side clients going through the local mixed-port can answer its
11+
// 407 challenge (the inbound auth below is otherwise opaque to them).
12+
const inboundAuthUser = _kInboundAuthUser;
13+
14+
Future<String?> inboundAuthPassword() => preferences.getInboundAuth();
15+
1016
String _generateInboundPassword() {
1117
final r = Random.secure();
1218
final buf = StringBuffer();

lib/providers/state.dart

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:dynamic_color/dynamic_color.dart';
22
import 'package:fl_clash/byedpi/host_list.dart';
3+
import 'package:fl_clash/byedpi/test_store.dart';
34
import 'package:fl_clash/common/common.dart';
45
import 'package:fl_clash/core/controller.dart';
56
import 'package:fl_clash/enum/enum.dart';
@@ -614,7 +615,13 @@ Future<SetupState> setupState(Ref ref, int? profileId) async {
614615
: [];
615616
final byeDpiSettings = ref.watch(byeDpiSettingsProvider);
616617
final hostListText = await readHostList();
617-
final byeDpiHostList = hostListText.split('\n');
618+
// Route to byedpi only hosts the active strategy verified (list − exclude);
619+
// excluded hosts get no byedpi rule and fall to normal routing (VPN).
620+
final exclude = await readExclude();
621+
final byeDpiHostList = hostListText
622+
.split('\n')
623+
.where((h) => !exclude.contains(h.trim()))
624+
.toList();
618625
return SetupState(
619626
profileId: profileId,
620627
profileLastUpdateDate: profileLastUpdateDate,

lib/providers/strategy_test.dart

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'dart:async';
22
import 'dart:convert';
33

44
import 'package:fl_clash/byedpi/host_list.dart';
5+
import 'package:fl_clash/byedpi/test_store.dart';
56
import 'package:fl_clash/controller.dart';
67
import 'package:fl_clash/plugins/service.dart';
78
import 'package:fl_clash/providers/byedpi.dart';
@@ -26,6 +27,7 @@ class StrategyTestResult {
2627
final int success;
2728
final int totalRequests;
2829
final List<SiteOutcome> sites;
30+
final int? testedAt; // epoch ms
2931

3032
const StrategyTestResult({
3133
required this.id,
@@ -34,9 +36,18 @@ class StrategyTestResult {
3436
required this.success,
3537
required this.totalRequests,
3638
required this.sites,
39+
this.testedAt,
3740
});
41+
42+
List<String> get failedHosts => [
43+
for (final s in sites)
44+
if (s.ok == 0) s.site,
45+
];
3846
}
3947

48+
// (byedpiCount, vpnCount) reported back to the UI on apply().
49+
typedef ApplySplit = ({int byedpi, int vpn});
50+
4051
class StrategyTestState {
4152
final TestPhase phase;
4253
final int completed;
@@ -72,8 +83,9 @@ class StrategyTestState {
7283

7384
// Drives the in-app strategy auto-test (bydpi flavor). The native side runs
7485
// byedpi standalone (no VPN tun) per strategy and streams progress; we snapshot
75-
// and pause the VPN for the run, then restore it. Apply selects the winning id
76-
// through the existing preset model.
86+
// and pause the VPN for the run, then restore it. Results persist to a cache so
87+
// the dashboard renders without re-testing. Apply selects the winning id AND
88+
// rewrites the byedpi exclude list (hosts that strategy failed → routed via VPN).
7789
@riverpod
7890
class StrategyTestController extends _$StrategyTestController {
7991
Service get _svc => Service();
@@ -83,6 +95,40 @@ class StrategyTestController extends _$StrategyTestController {
8395
@override
8496
StrategyTestState build() => const StrategyTestState();
8597

98+
// Render last results from the on-disk cache (no re-test).
99+
Future<void> loadCached() async {
100+
if (state.phase == TestPhase.running || state.results.isNotEmpty) return;
101+
final cache = await readTestResults();
102+
if (cache.isEmpty) return;
103+
final results = <StrategyTestResult>[];
104+
cache.forEach((id, raw) {
105+
if (raw is! Map) return;
106+
results.add(_resultFromCache(id, Map<String, dynamic>.from(raw)));
107+
});
108+
results.sort((a, b) => b.percent.compareTo(a.percent));
109+
state = StrategyTestState(phase: TestPhase.done, results: results);
110+
}
111+
112+
StrategyTestResult _resultFromCache(String id, Map<String, dynamic> raw) {
113+
final sites = [
114+
for (final s in (raw['sites'] as List? ?? []))
115+
SiteOutcome(
116+
(s as Map)['site'].toString(),
117+
(s['ok'] as num).toInt(),
118+
(s['total'] as num).toInt(),
119+
),
120+
];
121+
return StrategyTestResult(
122+
id: id,
123+
label: (raw['label'] ?? id).toString(),
124+
percent: (raw['percent'] as num?)?.toInt() ?? 0,
125+
success: sites.fold(0, (a, s) => a + s.ok),
126+
totalRequests: sites.fold(0, (a, s) => a + s.total),
127+
sites: sites,
128+
testedAt: (raw['timestamp'] as num?)?.toInt(),
129+
);
130+
}
131+
86132
Future<void> run({
87133
int requests = 1,
88134
int timeout = 5,
@@ -176,17 +222,57 @@ class StrategyTestController extends _$StrategyTestController {
176222
await appController.updateStatus(true);
177223
_wasVpnOn = false;
178224
}
179-
final sorted = [...state.results]
180-
..sort((a, b) => b.percent.compareTo(a.percent));
225+
final now = DateTime.now().millisecondsSinceEpoch;
226+
final stamped = [
227+
for (final r in state.results)
228+
StrategyTestResult(
229+
id: r.id,
230+
label: r.label,
231+
percent: r.percent,
232+
success: r.success,
233+
totalRequests: r.totalRequests,
234+
sites: r.sites,
235+
testedAt: now,
236+
),
237+
]..sort((a, b) => b.percent.compareTo(a.percent));
238+
await _persist(stamped);
181239
state = StrategyTestState(
182240
phase: TestPhase.done,
183241
completed: state.completed,
184242
total: state.total,
185-
results: sorted,
243+
results: stamped,
186244
error: error,
187245
);
188246
}
189247

190-
Future<void> apply(String id) =>
191-
ref.read(byeDpiSettingsProvider.notifier).setPreset(id);
248+
// Merge this run's results into the on-disk cache (other strategies' prior
249+
// entries survive), so per-strategy dates and offline render work.
250+
Future<void> _persist(List<StrategyTestResult> results) async {
251+
final cache = await readTestResults();
252+
for (final r in results) {
253+
cache[r.id] = {
254+
'label': r.label,
255+
'percent': r.percent,
256+
'timestamp': r.testedAt,
257+
'sites': [
258+
for (final s in r.sites)
259+
{'site': s.site, 'ok': s.ok, 'total': s.total},
260+
],
261+
};
262+
}
263+
await writeTestResults(cache);
264+
}
265+
266+
// Apply a strategy: make it active AND route its failed hosts via VPN by
267+
// writing them to the exclude list, then re-apply the profile so the byedpi
268+
// routing rules rebuild. Returns the byedpi/VPN split for the UI.
269+
Future<ApplySplit> apply(String id) async {
270+
final result = state.results.where((r) => r.id == id).firstOrNull;
271+
final failed = result?.failedHosts ?? const <String>[];
272+
await writeExclude(failed);
273+
await ref.read(byeDpiSettingsProvider.notifier).setPreset(id);
274+
await appController.applyProfile(silence: true);
275+
final total = result?.sites.length ?? 0;
276+
return (byedpi: total - failed.length, vpn: failed.length);
277+
}
192278
}

0 commit comments

Comments
 (0)