Add proxy routing and sing-box support

This commit is contained in:
Fabian Freund
2026-05-22 18:16:31 +02:00
parent 51289f1266
commit a5974617aa
262 changed files with 32003 additions and 3962 deletions
@@ -0,0 +1,66 @@
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
void main() {
group('proxyConnectionOptionsProvider', () {
test('returns Tor and saved sing-box profiles', () async {
final createdAt = DateTime(2026);
final container = ProviderContainer(
overrides: [
singboxProxyProfilesRepositoryProvider.overrideWith(
() => _FakeProfilesRepository([
ProxyProfile(
id: 'profile-1',
name: 'Mullvad',
type: SingboxProxyProfileType.customOutbound,
configJson: '{"type":"socks"}',
createdAt: createdAt,
updatedAt: createdAt,
),
]),
),
],
);
addTearDown(container.dispose);
container.listen(
singboxProxyProfilesRepositoryProvider,
(_, _) {},
fireImmediately: true,
);
await Future<void>.delayed(Duration.zero);
final options = container.read(proxyConnectionOptionsProvider);
expect(options.map((option) => option.id), [
const TorProxyConnectionId(),
const SingboxProxyConnectionId('profile-1'),
]);
expect(options.map((option) => option.title), ['Tor', 'Mullvad']);
});
test('labels unknown proxy ids explicitly', () {
expect(
proxyConnectionTitle(
const [],
const SingboxProxyConnectionId('missing'),
),
'Unknown proxy',
);
});
});
}
class _FakeProfilesRepository extends SingboxProxyProfilesRepository {
final List<ProxyProfile> profiles;
_FakeProfilesRepository(this.profiles);
@override
Stream<List<ProxyProfile>> build() => Stream.value(profiles);
}
@@ -0,0 +1,155 @@
import 'package:drift/native.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/data/database/functions/lexo_rank_functions.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/user/data/database/database.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/features/user/data/providers.dart';
void main() {
test(
'createProfile persists metadata and writes secrets separately',
() async {
final credentials = _FakeCredentialsRepository();
final container = _container(credentials);
addTearDown(container.dispose);
final repository = container.read(
singboxProxyProfilesRepositoryProvider.notifier,
);
final created = await repository.createProfile(
name: 'Mullvad WG',
type: SingboxProxyProfileType.wireguard,
configJson: '{"server":"vpn.example"}',
secretJson: '{"private_key":"secret"}',
);
final profiles = await repository.fetchProfiles();
expect(profiles, hasLength(1));
expect(profiles.single.id, created.id);
expect(profiles.single.name, 'Mullvad WG');
expect(profiles.single.type, SingboxProxyProfileType.wireguard);
expect(profiles.single.configJson, '{"server":"vpn.example"}');
expect(credentials.writtenSecrets, {
created.id: '{"private_key":"secret"}',
});
},
);
test(
'updateProfile updates metadata without touching secrets by default',
() async {
final credentials = _FakeCredentialsRepository();
final container = _container(credentials);
addTearDown(container.dispose);
final repository = container.read(
singboxProxyProfilesRepositoryProvider.notifier,
);
final profile = _profile(id: 'profile-1', name: 'Original');
await repository.updateProfile(profile);
await repository.updateProfile(
profile.copyWith(name: 'Updated', configJson: '{"type":"http"}'),
);
final profiles = await repository.fetchProfiles();
expect(profiles, hasLength(1));
expect(profiles.single.id, 'profile-1');
expect(profiles.single.name, 'Updated');
expect(profiles.single.configJson, '{"type":"http"}');
expect(credentials.writtenSecrets, isEmpty);
},
);
test('updateProfile writes secrets only when requested', () async {
final credentials = _FakeCredentialsRepository();
final container = _container(credentials);
addTearDown(container.dispose);
final repository = container.read(
singboxProxyProfilesRepositoryProvider.notifier,
);
final profile = _profile(id: 'profile-1', name: 'SOCKS');
await repository.updateProfile(
profile,
secretJson: '{"password":"secret"}',
);
expect(credentials.writtenSecrets, {'profile-1': '{"password":"secret"}'});
});
test('deleteProfile removes metadata and deletes secrets', () async {
final credentials = _FakeCredentialsRepository();
final container = _container(credentials);
addTearDown(container.dispose);
final repository = container.read(
singboxProxyProfilesRepositoryProvider.notifier,
);
await repository.updateProfile(
_profile(id: 'profile-1', name: 'SOCKS'),
secretJson: '{"password":"secret"}',
);
await repository.deleteProfile('profile-1');
expect(await repository.fetchProfiles(), isEmpty);
expect(credentials.deletedSecretIds, ['profile-1']);
});
}
ProviderContainer _container(_FakeCredentialsRepository credentials) {
final db = UserDatabase(
NativeDatabase.memory(
setup: (database) {
registerLexorankFunctions(database);
},
),
);
addTearDown(db.close);
return ProviderContainer(
overrides: [
userDatabaseProvider.overrideWith((ref) => db),
singboxProxyCredentialsRepositoryProvider.overrideWith(() => credentials),
],
);
}
ProxyProfile _profile({required String id, required String name}) {
final createdAt = DateTime(2026);
return ProxyProfile(
id: id,
name: name,
type: SingboxProxyProfileType.socks,
configJson: '{"type":"socks"}',
createdAt: createdAt,
updatedAt: createdAt,
);
}
class _FakeCredentialsRepository extends SingboxProxyCredentialsRepository {
final writtenSecrets = <String, String?>{};
final deletedSecretIds = <String>[];
@override
Future<void> writeSecretJson(String profileId, String? secretJson) async {
writtenSecrets[profileId] = secretJson;
}
@override
Future<void> deleteSecretJson(String profileId) async {
deletedSecretIds.add(profileId);
}
@override
void build() {}
}
@@ -0,0 +1,451 @@
import 'dart:async';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.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/container_proxy.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/repositories/singbox_proxy_runtime.dart';
import 'package:weblibre/features/proxy/domain/services/singbox_proxy_endpoint_sync.dart';
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
show ProxyProfile;
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'ensureProxyConnectionAvailable starts an assigned stopped profile',
() async {
final profile = _profile(id: 'profile-1', name: 'First');
final client = _FakeSingboxProxyClient(_state(const []));
final container = _container(
client: client,
profilesRepository: _FakeProfilesRepository([profile]),
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
final repository = container.read(
singboxProxyRuntimeRepositoryProvider.notifier,
);
await repository.ensureProxyConnectionAvailable(
SingboxProxyConnectionId(profile.id),
);
await repository.ensureProxyConnectionAvailable(
SingboxProxyConnectionId(profile.id),
);
expect(client.startCalls, hasLength(1));
expect(
client.startCalls.single.map((runtimeProfile) => runtimeProfile.id),
[profile.proxyConnectionId],
);
},
);
test('startProfile preserves already-running profiles', () async {
final profile1 = _profile(id: 'profile-1', name: 'First');
final profile2 = _profile(id: 'profile-2', name: 'Second');
final client = _FakeSingboxProxyClient(
_state([_endpoint(profile1.proxyConnectionId, port: 12080)]),
);
final container = _container(
client: client,
profilesRepository: _FakeProfilesRepository([profile1, profile2]),
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
await container
.read(singboxProxyRuntimeRepositoryProvider.notifier)
.startProfile(profile2.id);
expect(client.startCalls, hasLength(1));
expect(
client.startCalls.single.map((profile) => profile.id),
unorderedEquals([profile1.proxyConnectionId, profile2.proxyConnectionId]),
);
});
test(
'concurrent startProfile calls preserve both requested profiles',
() async {
final profile1 = _profile(id: 'profile-1', name: 'First');
final profile2 = _profile(id: 'profile-2', name: 'Second');
final client = _FakeSingboxProxyClient(_state(const []));
final container = _container(
client: client,
profilesRepository: _FakeProfilesRepository([profile1, profile2]),
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
final repository = container.read(
singboxProxyRuntimeRepositoryProvider.notifier,
);
await Future.wait([
repository.startProfile(profile1.id),
repository.startProfile(profile2.id),
]);
expect(client.startCalls, hasLength(2));
expect(
client.startCalls.last.map((profile) => profile.id),
unorderedEquals([
profile1.proxyConnectionId,
profile2.proxyConnectionId,
]),
);
expect(
container
.read(singboxProxyRuntimeRepositoryProvider)
.requireValue
.endpoints
.map((endpoint) => endpoint.profileId),
unorderedEquals([
profile1.proxyConnectionId,
profile2.proxyConnectionId,
]),
);
},
);
test('stopProfiles unregisters removed Gecko proxy endpoints', () async {
final profile1 = _profile(id: 'profile-1', name: 'First');
final profile2 = _profile(id: 'profile-2', name: 'Second');
final client =
_FakeSingboxProxyClient(
_state([
_endpoint(profile1.proxyConnectionId, port: 12080),
_endpoint(profile2.proxyConnectionId, port: 12081),
]),
)
..stateAfterStop = _state([
_endpoint(profile1.proxyConnectionId, port: 12080),
]);
final containerProxyRepository = _FakeContainerProxyRepository();
final container = _container(
client: client,
profilesRepository: _FakeProfilesRepository([profile1, profile2]),
containerProxyRepository: containerProxyRepository,
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
await container
.read(singboxProxyRuntimeRepositoryProvider.notifier)
.stopProfiles([profile2.id]);
await _drainSync();
expect(client.stopCalls, [profile2.proxyConnectionId]);
expect(containerProxyRepository.removedProxyIds, [
profile2.proxyConnectionId,
]);
});
test(
'deleteProfile stops runtime and preserves persisted references',
() async {
final profile = _profile(id: 'profile-1', name: 'First');
final client = _FakeSingboxProxyClient(
_state([_endpoint(profile.proxyConnectionId, port: 12080)]),
)..stateAfterStop = _state(const []);
final profilesRepository = _FakeProfilesRepository([profile]);
final containerRepository = _FakeContainerRepository();
final routingSettingsRepository = _FakeProxyRoutingSettingsRepository(
ProxyRoutingSettings(
regularTabsMode: ProxyRegularTabRoutingMode.all,
regularTabsProxyConnectionId: profile.proxyConnection,
privateTabsProxyConnectionId: profile.proxyConnection,
),
);
final container = _container(
client: client,
profilesRepository: profilesRepository,
containerRepository: containerRepository,
routingSettingsRepository: routingSettingsRepository,
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
await container
.read(singboxProxyRuntimeRepositoryProvider.notifier)
.deleteProfile(profile.id);
expect(client.stopCalls, [profile.proxyConnectionId]);
expect(containerRepository.clearedProxyConnectionIds, isEmpty);
final routingSettings = routingSettingsRepository.peekSettings();
expect(routingSettings, isNotNull);
expect(routingSettings!.regularTabsMode, ProxyRegularTabRoutingMode.all);
expect(
routingSettings.regularTabsProxyConnectionId,
profile.proxyConnection,
);
expect(
routingSettings.privateTabsProxyConnectionId,
profile.proxyConnection,
);
expect(profilesRepository.deletedProfileIds, [profile.id]);
},
);
test('runtime stream sync removes stale Gecko proxy registrations', () async {
final profile1 = _profile(id: 'profile-1', name: 'First');
final profile2 = _profile(id: 'profile-2', name: 'Second');
final client = _FakeSingboxProxyClient(
_state([
_endpoint(profile1.proxyConnectionId, port: 12080),
_endpoint(profile2.proxyConnectionId, port: 12081),
]),
);
final containerProxyRepository = _FakeContainerProxyRepository();
final container = _container(
client: client,
profilesRepository: _FakeProfilesRepository([profile1, profile2]),
containerProxyRepository: containerProxyRepository,
);
addTearDown(container.dispose);
await container.read(singboxProxyRuntimeRepositoryProvider.future);
client.emit(_state([_endpoint(profile2.proxyConnectionId, port: 12081)]));
await _drainSync();
expect(containerProxyRepository.removedProxyIds, [
profile1.proxyConnectionId,
]);
});
}
ProviderContainer _container({
required _FakeSingboxProxyClient client,
required _FakeProfilesRepository profilesRepository,
_FakeContainerProxyRepository? containerProxyRepository,
_FakeContainerRepository? containerRepository,
_FakeProxyRoutingSettingsRepository? routingSettingsRepository,
}) {
final container = ProviderContainer(
overrides: [
singboxProxyClientProvider.overrideWithValue(client),
singboxProxyProfilesRepositoryProvider.overrideWith(
() => profilesRepository,
),
singboxProxyCredentialsRepositoryProvider.overrideWith(
_FakeCredentialsRepository.new,
),
engineSettingsRepositoryProvider.overrideWith(
_FakeEngineSettingsRepository.new,
),
containerProxyRepositoryProvider.overrideWith(
() => containerProxyRepository ?? _FakeContainerProxyRepository(),
),
containerRepositoryProvider.overrideWith(
() => containerRepository ?? _FakeContainerRepository(),
),
proxyRoutingSettingsRepositoryProvider.overrideWith(
() =>
routingSettingsRepository ?? _FakeProxyRoutingSettingsRepository(),
),
],
);
// Activate the endpoint sync notifier so runtime state changes propagate
// into the fake container proxy repository. Production wires this in
// main.dart; tests have to ask for it explicitly.
container.read(singboxProxyEndpointSyncProvider);
return container;
}
/// Drains all currently-pending microtasks/futures so the endpoint-sync
/// `ref.listen` callback (chained off the runtime state stream listener)
/// has time to apply its registrations against the fake Gecko repo.
Future<void> _drainSync() => pumpEventQueue();
ProxyProfile _profile({required String id, required String name}) {
final createdAt = DateTime(2026);
return ProxyProfile(
id: id,
name: name,
type: SingboxProxyProfileType.customOutbound,
configJson: '{"type":"socks"}',
createdAt: createdAt,
updatedAt: createdAt,
);
}
SingboxProxyRuntimeState _state(List<SingboxProxyRuntimeEndpoint> endpoints) {
return SingboxProxyRuntimeState(
status: endpoints.isEmpty
? SingboxProxyRuntimeStatus.stopped
: SingboxProxyRuntimeStatus.running,
endpoints: endpoints,
);
}
SingboxProxyRuntimeEndpoint _endpoint(String profileId, {required int port}) {
return SingboxProxyRuntimeEndpoint(
profileId: profileId,
host: '127.0.0.1',
port: port,
username: 'user-$port',
password: 'pass-$port',
);
}
class _FakeSingboxProxyClient implements SingboxProxyClient {
final _stateController =
StreamController<SingboxProxyRuntimeState>.broadcast();
final startCalls = <List<SingboxProxyProfile>>[];
final stopCalls = <String>[];
SingboxProxyRuntimeState _currentState;
SingboxProxyRuntimeState? stateAfterStop;
_FakeSingboxProxyClient(this._currentState);
@override
Stream<SingboxProxyRuntimeState> get stateStream => _stateController.stream;
@override
Stream<SingboxProxyLogMessage> get logStream => const Stream.empty();
void emit(SingboxProxyRuntimeState state) {
_currentState = state;
_stateController.add(state);
}
@override
Future<SingboxProxyRuntimeState> getState() async => _currentState;
@override
Future<SingboxProxyRuntimeState> start(
List<SingboxProxyProfile> profiles, {
SingboxProxyRuntimeOptions? options,
}) async {
startCalls.add(profiles);
return _currentState = _state(
profiles.indexed
.map((item) => _endpoint(item.$2.id, port: 12080 + item.$1))
.toList(),
);
}
@override
Future<void> stop(List<String> profileIds) async {
stopCalls.addAll(profileIds);
_currentState = stateAfterStop ?? _currentState;
}
@override
Future<void> stopAll() async {
_currentState = _state(const []);
}
@override
Future<String?> validateProfile(SingboxProxyProfile profile) async => null;
@override
Future<SingboxProxyConfigResult> buildConfig(
List<SingboxProxyProfile> profiles, {
SingboxProxyRuntimeOptions? options,
}) async {
return SingboxProxyConfigResult(configJson: '{}', endpoints: const []);
}
@override
Future<void> dispose() => _stateController.close();
}
class _FakeProfilesRepository extends SingboxProxyProfilesRepository {
final List<ProxyProfile> profiles;
final deletedProfileIds = <String>[];
_FakeProfilesRepository(this.profiles);
@override
Future<List<ProxyProfile>> fetchProfiles() async => profiles;
@override
Future<void> deleteProfile(String profileId) async {
deletedProfileIds.add(profileId);
}
@override
Stream<List<ProxyProfile>> build() => Stream.value(profiles);
}
class _FakeCredentialsRepository extends SingboxProxyCredentialsRepository {
@override
Future<String?> readSecretJson(String profileId) async => null;
@override
void build() {}
}
class _FakeEngineSettingsRepository extends EngineSettingsRepository {
final settings = EngineSettings.withDefaults();
@override
Future<EngineSettings> fetchSettings() async => settings;
@override
Stream<EngineSettings> build() => Stream.value(settings);
}
class _FakeContainerProxyRepository extends ContainerProxyRepository {
final upsertedProxies = <GeckoProxySettings>[];
final removedProxyIds = <String>[];
@override
Future<void> upsertProxy(GeckoProxySettings proxy) async {
upsertedProxies.add(proxy);
}
@override
Future<void> removeProxy(String proxyId) async {
removedProxyIds.add(proxyId);
}
@override
void build() {}
}
class _FakeContainerRepository extends ContainerRepository {
final clearedProxyConnectionIds = <ProxyConnectionId>[];
@override
Future<void> clearProxyConnectionAssignments(
ProxyConnectionId proxyConnectionId,
) async {
clearedProxyConnectionIds.add(proxyConnectionId);
}
@override
void build() {}
}
class _FakeProxyRoutingSettingsRepository
extends ProxyRoutingSettingsRepository {
ProxyRoutingSettings? _settings;
_FakeProxyRoutingSettingsRepository([this._settings]);
ProxyRoutingSettings? peekSettings() => _settings;
@override
Stream<ProxyRoutingSettings> build() {
return Stream.value(_settings ?? ProxyRoutingSettings.withDefaults());
}
}
@@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/proxy/domain/services/dns_config_resolver.dart';
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
void main() {
test('mirrors browser DoH through running profiles automatically', () {
final config = buildDnsConfig(
overridesByProfileId: const {'profile-1': null},
runningProfileIds: const {'profile-1'},
browserDohUrl: 'https://dns.example/dns-query',
);
expect(config, isNotNull);
expect(config!.finalServerTag, 'browser-doh');
expect(config.servers, hasLength(2));
expect(config.servers.first.detourTag, isNull);
expect(config.servers.last.tag, 'browser-doh-profile-1');
expect(config.servers.last.detourTag, 'out-singbox_profile-1');
expect(config.servers.last.matchInbounds, ['in-singbox_profile-1']);
});
test('profile override replaces automatic browser DoH mirror', () {
final config = buildDnsConfig(
overridesByProfileId: {
'profile-1': ProxyDnsOverride(remoteServerAddress: '10.0.0.53'),
},
runningProfileIds: const {'profile-1'},
browserDohUrl: 'https://dns.example/dns-query',
);
expect(config, isNotNull);
expect(config!.servers.map((s) => s.tag), [
'browser-doh',
'override-profile-1',
]);
expect(config.servers.last.address, '10.0.0.53');
expect(config.servers.last.detourTag, 'out-singbox_profile-1');
expect(config.servers.last.matchInbounds, ['in-singbox_profile-1']);
});
test('returns null when there is no browser DoH or override', () {
final config = buildDnsConfig(
overridesByProfileId: const {'profile-1': null},
runningProfileIds: const {'profile-1'},
browserDohUrl: null,
);
expect(config, isNull);
});
test('auto domain strategy emits sing-box as-is strategy', () {
expect(ProxyDnsDomainStrategy.auto.singboxValue, isEmpty);
});
}
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/proxy/domain/services/dns_config_resolver.dart';
/// The Dart and Kotlin sides of the sing-box config builder must produce the
/// same `out-`/`in-` tags. These cases mirror `SingboxTagFormatTest.kt` —
/// both must update together if this format ever changes.
void main() {
group('singbox tag format', () {
test('outboundTag is prefixed and sanitized', () {
expect(singboxOutboundTag('singbox:foo-bar'), 'out-singbox_foo-bar');
});
test('inboundTag is prefixed and sanitized', () {
expect(singboxInboundTag('singbox:foo-bar'), 'in-singbox_foo-bar');
});
test('sanitizeTag preserves alphanumeric, dots, dashes, underscores', () {
expect(singboxSanitizeTag('Abc_123.x-Y'), 'Abc_123.x-Y');
});
test('sanitizeTag replaces spaces, slashes, colons', () {
expect(singboxSanitizeTag('a:b c/d\\e'), 'a_b_c_d_e');
});
});
}