Add proxy routing and sing-box support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user