Add proxy routing and sing-box support

This commit is contained in:
Fabian Freund
2026-05-22 18:16:31 +02:00
parent 51289f1266
commit a5974617aa
262 changed files with 32003 additions and 3962 deletions
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
part 'browser_dns_leak_guard.g.dart';
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
/// [DohSettingsMode.off]) while at least one profile is running.
///
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
/// the system network, *before* any SOCKS connection is established — so a
/// DoH lookup leaks the destination outside the proxy even when the data
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
/// TRR so GeckoView uses its native resolver, which — combined with
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
/// direct DoH lookup first.
///
/// The previous TRR mode is captured on engage and restored when all
/// profiles stop.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
@Riverpod(keepAlive: true)
class BrowserDnsLeakGuard extends _$BrowserDnsLeakGuard {
DohSettingsMode? _savedMode;
@override
Future<void> build() async {
final runtime = ref.watch(singboxProxyRuntimeRepositoryProvider);
// Skip while a start/stop is in flight. `startProfiles` resets the runtime
// state to `AsyncLoading` for the entire restart — `asData` is briefly
// null, which would otherwise look like "no profiles running" and trigger
// a premature DoH restore in the middle of e.g. starting a second profile
// while one is already active, opening a leak window during the transition.
if (runtime.isLoading) return;
final anyRunning = runtime.asData?.value.endpoints.isNotEmpty ?? false;
if (anyRunning) {
await _enforceOffMode();
} else if (_savedMode != null) {
await _restoreSavedMode();
}
}
Future<void> _enforceOffMode() async {
final engine = ref.read(engineSettingsRepositoryProvider.notifier);
try {
final current = await engine.fetchSettings();
if (current.dohSettingsMode == DohSettingsMode.off) {
// Already off — don't capture it as the "saved" value, otherwise we
// would restore it back to off on disengage instead of the user's
// real previous choice.
return;
}
_savedMode = current.dohSettingsMode;
await engine.updateSettings(
(current) => current.copyWith.dohSettingsMode(DohSettingsMode.off),
);
} catch (error, stack) {
logger.e(
'browser DNS leak guard failed to disable TRR',
error: error,
stackTrace: stack,
);
}
}
Future<void> _restoreSavedMode() async {
final saved = _savedMode;
_savedMode = null;
if (saved == null) return;
try {
await ref
.read(engineSettingsRepositoryProvider.notifier)
.updateSettings((current) => current.copyWith.dohSettingsMode(saved));
} catch (error, stack) {
logger.e(
'browser DNS leak guard failed to restore DoH mode',
error: error,
stackTrace: stack,
);
}
}
}
@@ -0,0 +1,128 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'browser_dns_leak_guard.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
/// [DohSettingsMode.off]) while at least one profile is running.
///
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
/// the system network, *before* any SOCKS connection is established — so a
/// DoH lookup leaks the destination outside the proxy even when the data
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
/// TRR so GeckoView uses its native resolver, which — combined with
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
/// direct DoH lookup first.
///
/// The previous TRR mode is captured on engage and restored when all
/// profiles stop.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
@ProviderFor(BrowserDnsLeakGuard)
final browserDnsLeakGuardProvider = BrowserDnsLeakGuardProvider._();
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
/// [DohSettingsMode.off]) while at least one profile is running.
///
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
/// the system network, *before* any SOCKS connection is established — so a
/// DoH lookup leaks the destination outside the proxy even when the data
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
/// TRR so GeckoView uses its native resolver, which — combined with
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
/// direct DoH lookup first.
///
/// The previous TRR mode is captured on engage and restored when all
/// profiles stop.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
final class BrowserDnsLeakGuardProvider
extends $AsyncNotifierProvider<BrowserDnsLeakGuard, void> {
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
/// [DohSettingsMode.off]) while at least one profile is running.
///
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
/// the system network, *before* any SOCKS connection is established — so a
/// DoH lookup leaks the destination outside the proxy even when the data
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
/// TRR so GeckoView uses its native resolver, which — combined with
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
/// direct DoH lookup first.
///
/// The previous TRR mode is captured on engage and restored when all
/// profiles stop.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
BrowserDnsLeakGuardProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'browserDnsLeakGuardProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$browserDnsLeakGuardHash();
@$internal
@override
BrowserDnsLeakGuard create() => BrowserDnsLeakGuard();
}
String _$browserDnsLeakGuardHash() =>
r'366aaa6ae8c163449dca50147be0451c766ba765';
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
/// [DohSettingsMode.off]) while at least one profile is running.
///
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
/// the system network, *before* any SOCKS connection is established — so a
/// DoH lookup leaks the destination outside the proxy even when the data
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
/// TRR so GeckoView uses its native resolver, which — combined with
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
/// direct DoH lookup first.
///
/// The previous TRR mode is captured on engage and restored when all
/// profiles stop.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
abstract class _$BrowserDnsLeakGuard extends $AsyncNotifier<void> {
FutureOr<void> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<void>, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, void>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
/// Builds a sing-box [SingboxProxyDnsConfig] from automatic browser-DNS
/// mirroring plus per-profile [overridesByProfileId].
///
/// Profiles without an override reuse the browser DoH URL, detoured through the
/// profile and scoped to that profile's SOCKS inbound. Profiles with an
/// override use their own resolver instead.
///
/// Returns null when there is nothing to configure, letting sing-box fall back
/// to its built-in resolver behaviour.
SingboxProxyDnsConfig? buildDnsConfig({
required Map<String, ProxyDnsOverride?> overridesByProfileId,
required Set<String> runningProfileIds,
required String? browserDohUrl,
}) {
final servers = <SingboxProxyDnsServerConfig>[];
final hasBrowserDoh = browserDohUrl != null && browserDohUrl.isNotEmpty;
if (hasBrowserDoh) {
servers.add(
SingboxProxyDnsServerConfig(tag: 'browser-doh', address: browserDohUrl),
);
// Mirror the browser DoH URL through each running profile unless that
// profile has an explicit override. Scope by inbound so endpoint bootstrap
// lookups do not route through the not-yet-ready outbound and deadlock.
for (final profileId in runningProfileIds) {
if (overridesByProfileId[profileId] != null) {
continue;
}
final outboundTag = _outboundTagForProfile(profileId);
final inboundTag = _inboundTagForProfile(profileId);
servers.add(
SingboxProxyDnsServerConfig(
tag: 'browser-doh-${singboxSanitizeTag(profileId)}',
address: browserDohUrl,
detourTag: outboundTag,
matchInbounds: [inboundTag],
),
);
}
}
overridesByProfileId.forEach((profileId, override) {
if (override == null || !runningProfileIds.contains(profileId)) {
return;
}
final outboundTag = _outboundTagForProfile(profileId);
final inboundTag = _inboundTagForProfile(profileId);
final address = override.remoteServerAddress;
if (address == null || address.isEmpty) {
return;
}
servers.add(
SingboxProxyDnsServerConfig(
tag: 'override-${singboxSanitizeTag(profileId)}',
address: address,
detourTag: outboundTag,
matchInbounds: [inboundTag],
),
);
});
if (servers.isEmpty) return null;
return SingboxProxyDnsConfig(
servers: servers,
finalServerTag: hasBrowserDoh ? 'browser-doh' : null,
domainStrategy: _domainStrategy(overridesByProfileId).singboxValue,
);
}
ProxyDnsDomainStrategy _domainStrategy(
Map<String, ProxyDnsOverride?> overridesByProfileId,
) {
for (final override in overridesByProfileId.values) {
if (override != null) return override.domainStrategy;
}
return ProxyDnsDomainStrategy.preferIpv4;
}
/// Must mirror Kotlin `SingboxTagFormat.outboundTag(profileId)`.
///
/// The Kotlin builder receives the *runtime* profile id (which Dart prefixes
/// with `singbox:` via [SingboxProxyConnectionId] in
/// `ProxyProfileX.toRuntimeProfile`), then sanitises it. We must therefore
/// apply the same prefix here so the detour tag we emit references the same
/// outbound the builder actually created.
///
/// The mirrored Kotlin test lives in `SingboxTagFormatTest.kt` — both must
/// update together if this format ever changes.
String _outboundTagForProfile(String profileId) {
return singboxOutboundTag(SingboxProxyConnectionId(profileId).encode());
}
/// Must mirror Kotlin `SingboxTagFormat.inboundTag(profileId)`.
String _inboundTagForProfile(String profileId) {
return singboxInboundTag(SingboxProxyConnectionId(profileId).encode());
}
/// Public so the format-contract test can assert it directly.
String singboxOutboundTag(String runtimeProfileId) =>
'out-${singboxSanitizeTag(runtimeProfileId)}';
String singboxInboundTag(String runtimeProfileId) =>
'in-${singboxSanitizeTag(runtimeProfileId)}';
String singboxSanitizeTag(String value) =>
value.replaceAll(RegExp('[^A-Za-z0-9_.-]'), '_');
@@ -0,0 +1,237 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
import 'package:weblibre/features/proxy/data/models/proxy_share.dart';
import 'package:weblibre/features/proxy/data/models/wireguard_config_import.dart';
import 'package:weblibre/features/proxy/data/parsers/singbox_proxy_uri.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
part 'proxy_input_consumer.g.dart';
enum ProxyFileImportKind { wireguardConf, singboxOutboundJson }
sealed class ProxyInputOutcome {
const ProxyInputOutcome();
}
class ProxyInputImported extends ProxyInputOutcome {
final ProxyProfile created;
const ProxyInputImported(this.created);
}
class ProxyInputSeed extends ProxyInputOutcome {
final ProxyProfileSeed seed;
const ProxyInputSeed(this.seed);
}
class ProxyInputError extends ProxyInputOutcome {
final String message;
const ProxyInputError(this.message);
}
@Riverpod(keepAlive: true)
class ProxyInputConsumer extends _$ProxyInputConsumer {
Future<ProxyInputOutcome> consumeRawText(String raw) async {
final trimmed = raw.trim();
if (trimmed.startsWith('$weblibreProxyShareScheme://')) {
return _consumeShareUri(trimmed);
}
if (_looksLikeWireguardConf(trimmed)) {
return _seedOutcome(
() => _seedFromWireguardConf(trimmed, fileName: 'WireGuard'),
logMessage: 'Failed to parse WireGuard configuration',
);
}
if (_looksLikeJsonObject(trimmed)) {
return _seedOutcome(
() => _seedFromSingboxOutboundJson(trimmed, fileName: 'Outbound'),
logMessage: 'Failed to parse pasted sing-box outbound JSON',
);
}
return _seedOutcome(() {
final imported = importSingboxProxyUri(trimmed);
return ProxyProfileSeed(
type: imported.type,
name: imported.name,
values: imported.values,
);
}, logMessage: 'Failed to import proxy URI');
}
Future<ProxyInputOutcome> consumeFile(
ProxyFileImportKind kind,
PlatformFile file,
) async {
final String text;
try {
text = await _readFileText(file);
} catch (error, stackTrace) {
logger.e(
'Failed to read proxy import file ${file.name}',
error: error,
stackTrace: stackTrace,
);
return ProxyInputError('Failed to read file: $error');
}
return _seedOutcome(
() => switch (kind) {
ProxyFileImportKind.wireguardConf => _seedFromWireguardConf(
text,
fileName: file.name,
),
ProxyFileImportKind.singboxOutboundJson => _seedFromSingboxOutboundJson(
text,
fileName: file.name,
),
},
logMessage: 'Invalid proxy import file ${file.name} ($kind)',
);
}
Future<ProxyInputOutcome> _consumeShareUri(String text) async {
try {
final envelope = decodeProxyShareUri(text);
final created = await ref
.read(singboxProxyProfilesRepositoryProvider.notifier)
.createProfile(
name: envelope.name,
type: envelope.type,
configJson: envelope.configJson,
secretJson: envelope.secretJson,
dnsOverrideJson: envelope.dnsOverrideJson,
);
return ProxyInputImported(created);
} on FormatException catch (error, stackTrace) {
logger.e(
'Failed to decode WebLibre proxy share URI',
error: error,
stackTrace: stackTrace,
);
return ProxyInputError(error.message);
}
}
ProxyInputOutcome _seedOutcome(
ProxyProfileSeed Function() createSeed, {
required String logMessage,
}) {
try {
return ProxyInputSeed(createSeed());
} on FormatException catch (error, stackTrace) {
logger.e(logMessage, error: error, stackTrace: stackTrace);
return ProxyInputError(error.message);
}
}
Future<String> _readFileText(PlatformFile file) async {
final bytes = file.bytes;
if (bytes != null) {
return utf8.decode(bytes, allowMalformed: true);
}
final path = file.path;
if (path == null) {
throw const FormatException('Unable to read file contents.');
}
return File(path).readAsString();
}
@override
void build() {}
}
ProxyProfileSeed _seedFromWireguardConf(
String configText, {
required String fileName,
}) {
final imported = WireguardConfigImport.fromConfigText(configText);
final dnsAddress = imported.primaryDnsAddress;
final dnsOverrideJson = dnsAddress == null
? null
: jsonEncode(ProxyDnsOverride(remoteServerAddress: dnsAddress).toJson());
return ProxyProfileSeed(
type: SingboxProxyProfileType.wireguard,
name: _stripExtension(fileName),
values: imported.values,
dnsOverrideJson: dnsOverrideJson,
);
}
ProxyProfileSeed _seedFromSingboxOutboundJson(
String text, {
required String fileName,
}) {
final decoded = jsonDecode(text);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Expected a sing-box outbound JSON object.');
}
final outboundType = decoded['type'];
if (outboundType is! String) {
throw const FormatException(
'Outbound JSON is missing a top-level "type" field.',
);
}
final spec = singboxProxyFormSpecs.values.firstWhere(
(entry) => entry.outboundType == outboundType,
orElse: () => throw FormatException(
'No structured form for outbound type "$outboundType". '
'Use Custom Outbound JSON instead.',
),
);
final values = spec.valuesFromJson(configJson: jsonEncode(decoded));
return ProxyProfileSeed(
type: spec.type,
name: (decoded['tag'] as String?) ?? _stripExtension(fileName),
values: values,
);
}
String _stripExtension(String fileName) {
final dot = fileName.lastIndexOf('.');
if (dot <= 0) return fileName;
return fileName.substring(0, dot);
}
bool _looksLikeWireguardConf(String text) {
return text.contains('[Interface]') &&
(text.contains('PrivateKey') || text.contains('Address'));
}
bool _looksLikeJsonObject(String text) {
return text.startsWith('{') && text.endsWith('}');
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'proxy_input_consumer.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ProxyInputConsumer)
final proxyInputConsumerProvider = ProxyInputConsumerProvider._();
final class ProxyInputConsumerProvider
extends $NotifierProvider<ProxyInputConsumer, void> {
ProxyInputConsumerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'proxyInputConsumerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$proxyInputConsumerHash();
@$internal
@override
ProxyInputConsumer create() => ProxyInputConsumer();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$proxyInputConsumerHash() =>
r'fed529f253a9bdf72d0b1c23a298765b681d07b8';
abstract class _$ProxyInputConsumer extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,249 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:socks5_proxy/socks_client.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/tor/domain/extensions/tor_status_x.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
part 'proxy_latency_tester.g.dart';
/// Mullvad's connectivity check — returns JSON with the egress `ip` plus
/// geolocation. Single round trip gives both reachability and the IP we
/// surface in the chip. Mullvad has a no-logs policy, which fits a
/// privacy-focused browser better than funneling every probe through
/// Cloudflare.
const _probeUrl = 'https://am.i.mullvad.net/json';
const _testTimeout = Duration(seconds: 8);
class ProxyLatencyData with FastEquatable {
final Duration latency;
final int statusCode;
final String? egressIp;
ProxyLatencyData({
required this.latency,
required this.statusCode,
this.egressIp,
});
@override
List<Object?> get hashParameters => [latency, statusCode, egressIp];
}
/// Per-profile latency results, keyed by profile id. Holds the latest result
/// only; we don't keep history because the test is user-triggered and the user
/// is looking at the chip we render from it.
@Riverpod(keepAlive: true)
class ProxyLatencyResults extends _$ProxyLatencyResults {
@override
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>> build() => const {};
void _set(ProxyConnectionId id, AsyncValue<ProxyLatencyData> result) {
state = {...state, id: result};
}
void clear(ProxyConnectionId id) {
if (!state.containsKey(id)) return;
state = {
for (final entry in state.entries)
if (entry.key != id) entry.key: entry.value,
};
}
Future<void> _run(
ProxyConnectionId id,
SingboxProxyRuntimeEndpoint endpoint,
) async {
_set(id, const AsyncLoading());
final result = await AsyncValue.guard(
() => measureViaSocks(endpoint: endpoint, url: Uri.parse(_probeUrl)),
);
_set(id, result);
}
/// Runs a probe through the profile's local SOCKS endpoint and records the
/// result. Profile must already be running — the endpoint is read from the
/// live runtime state.
Future<void> test(String profileId) async {
final runtimeState = ref.read(singboxProxyRuntimeRepositoryProvider).value;
final endpoint = runtimeState?.endpoints.where((endpoint) {
final decoded = ProxyConnectionId.decode(endpoint.profileId);
return decoded is SingboxProxyConnectionId &&
decoded.profileId == profileId;
}).firstOrNull;
if (endpoint == null) {
_set(
SingboxProxyConnectionId(profileId),
AsyncError('Profile is not running', StackTrace.current),
);
return;
}
await _run(SingboxProxyConnectionId(profileId), endpoint);
}
/// Probes Tor's local SOCKS endpoint. Keyed by [TorProxyConnectionId] so the
/// chip and clear/retain logic share a code path with sing-box profiles.
Future<void> testTor() async {
final socksPort = ref.read(torProxyServiceProvider).value?.usableSocksPort;
if (socksPort == null) {
_set(
const TorProxyConnectionId(),
AsyncError('Tor is not ready', StackTrace.current),
);
return;
}
await _run(
const TorProxyConnectionId(),
SingboxProxyRuntimeEndpoint(
profileId: const TorProxyConnectionId().encode(),
host: '127.0.0.1',
port: socksPort,
username: '',
password: '',
),
);
}
/// Drop any cached results for profile ids that are no longer running.
void retainRunning(Set<ProxyConnectionId> runningIds) {
if (setEquals(runningIds, state.keys.toSet())) return;
state = {
for (final entry in state.entries)
if (runningIds.contains(entry.key)) entry.key: entry.value,
};
}
}
/// Runs a warmup probe (discarded) followed by [sampleCount] timed requests
/// through a single SOCKS5-bound [HttpClient] and reports the minimum RTT —
/// mirrors the speedtest-style "best RTT" reporting used by NekoBox/v2rayN/
/// clash for user-triggered URL tests. The cold path (TCP + SOCKS5 handshake +
/// upstream outbound warmup) skews the first sample, so we discard it. Reusing
/// the HttpClient lets later samples reuse the pooled SOCKS connection.
///
/// Throws on failure; the latest successful sample also yields the egress IP
/// parsed from the probe body.
Future<ProxyLatencyData> measureViaSocks({
required SingboxProxyRuntimeEndpoint endpoint,
required Uri url,
Duration timeout = _testTimeout,
int sampleCount = 3,
}) async {
final httpClient = HttpClient()..connectionTimeout = timeout;
SocksTCPClient.assignToHttpClient(httpClient, [
ProxySettings(
InternetAddress(endpoint.host),
endpoint.port,
username: endpoint.username,
password: endpoint.password,
),
]);
try {
// Warmup — result discarded for timing, but if it fails we surface the
// error rather than aggregating min of {failures}.
await _singleProbe(httpClient, url, timeout);
Duration? best;
var lastStatusCode = 0;
String? lastEgressIp;
Object? lastError;
StackTrace? lastStackTrace;
for (var i = 0; i < sampleCount; i++) {
try {
final probe = await _singleProbe(httpClient, url, timeout);
if (best == null || probe.latency < best) best = probe.latency;
lastStatusCode = probe.statusCode;
lastEgressIp = probe.egressIp ?? lastEgressIp;
} catch (error, stackTrace) {
lastError = error;
lastStackTrace = stackTrace;
}
}
if (best == null) {
if (lastError != null) {
Error.throwWithStackTrace(lastError, lastStackTrace!);
}
throw const SocketException('No samples completed');
}
return ProxyLatencyData(
latency: best,
statusCode: lastStatusCode,
egressIp: lastEgressIp,
);
} finally {
httpClient.close(force: true);
}
}
Future<ProxyLatencyData> _singleProbe(
HttpClient httpClient,
Uri url,
Duration timeout,
) async {
final stopwatch = Stopwatch()..start();
final request = await httpClient.getUrl(url).timeout(timeout);
final response = await request.close().timeout(timeout);
stopwatch.stop();
String? egressIp;
if (response.statusCode == 200) {
final body = await response.transform(utf8.decoder).join();
egressIp = _parseEgressIp(body);
} else {
await response.drain<void>();
}
return ProxyLatencyData(
latency: stopwatch.elapsed,
statusCode: response.statusCode,
egressIp: egressIp,
);
}
String? _parseEgressIp(String body) {
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final ip = decoded['ip'];
if (ip is String && ip.isNotEmpty) return ip;
}
} on FormatException {
// Not JSON — fall through.
}
return null;
}
@@ -0,0 +1,94 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'proxy_latency_tester.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Per-profile latency results, keyed by profile id. Holds the latest result
/// only; we don't keep history because the test is user-triggered and the user
/// is looking at the chip we render from it.
@ProviderFor(ProxyLatencyResults)
final proxyLatencyResultsProvider = ProxyLatencyResultsProvider._();
/// Per-profile latency results, keyed by profile id. Holds the latest result
/// only; we don't keep history because the test is user-triggered and the user
/// is looking at the chip we render from it.
final class ProxyLatencyResultsProvider
extends
$NotifierProvider<
ProxyLatencyResults,
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>
> {
/// Per-profile latency results, keyed by profile id. Holds the latest result
/// only; we don't keep history because the test is user-triggered and the user
/// is looking at the chip we render from it.
ProxyLatencyResultsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'proxyLatencyResultsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$proxyLatencyResultsHash();
@$internal
@override
ProxyLatencyResults create() => ProxyLatencyResults();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>> value,
) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>
>(value),
);
}
}
String _$proxyLatencyResultsHash() =>
r'f86dba5c5f17d1f74fdf2b7ca6f5adaa4c42ff75';
/// Per-profile latency results, keyed by profile id. Holds the latest result
/// only; we don't keep history because the test is user-triggered and the user
/// is looking at the chip we render from it.
abstract class _$ProxyLatencyResults
extends $Notifier<Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>> {
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>,
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>,
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>
>,
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
import 'package:weblibre/features/proxy/domain/repositories/container_proxy.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
part 'singbox_proxy_endpoint_sync.g.dart';
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
/// and diffs the previously registered set against the current endpoints.
///
/// Extracted from the runtime repository so that:
/// - the runtime repo only owns process state (start/stop/validate), and
/// - sync is a single side-effect channel — every transition (start, stop,
/// stream-driven refresh, native crash) flows through the same listener,
/// eliminating the duplicate-sync-call hazard that the inline approach had.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
@Riverpod(keepAlive: true)
class SingboxProxyEndpointSync extends _$SingboxProxyEndpointSync {
/// Proxy connection ids most recently registered with Gecko. Used to compute
/// the unregister set on the next sync.
var _registeredProxyIds = <String>{};
/// Serialises [_sync] runs so that a fast start→stop→start sequence can't
/// interleave upserts/removals.
final _syncLock = Lock();
Future<void> _sync(SingboxProxyRuntimeState runtimeState) async {
await _syncLock.synchronized(() async {
final nextProxyIds = runtimeState.endpoints
.map((endpoint) => endpoint.profileId)
.toSet();
final containerProxy = ref.read(
containerProxyRepositoryProvider.notifier,
);
for (final proxyId in _registeredProxyIds.difference(nextProxyIds)) {
await containerProxy.removeProxy(proxyId);
}
if (runtimeState.endpoints.isNotEmpty) {
final profiles = await ref
.read(singboxProxyProfilesRepositoryProvider.notifier)
.fetchProfiles();
final profileNames = {
for (final profile in profiles)
profile.proxyConnectionId: profile.name,
};
for (final endpoint in runtimeState.endpoints) {
await containerProxy.upsertProxy(
GeckoProxySettings(
id: endpoint.profileId,
title: profileNames[endpoint.profileId] ?? endpoint.profileId,
type: 'socks',
host: endpoint.host,
port: endpoint.port,
username: endpoint.username,
password: endpoint.password,
proxyDNS: true,
doNotProxyLocal: true,
),
);
}
}
_registeredProxyIds = nextProxyIds;
});
}
@override
void build() {
ref.listen<AsyncValue<SingboxProxyRuntimeState>>(
singboxProxyRuntimeRepositoryProvider,
fireImmediately: true,
(previous, next) {
final runtimeState = next.value;
if (runtimeState == null) return;
_sync(runtimeState).catchError((Object error, StackTrace stackTrace) {
logger.e(
'Failed to sync sing-box proxy endpoints to Gecko',
error: error,
stackTrace: stackTrace,
);
});
},
);
}
}
@@ -0,0 +1,116 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'singbox_proxy_endpoint_sync.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
/// and diffs the previously registered set against the current endpoints.
///
/// Extracted from the runtime repository so that:
/// - the runtime repo only owns process state (start/stop/validate), and
/// - sync is a single side-effect channel — every transition (start, stop,
/// stream-driven refresh, native crash) flows through the same listener,
/// eliminating the duplicate-sync-call hazard that the inline approach had.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
@ProviderFor(SingboxProxyEndpointSync)
final singboxProxyEndpointSyncProvider = SingboxProxyEndpointSyncProvider._();
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
/// and diffs the previously registered set against the current endpoints.
///
/// Extracted from the runtime repository so that:
/// - the runtime repo only owns process state (start/stop/validate), and
/// - sync is a single side-effect channel — every transition (start, stop,
/// stream-driven refresh, native crash) flows through the same listener,
/// eliminating the duplicate-sync-call hazard that the inline approach had.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
final class SingboxProxyEndpointSyncProvider
extends $NotifierProvider<SingboxProxyEndpointSync, void> {
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
/// and diffs the previously registered set against the current endpoints.
///
/// Extracted from the runtime repository so that:
/// - the runtime repo only owns process state (start/stop/validate), and
/// - sync is a single side-effect channel — every transition (start, stop,
/// stream-driven refresh, native crash) flows through the same listener,
/// eliminating the duplicate-sync-call hazard that the inline approach had.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
SingboxProxyEndpointSyncProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'singboxProxyEndpointSyncProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$singboxProxyEndpointSyncHash();
@$internal
@override
SingboxProxyEndpointSync create() => SingboxProxyEndpointSync();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(void value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<void>(value),
);
}
}
String _$singboxProxyEndpointSyncHash() =>
r'2d6b0641db33638b0b339b510dd63cdddab7f7cf';
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
/// and diffs the previously registered set against the current endpoints.
///
/// Extracted from the runtime repository so that:
/// - the runtime repo only owns process state (start/stop/validate), and
/// - sync is a single side-effect channel — every transition (start, stop,
/// stream-driven refresh, native crash) flows through the same listener,
/// eliminating the duplicate-sync-call hazard that the inline approach had.
///
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
/// explicitly listened-to from main.dart so the side effect runs without any
/// widget needing to depend on it.
abstract class _$SingboxProxyEndpointSync extends $Notifier<void> {
void build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<void, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<void, void>,
void,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:weblibre/features/proxy/data/parsers/singbox_proxy_uri.dart';
/// One line in a subscription that we attempted to parse. Either a usable
/// [SingboxProxyUriImport] or the [FormatException]-style error explaining
/// why the line failed to parse, so the UI can show a per-line outcome
/// instead of silently dropping nodes.
sealed class SubscriptionImportEntry {
final String rawLine;
const SubscriptionImportEntry({required this.rawLine});
}
class SubscriptionEntrySuccess extends SubscriptionImportEntry {
final SingboxProxyUriImport imported;
const SubscriptionEntrySuccess({
required super.rawLine,
required this.imported,
});
}
class SubscriptionEntryFailure extends SubscriptionImportEntry {
final Object error;
const SubscriptionEntryFailure({required super.rawLine, required this.error});
}
class SubscriptionImportResult {
final List<SubscriptionImportEntry> entries;
const SubscriptionImportResult(this.entries);
Iterable<SubscriptionEntrySuccess> get successes =>
entries.whereType<SubscriptionEntrySuccess>();
Iterable<SubscriptionEntryFailure> get failures =>
entries.whereType<SubscriptionEntryFailure>();
}
/// Fetches a v2rayN-style subscription URL and parses its contents.
///
/// Most subscription servers serve a base64-encoded blob whose decoded body is
/// a newline-delimited list of `ss://`, `vless://`, etc. URIs. Some serve the
/// raw newline-delimited list. We try both: base64 first, then raw, and use
/// whichever produces parseable URIs.
Future<SubscriptionImportResult> fetchSubscription(
Uri url, {
http.Client? client,
}) async {
final ownsClient = client == null;
final actualClient = client ?? http.Client();
try {
final response = await actualClient
.get(url, headers: {'User-Agent': 'WebLibre/sing-box-subscriber'})
.timeout(const Duration(seconds: 30));
if (response.statusCode >= 400) {
throw http.ClientException(
'Subscription returned HTTP ${response.statusCode}.',
url,
);
}
return parseSubscriptionBody(response.body);
} finally {
if (ownsClient) actualClient.close();
}
}
/// Decodes a subscription body into individual proxy entries. Public so the
/// UI can preview a pasted body without making a network call.
SubscriptionImportResult parseSubscriptionBody(String body) {
final lines = _tryBase64Decode(body) ?? body;
final entries = <SubscriptionImportEntry>[];
for (final raw in const LineSplitter().convert(lines)) {
final line = raw.trim();
if (line.isEmpty || line.startsWith('#')) continue;
try {
entries.add(
SubscriptionEntrySuccess(
rawLine: line,
imported: importSingboxProxyUri(line),
),
);
} on FormatException catch (error) {
entries.add(SubscriptionEntryFailure(rawLine: line, error: error));
}
}
return SubscriptionImportResult(entries);
}
String? _tryBase64Decode(String body) {
// Subscription bodies are base64 (sometimes URL-safe) without padding.
final stripped = body.replaceAll(RegExp(r'\s'), '');
if (stripped.isEmpty) return null;
// Only attempt if the body looks like base64 — bail out if it contains
// characters never present in base64 alphabets.
if (!RegExp(r'^[A-Za-z0-9+/_=-]+$').hasMatch(stripped)) return null;
try {
return utf8.decode(base64.decode(base64.normalize(stripped)));
} catch (_) {
try {
return utf8.decode(base64Url.decode(base64Url.normalize(stripped)));
} catch (_) {
return null;
}
}
}