Add proxy routing and sing-box support
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* 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 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
|
||||
part 'container_proxy.g.dart';
|
||||
|
||||
/// Slower-than-default healthcheck timeout for the Tor SOCKS port. Tor's
|
||||
/// bootstrap can stall when the service is starting up before the proxy
|
||||
/// service is fully ready; we accept a longer wait here than for other ops.
|
||||
const _torHealthcheckTimeout = Duration(seconds: 30);
|
||||
|
||||
/// Default healthcheck timeout for ordinary proxy CRUD operations.
|
||||
const _defaultHealthcheckTimeout = Duration(seconds: 10);
|
||||
|
||||
/// Outer cap on a single healthcheck wait. Exceeded → [TimeoutException].
|
||||
const _maxHealthcheckTimeout = Duration(seconds: 15);
|
||||
|
||||
const _healthcheckInitialDelay = Duration(milliseconds: 50);
|
||||
const _healthcheckMaxDelay = Duration(milliseconds: 500);
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ContainerProxyRepository extends _$ContainerProxyRepository {
|
||||
final _service = GeckoContainerProxyService();
|
||||
final _serviceLock = Lock();
|
||||
|
||||
Future<void> setTorProxyPort(int? port) {
|
||||
return _runLocked(
|
||||
healthcheckTimeout: _torHealthcheckTimeout,
|
||||
body: () {
|
||||
if (port == null) {
|
||||
return _service.removeProxy(const TorProxyConnectionId().encode());
|
||||
}
|
||||
|
||||
return _service.upsertProxy(
|
||||
GeckoProxySettings(
|
||||
id: const TorProxyConnectionId().encode(),
|
||||
title: 'Tor',
|
||||
type: 'socks',
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
proxyDNS: true,
|
||||
doNotProxyLocal: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> upsertProxy(GeckoProxySettings proxy) {
|
||||
return _runLocked(body: () => _service.upsertProxy(proxy));
|
||||
}
|
||||
|
||||
Future<void> removeProxy(String proxyId) {
|
||||
return _runLocked(body: () => _service.removeProxy(proxyId));
|
||||
}
|
||||
|
||||
Future<void> setContainerProxy(String contextId, String proxyId) {
|
||||
return _runLocked(
|
||||
body: () => _service.setContainerProxy(contextId, proxyId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearContainerProxy(String contextId) {
|
||||
return _runLocked(body: () => _service.clearContainerProxy(contextId));
|
||||
}
|
||||
|
||||
Future<void> removeContainerProxyRelation(String contextId, String proxyId) {
|
||||
return _runLocked(
|
||||
body: () => _service.removeContainerProxyRelation(contextId, proxyId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setSiteAssignments(List<SiteAssignment> assignements) {
|
||||
return _runLocked(
|
||||
body: () => _service.setSiteAssignments(
|
||||
Map.fromEntries(
|
||||
assignements.map(
|
||||
(e) => MapEntry(
|
||||
e.assignedSite.origin,
|
||||
e.contextualIdentity ?? 'general',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Serialises [body] behind the service lock after the underlying Gecko
|
||||
/// proxy service reports healthy. Replaces the per-method boilerplate that
|
||||
/// each public mutator used to repeat.
|
||||
Future<T> _runLocked<T>({
|
||||
required Future<T> Function() body,
|
||||
Duration healthcheckTimeout = _defaultHealthcheckTimeout,
|
||||
}) {
|
||||
return _serviceLock.synchronized(() async {
|
||||
await _waitHealthcheck().timeout(healthcheckTimeout);
|
||||
return body();
|
||||
});
|
||||
}
|
||||
|
||||
/// Polls the Gecko proxy service with exponential backoff (capped) until it
|
||||
/// reports healthy. The previous implementation polled every 25ms, which
|
||||
/// burns CPU during the cold-start window before the native plugin is ready.
|
||||
Future<void> _waitHealthcheck({
|
||||
Duration timeout = _maxHealthcheckTimeout,
|
||||
}) async {
|
||||
final startTime = DateTime.now();
|
||||
var delay = _healthcheckInitialDelay;
|
||||
|
||||
while (!await _service.healthcheck()) {
|
||||
if (DateTime.now().difference(startTime) > timeout) {
|
||||
throw TimeoutException('Timed out waiting for proxy service');
|
||||
}
|
||||
|
||||
await Future<void>.delayed(delay);
|
||||
delay = delay * 2;
|
||||
if (delay > _healthcheckMaxDelay) delay = _healthcheckMaxDelay;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'container_proxy.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(ContainerProxyRepository)
|
||||
final containerProxyRepositoryProvider = ContainerProxyRepositoryProvider._();
|
||||
|
||||
final class ContainerProxyRepositoryProvider
|
||||
extends $NotifierProvider<ContainerProxyRepository, void> {
|
||||
ContainerProxyRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'containerProxyRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$containerProxyRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ContainerProxyRepository create() => ContainerProxyRepository();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$containerProxyRepositoryHash() =>
|
||||
r'08cd408a3ae96c859ed5c9d56d61cf7e6514415f';
|
||||
|
||||
abstract class _$ContainerProxyRepository 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,53 @@
|
||||
/*
|
||||
* 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_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'singbox_proxy_credentials.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SingboxProxyCredentialsRepository
|
||||
extends _$SingboxProxyCredentialsRepository {
|
||||
static const _storage = FlutterSecureStorage();
|
||||
|
||||
String _secretKey(String profileId) => 'singbox_proxy.secret.$profileId';
|
||||
|
||||
Future<String?> readSecretJson(String profileId) {
|
||||
return _storage.read(key: _secretKey(profileId));
|
||||
}
|
||||
|
||||
Future<void> writeSecretJson(String profileId, String? secretJson) async {
|
||||
if (secretJson == null || secretJson.trim().isEmpty) {
|
||||
await deleteSecretJson(profileId);
|
||||
return;
|
||||
}
|
||||
|
||||
await _storage.write(key: _secretKey(profileId), value: secretJson);
|
||||
}
|
||||
|
||||
Future<void> deleteSecretJson(String profileId) {
|
||||
return _storage.delete(key: _secretKey(profileId));
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'singbox_proxy_credentials.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(SingboxProxyCredentialsRepository)
|
||||
final singboxProxyCredentialsRepositoryProvider =
|
||||
SingboxProxyCredentialsRepositoryProvider._();
|
||||
|
||||
final class SingboxProxyCredentialsRepositoryProvider
|
||||
extends $NotifierProvider<SingboxProxyCredentialsRepository, void> {
|
||||
SingboxProxyCredentialsRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'singboxProxyCredentialsRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() =>
|
||||
_$singboxProxyCredentialsRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SingboxProxyCredentialsRepository create() =>
|
||||
SingboxProxyCredentialsRepository();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$singboxProxyCredentialsRepositoryHash() =>
|
||||
r'b4e11b001ccccfbf26417963adf6cb81e1b1e69f';
|
||||
|
||||
abstract class _$SingboxProxyCredentialsRepository 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,78 @@
|
||||
/*
|
||||
* 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:collection';
|
||||
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:flutter_tor/flutter_tor.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/proxy/data/models/proxy_log_message.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||
|
||||
part 'singbox_proxy_logs.g.dart';
|
||||
|
||||
/// Cap to keep memory bounded. ~2KB per line × 2000 = ~4MB worst-case, which
|
||||
/// is well within budget for a debugging surface.
|
||||
const int _ringBufferCapacity = 2000;
|
||||
|
||||
/// Snapshot of buffered log entries. Most-recent-last (chronological).
|
||||
@Riverpod(keepAlive: true)
|
||||
class SingboxProxyLogs extends _$SingboxProxyLogs {
|
||||
final _buffer = Queue<ProxyLogMessage>();
|
||||
|
||||
StreamSubscription<SingboxProxyLogMessage>? _singboxSubscription;
|
||||
StreamSubscription<TorLogMessage>? _torSubscription;
|
||||
|
||||
void _append(ProxyLogMessage message) {
|
||||
_buffer.add(message);
|
||||
|
||||
while (_buffer.length > _ringBufferCapacity) {
|
||||
_buffer.removeFirst();
|
||||
}
|
||||
|
||||
state = List.unmodifiable(_buffer);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_buffer.clear();
|
||||
state = const [];
|
||||
}
|
||||
|
||||
@override
|
||||
List<ProxyLogMessage> build() {
|
||||
final client = ref.watch(singboxProxyClientProvider);
|
||||
final torLogs = torLogStream(ref);
|
||||
|
||||
_singboxSubscription = client.logStream.listen(
|
||||
(message) => _append(ProxyLogMessage.fromSingbox(message)),
|
||||
);
|
||||
_torSubscription = torLogs.listen(
|
||||
(message) => _append(ProxyLogMessage.fromTor(message)),
|
||||
);
|
||||
|
||||
ref.onDispose(() {
|
||||
unawaited(_singboxSubscription?.cancel());
|
||||
unawaited(_torSubscription?.cancel());
|
||||
});
|
||||
|
||||
return List.unmodifiable(_buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'singbox_proxy_logs.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Snapshot of buffered log entries. Most-recent-last (chronological).
|
||||
|
||||
@ProviderFor(SingboxProxyLogs)
|
||||
final singboxProxyLogsProvider = SingboxProxyLogsProvider._();
|
||||
|
||||
/// Snapshot of buffered log entries. Most-recent-last (chronological).
|
||||
final class SingboxProxyLogsProvider
|
||||
extends $NotifierProvider<SingboxProxyLogs, List<ProxyLogMessage>> {
|
||||
/// Snapshot of buffered log entries. Most-recent-last (chronological).
|
||||
SingboxProxyLogsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'singboxProxyLogsProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$singboxProxyLogsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SingboxProxyLogs create() => SingboxProxyLogs();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(List<ProxyLogMessage> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<List<ProxyLogMessage>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$singboxProxyLogsHash() => r'9fa30201ed4c128335142227226d937022f79d47';
|
||||
|
||||
/// Snapshot of buffered log entries. Most-recent-last (chronological).
|
||||
|
||||
abstract class _$SingboxProxyLogs extends $Notifier<List<ProxyLogMessage>> {
|
||||
List<ProxyLogMessage> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<List<ProxyLogMessage>, List<ProxyLogMessage>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<List<ProxyLogMessage>, List<ProxyLogMessage>>,
|
||||
List<ProxyLogMessage>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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:riverpod/riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/uuid.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
|
||||
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||
show ProxyProfile;
|
||||
import 'package:weblibre/features/user/data/providers.dart';
|
||||
|
||||
part 'singbox_proxy_profiles.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SingboxProxyProfilesRepository extends _$SingboxProxyProfilesRepository {
|
||||
Future<List<ProxyProfile>> fetchProfiles() {
|
||||
return ref.read(userDatabaseProvider).proxyProfileDao.fetchAll();
|
||||
}
|
||||
|
||||
Future<ProxyProfile?> findProfile(String id) {
|
||||
return ref.read(userDatabaseProvider).proxyProfileDao.findById(id);
|
||||
}
|
||||
|
||||
Future<ProxyProfile> createProfile({
|
||||
required String name,
|
||||
required SingboxProxyProfileType type,
|
||||
required String configJson,
|
||||
String? secretJson,
|
||||
String? dnsOverrideJson,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
final profile = ProxyProfile(
|
||||
id: uuid.v4(),
|
||||
name: name,
|
||||
type: type,
|
||||
configJson: configJson,
|
||||
dnsOverrideJson: dnsOverrideJson,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
|
||||
await ref.read(userDatabaseProvider).proxyProfileDao.upsert(profile);
|
||||
await ref
|
||||
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||
.writeSecretJson(profile.id, secretJson);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// Updates an existing profile, bumping `updatedAt` only when the persisted
|
||||
/// row actually changed. Pass [secretJson] (possibly null) to replace the
|
||||
/// stored secret; pass [updateSecret] = false to leave secrets untouched.
|
||||
Future<void> updateProfile(ProxyProfile profile, {String? secretJson}) async {
|
||||
final dao = ref.read(userDatabaseProvider).proxyProfileDao;
|
||||
final existing = await dao.findById(profile.id);
|
||||
|
||||
final contentChanged =
|
||||
existing == null ||
|
||||
existing.name != profile.name ||
|
||||
existing.type != profile.type ||
|
||||
existing.configJson != profile.configJson ||
|
||||
existing.dnsOverrideJson != profile.dnsOverrideJson;
|
||||
|
||||
if (contentChanged) {
|
||||
await dao.upsert(
|
||||
ProxyProfile(
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
type: profile.type,
|
||||
configJson: profile.configJson,
|
||||
dnsOverrideJson: profile.dnsOverrideJson,
|
||||
createdAt: existing?.createdAt ?? profile.createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (secretJson != null) {
|
||||
await ref
|
||||
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||
.writeSecretJson(profile.id, secretJson);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteProfile(String profileId) async {
|
||||
await ref.read(userDatabaseProvider).proxyProfileDao.deleteById(profileId);
|
||||
await ref
|
||||
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||
.deleteSecretJson(profileId);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<ProxyProfile>> build() {
|
||||
return ref.watch(userDatabaseProvider).proxyProfileDao.watch().watch();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'singbox_proxy_profiles.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(SingboxProxyProfilesRepository)
|
||||
final singboxProxyProfilesRepositoryProvider =
|
||||
SingboxProxyProfilesRepositoryProvider._();
|
||||
|
||||
final class SingboxProxyProfilesRepositoryProvider
|
||||
extends
|
||||
$StreamNotifierProvider<
|
||||
SingboxProxyProfilesRepository,
|
||||
List<ProxyProfile>
|
||||
> {
|
||||
SingboxProxyProfilesRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'singboxProxyProfilesRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$singboxProxyProfilesRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SingboxProxyProfilesRepository create() => SingboxProxyProfilesRepository();
|
||||
}
|
||||
|
||||
String _$singboxProxyProfilesRepositoryHash() =>
|
||||
r'5250f2aafb7ec621b3b33a53ff5a00874e8a4b12';
|
||||
|
||||
abstract class _$SingboxProxyProfilesRepository
|
||||
extends $StreamNotifier<List<ProxyProfile>> {
|
||||
Stream<List<ProxyProfile>> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<List<ProxyProfile>>, List<ProxyProfile>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<List<ProxyProfile>>, List<ProxyProfile>>,
|
||||
AsyncValue<List<ProxyProfile>>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* 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 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||
import 'package:weblibre/features/proxy/domain/services/dns_config_resolver.dart';
|
||||
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||
show ProxyProfile;
|
||||
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
||||
|
||||
part 'singbox_proxy_runtime.g.dart';
|
||||
|
||||
abstract interface class SingboxProxyClient {
|
||||
Stream<SingboxProxyRuntimeState> get stateStream;
|
||||
|
||||
Stream<SingboxProxyLogMessage> get logStream;
|
||||
|
||||
Future<String?> validateProfile(SingboxProxyProfile profile);
|
||||
|
||||
Future<SingboxProxyConfigResult> buildConfig(
|
||||
List<SingboxProxyProfile> profiles, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
});
|
||||
|
||||
Future<SingboxProxyRuntimeState> start(
|
||||
List<SingboxProxyProfile> profiles, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
});
|
||||
|
||||
Future<void> stop(List<String> profileIds);
|
||||
|
||||
Future<void> stopAll();
|
||||
|
||||
Future<SingboxProxyRuntimeState> getState();
|
||||
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
class FlutterSingboxProxyClient implements SingboxProxyClient {
|
||||
final _plugin = FlutterSingboxProxy();
|
||||
|
||||
@override
|
||||
Stream<SingboxProxyRuntimeState> get stateStream => _plugin.stateStream;
|
||||
|
||||
@override
|
||||
Stream<SingboxProxyLogMessage> get logStream => _plugin.logStream;
|
||||
|
||||
@override
|
||||
Future<String?> validateProfile(SingboxProxyProfile profile) {
|
||||
return _plugin.validateProfile(profile);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SingboxProxyConfigResult> buildConfig(
|
||||
List<SingboxProxyProfile> profiles, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
}) {
|
||||
return _plugin.buildConfig(profiles, options: options);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SingboxProxyRuntimeState> start(
|
||||
List<SingboxProxyProfile> profiles, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
}) {
|
||||
return _plugin.start(profiles, options: options);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop(List<String> profileIds) => _plugin.stop(profileIds);
|
||||
|
||||
@override
|
||||
Future<void> stopAll() => _plugin.stopAll();
|
||||
|
||||
@override
|
||||
Future<SingboxProxyRuntimeState> getState() => _plugin.getState();
|
||||
|
||||
@override
|
||||
Future<void> dispose() => _plugin.dispose();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
SingboxProxyClient singboxProxyClient(Ref ref) {
|
||||
return FlutterSingboxProxyClient();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SingboxProxyRuntimeRepository extends _$SingboxProxyRuntimeRepository {
|
||||
final _lock = Lock();
|
||||
|
||||
SingboxProxyClient get _plugin => ref.read(singboxProxyClientProvider);
|
||||
|
||||
Future<SingboxProxyRuntimeState> _stateSnapshotUnlocked() async {
|
||||
final currentState = state.asData?.value;
|
||||
if (currentState != null) return currentState;
|
||||
|
||||
final nextState = await _plugin.getState();
|
||||
state = AsyncData(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
Future<SingboxProxyRuntimeState> startProfile(
|
||||
String profileId, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
}) async {
|
||||
return _lock.synchronized(() async {
|
||||
final currentState = await _stateSnapshotUnlocked();
|
||||
final activeProfileIds = _activeProfileIds(currentState);
|
||||
|
||||
return _startProfilesUnlocked(
|
||||
{...activeProfileIds, profileId}.toList(),
|
||||
options: options,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> ensureProxyConnectionAvailable(
|
||||
SingboxProxyConnectionId connectionId,
|
||||
) async {
|
||||
await _lock.synchronized(() async {
|
||||
final currentState = await _stateSnapshotUnlocked();
|
||||
final isRunning = currentState.endpoints.any(
|
||||
(endpoint) => endpoint.profileId == connectionId.encode(),
|
||||
);
|
||||
if (isRunning) return;
|
||||
|
||||
final activeProfileIds = _activeProfileIds(currentState);
|
||||
await _startProfilesUnlocked(
|
||||
{...activeProfileIds, connectionId.profileId}.toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Set<String> _activeProfileIds(SingboxProxyRuntimeState runtimeState) {
|
||||
return runtimeState.endpoints
|
||||
.map((endpoint) => ProxyConnectionId.decode(endpoint.profileId))
|
||||
.whereType<SingboxProxyConnectionId>()
|
||||
.map((connectionId) => connectionId.profileId)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
Future<SingboxProxyRuntimeState> startProfiles(
|
||||
List<String> profileIds, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
}) {
|
||||
return _lock.synchronized(
|
||||
() => _startProfilesUnlocked(profileIds, options: options),
|
||||
);
|
||||
}
|
||||
|
||||
Future<SingboxProxyRuntimeState> _startProfilesUnlocked(
|
||||
List<String> profileIds, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
}) async {
|
||||
state = const AsyncLoading<SingboxProxyRuntimeState>();
|
||||
|
||||
try {
|
||||
final profiles = await _runtimeProfiles(profileIds);
|
||||
final resolvedOptions = await _buildRuntimeOptions(
|
||||
options ?? SingboxProxyRuntimeOptions(),
|
||||
profileIds: profileIds.toSet(),
|
||||
);
|
||||
|
||||
final nextState = await _plugin.start(profiles, options: resolvedOptions);
|
||||
|
||||
state = AsyncData(nextState);
|
||||
return nextState;
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncError(error, stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<SingboxProxyRuntimeOptions> _buildRuntimeOptions(
|
||||
SingboxProxyRuntimeOptions base, {
|
||||
required Set<String> profileIds,
|
||||
}) async {
|
||||
// Don't overwrite a caller-supplied dnsConfig (e.g. tests or one-off
|
||||
// overrides).
|
||||
final engineSettings = await ref
|
||||
.read(engineSettingsRepositoryProvider.notifier)
|
||||
.fetchSettings();
|
||||
final dohUrl = engineSettings.dohProviderUrl;
|
||||
|
||||
if (base.dnsConfig != null) {
|
||||
return SingboxProxyRuntimeOptions(
|
||||
preferredBasePort: base.preferredBasePort,
|
||||
blockUnmatchedTraffic: base.blockUnmatchedTraffic,
|
||||
dnsConfig: base.dnsConfig,
|
||||
bootstrapDohUrl: base.bootstrapDohUrl ?? dohUrl,
|
||||
);
|
||||
}
|
||||
|
||||
final profiles = await ref
|
||||
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||
.fetchProfiles();
|
||||
|
||||
final overrides = <String, ProxyDnsOverride?>{
|
||||
for (final profile in profiles)
|
||||
if (profileIds.contains(profile.id))
|
||||
profile.id: _decodeOverride(profile.dnsOverrideJson),
|
||||
};
|
||||
|
||||
final dnsConfig = buildDnsConfig(
|
||||
overridesByProfileId: overrides,
|
||||
runningProfileIds: profileIds,
|
||||
browserDohUrl: dohUrl,
|
||||
);
|
||||
|
||||
return SingboxProxyRuntimeOptions(
|
||||
preferredBasePort: base.preferredBasePort,
|
||||
blockUnmatchedTraffic: base.blockUnmatchedTraffic,
|
||||
dnsConfig: dnsConfig,
|
||||
bootstrapDohUrl: dohUrl,
|
||||
);
|
||||
}
|
||||
|
||||
ProxyDnsOverride? _decodeOverride(String? json) {
|
||||
if (json == null || json.trim().isEmpty) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(json);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
return ProxyDnsOverride.fromJson(decoded);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopProfiles(List<String> profileIds) {
|
||||
return _lock.synchronized(() async {
|
||||
await _stopProfilesUnlocked(profileIds);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteProfile(String profileId) {
|
||||
return _lock.synchronized(() async {
|
||||
await _stopProfilesUnlocked([profileId]);
|
||||
await ref
|
||||
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||
.deleteProfile(profileId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> stopAll() {
|
||||
return _lock.synchronized(() async {
|
||||
await _plugin.stopAll();
|
||||
final nextState = await _plugin.getState();
|
||||
state = AsyncData(nextState);
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> validateProfile(ProxyProfile profile) async {
|
||||
return _plugin.validateProfile(await _runtimeProfile(profile));
|
||||
}
|
||||
|
||||
Future<void> _stopProfilesUnlocked(List<String> profileIds) async {
|
||||
final proxyIds = profileIds
|
||||
.map((profileId) => SingboxProxyConnectionId(profileId).encode())
|
||||
.toList();
|
||||
|
||||
await _plugin.stop(proxyIds);
|
||||
final nextState = await _plugin.getState();
|
||||
state = AsyncData(nextState);
|
||||
}
|
||||
|
||||
Future<String?> validateProfileDraft(
|
||||
ProxyProfile profile, {
|
||||
String? secretJson,
|
||||
}) {
|
||||
return _plugin.validateProfile(
|
||||
profile.toRuntimeProfile(secretJson: secretJson),
|
||||
);
|
||||
}
|
||||
|
||||
Future<SingboxProxyConfigResult> buildConfig(
|
||||
List<String> profileIds, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
}) async {
|
||||
final resolvedOptions = await _buildRuntimeOptions(
|
||||
options ?? SingboxProxyRuntimeOptions(),
|
||||
profileIds: profileIds.toSet(),
|
||||
);
|
||||
return _plugin.buildConfig(
|
||||
await _runtimeProfiles(profileIds),
|
||||
options: resolvedOptions,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SingboxProxyProfile>> _runtimeProfiles(
|
||||
List<String> profileIds,
|
||||
) async {
|
||||
final profiles = await ref
|
||||
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||
.fetchProfiles();
|
||||
final profileMap = {for (final profile in profiles) profile.id: profile};
|
||||
|
||||
return Future.wait(
|
||||
profileIds.map((profileId) async {
|
||||
final profile = profileMap[profileId];
|
||||
if (profile == null) {
|
||||
throw StateError('Unknown sing-box proxy profile: $profileId');
|
||||
}
|
||||
|
||||
return _runtimeProfile(profile);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<SingboxProxyProfile> _runtimeProfile(ProxyProfile profile) async {
|
||||
final secretJson = await ref
|
||||
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||
.readSecretJson(profile.id);
|
||||
|
||||
return profile.toRuntimeProfile(secretJson: secretJson);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SingboxProxyRuntimeState> build() async {
|
||||
final plugin = ref.watch(singboxProxyClientProvider);
|
||||
final stateSubscription = plugin.stateStream.listen((nextState) {
|
||||
state = AsyncData(nextState);
|
||||
});
|
||||
|
||||
ref.onDispose(() async {
|
||||
await stateSubscription.cancel();
|
||||
await plugin.dispose();
|
||||
});
|
||||
|
||||
return plugin.getState();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'singbox_proxy_runtime.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(singboxProxyClient)
|
||||
final singboxProxyClientProvider = SingboxProxyClientProvider._();
|
||||
|
||||
final class SingboxProxyClientProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
SingboxProxyClient,
|
||||
SingboxProxyClient,
|
||||
SingboxProxyClient
|
||||
>
|
||||
with $Provider<SingboxProxyClient> {
|
||||
SingboxProxyClientProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'singboxProxyClientProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$singboxProxyClientHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<SingboxProxyClient> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
SingboxProxyClient create(Ref ref) {
|
||||
return singboxProxyClient(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(SingboxProxyClient value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<SingboxProxyClient>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$singboxProxyClientHash() =>
|
||||
r'3e56b277667e92a2d4941fd084dddd0ff25afd52';
|
||||
|
||||
@ProviderFor(SingboxProxyRuntimeRepository)
|
||||
final singboxProxyRuntimeRepositoryProvider =
|
||||
SingboxProxyRuntimeRepositoryProvider._();
|
||||
|
||||
final class SingboxProxyRuntimeRepositoryProvider
|
||||
extends
|
||||
$AsyncNotifierProvider<
|
||||
SingboxProxyRuntimeRepository,
|
||||
SingboxProxyRuntimeState
|
||||
> {
|
||||
SingboxProxyRuntimeRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'singboxProxyRuntimeRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$singboxProxyRuntimeRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SingboxProxyRuntimeRepository create() => SingboxProxyRuntimeRepository();
|
||||
}
|
||||
|
||||
String _$singboxProxyRuntimeRepositoryHash() =>
|
||||
r'665908eaff34569f7a19dc5328a067711d1937f2';
|
||||
|
||||
abstract class _$SingboxProxyRuntimeRepository
|
||||
extends $AsyncNotifier<SingboxProxyRuntimeState> {
|
||||
FutureOr<SingboxProxyRuntimeState> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref
|
||||
as $Ref<
|
||||
AsyncValue<SingboxProxyRuntimeState>,
|
||||
SingboxProxyRuntimeState
|
||||
>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
AsyncValue<SingboxProxyRuntimeState>,
|
||||
SingboxProxyRuntimeState
|
||||
>,
|
||||
AsyncValue<SingboxProxyRuntimeState>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user