From dce15e238143f2a192b0614b1fd7ca4a955ea232 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 1 Sep 2025 12:11:30 +0200 Subject: [PATCH] add moat service --- .../lib/pluggable_transports_proxy.dart | 1 + .../lib/src/data/models/moat.dart | 151 +++++++++++++++++ .../lib/src/data/models/moat.g.dart | 102 +++++++++++ .../lib/src/data/service/moat_api.dart | 70 ++++++++ .../lib/src/data/service/moat_service.dart | 160 ++++++++++++++++++ .../pluggable_transports_proxy/pubspec.yaml | 8 + 6 files changed, 492 insertions(+) create mode 100644 packages/pluggable_transports_proxy/lib/src/data/models/moat.dart create mode 100644 packages/pluggable_transports_proxy/lib/src/data/models/moat.g.dart create mode 100644 packages/pluggable_transports_proxy/lib/src/data/service/moat_api.dart create mode 100644 packages/pluggable_transports_proxy/lib/src/data/service/moat_service.dart diff --git a/packages/pluggable_transports_proxy/lib/pluggable_transports_proxy.dart b/packages/pluggable_transports_proxy/lib/pluggable_transports_proxy.dart index ad52e0a0..e23ce904 100644 --- a/packages/pluggable_transports_proxy/lib/pluggable_transports_proxy.dart +++ b/packages/pluggable_transports_proxy/lib/pluggable_transports_proxy.dart @@ -1 +1,2 @@ +export 'src/data/service/moat_service.dart'; export 'src/pigeons/proxy.g.dart' show IPtProxyController, ProxyType; diff --git a/packages/pluggable_transports_proxy/lib/src/data/models/moat.dart b/packages/pluggable_transports_proxy/lib/src/data/models/moat.dart new file mode 100644 index 00000000..810a8aab --- /dev/null +++ b/packages/pluggable_transports_proxy/lib/src/data/models/moat.dart @@ -0,0 +1,151 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'moat.g.dart'; + +enum TransportType { + obfs4('obfs4'), + snowflake('snowflake'), + meek('meek'), + meekAzure('meek-azure'), + webtunnel('webtunnel'); + + const TransportType(this.value); + final String value; + + @override + String toString() => value; + + static TransportType? fromString(String value) { + for (final transport in TransportType.values) { + if (transport.value == value) return transport; + } + return null; + } +} + +@JsonSerializable() +class SettingsRequest { + final String? country; + @JsonKey(toJson: _transportsToJson, fromJson: _transportsFromJson) + final List transports; + + const SettingsRequest({ + this.country, + this.transports = const [ + TransportType.obfs4, + TransportType.snowflake, + TransportType.webtunnel, + ], + }); + + static List _transportsToJson(List transports) => + transports.map((t) => t.value).toList(); + + static List _transportsFromJson(List json) => json + .cast() + .map((s) => TransportType.fromString(s)) + .where((t) => t != null) + .cast() + .toList(); + + factory SettingsRequest.fromJson(Map json) => + _$SettingsRequestFromJson(json); + + Map toJson() => _$SettingsRequestToJson(this); +} + +@JsonSerializable() +class SettingsResponse { + final List? settings; + final String? country; + final List? errors; + + const SettingsResponse({this.settings, this.country, this.errors}); + + factory SettingsResponse.fromJson(Map json) => + _$SettingsResponseFromJson(json); + + Map toJson() => _$SettingsResponseToJson(this); +} + +@JsonSerializable() +class Setting { + @JsonKey(name: 'bridges') + final Bridge bridge; + + const Setting({required this.bridge}); + + factory Setting.fromJson(Map json) => + _$SettingFromJson(json); + + Map toJson() => _$SettingToJson(this); +} + +@JsonSerializable() +class Bridge { + final String type; + final String source; + @JsonKey(name: 'bridge_strings') + final List? bridges; + + const Bridge({required this.type, required this.source, this.bridges}); + + factory Bridge.fromJson(Map json) => _$BridgeFromJson(json); + + Map toJson() => _$BridgeToJson(this); +} + +@JsonSerializable() +class MoatError implements Exception { + final String? id; + final String? type; + final String? version; + final int? code; + final String? status; + final String? detail; + + const MoatError({ + this.id, + this.type, + this.version, + this.code, + this.status, + this.detail, + }); + + factory MoatError.fromJson(Map json) => + _$MoatErrorFromJson(json); + + Map toJson() => _$MoatErrorToJson(this); + + @override + String toString() { + if (detail != null && detail!.isNotEmpty) { + return detail!; + } + return '$code $status'; + } +} + +@JsonSerializable() +class BuiltInBridges { + final List meek; + + @JsonKey(name: 'meek-azure') + final List meekAzure; + + final List obfs4; + final List snowflake; + + const BuiltInBridges({ + required this.meek, + required this.meekAzure, + required this.obfs4, + required this.snowflake, + }); + + factory BuiltInBridges.fromJson(Map json) => + _$BuiltInBridgesFromJson(json); + + Map toJson() => _$BuiltInBridgesToJson(this); +} diff --git a/packages/pluggable_transports_proxy/lib/src/data/models/moat.g.dart b/packages/pluggable_transports_proxy/lib/src/data/models/moat.g.dart new file mode 100644 index 00000000..b6a2999a --- /dev/null +++ b/packages/pluggable_transports_proxy/lib/src/data/models/moat.g.dart @@ -0,0 +1,102 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'moat.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SettingsRequest _$SettingsRequestFromJson(Map json) => + SettingsRequest( + country: json['country'] as String?, + transports: json['transports'] == null + ? const [ + TransportType.obfs4, + TransportType.snowflake, + TransportType.webtunnel, + ] + : SettingsRequest._transportsFromJson(json['transports'] as List), + ); + +Map _$SettingsRequestToJson(SettingsRequest instance) => + { + 'country': instance.country, + 'transports': SettingsRequest._transportsToJson(instance.transports), + }; + +SettingsResponse _$SettingsResponseFromJson(Map json) => + SettingsResponse( + settings: (json['settings'] as List?) + ?.map((e) => Setting.fromJson(e as Map)) + .toList(), + country: json['country'] as String?, + errors: (json['errors'] as List?) + ?.map((e) => MoatError.fromJson(e as Map)) + .toList(), + ); + +Map _$SettingsResponseToJson(SettingsResponse instance) => + { + 'settings': instance.settings, + 'country': instance.country, + 'errors': instance.errors, + }; + +Setting _$SettingFromJson(Map json) => + Setting(bridge: Bridge.fromJson(json['bridges'] as Map)); + +Map _$SettingToJson(Setting instance) => { + 'bridges': instance.bridge, +}; + +Bridge _$BridgeFromJson(Map json) => Bridge( + type: json['type'] as String, + source: json['source'] as String, + bridges: (json['bridge_strings'] as List?) + ?.map((e) => e as String) + .toList(), +); + +Map _$BridgeToJson(Bridge instance) => { + 'type': instance.type, + 'source': instance.source, + 'bridge_strings': instance.bridges, +}; + +MoatError _$MoatErrorFromJson(Map json) => MoatError( + id: json['id'] as String?, + type: json['type'] as String?, + version: json['version'] as String?, + code: (json['code'] as num?)?.toInt(), + status: json['status'] as String?, + detail: json['detail'] as String?, +); + +Map _$MoatErrorToJson(MoatError instance) => { + 'id': instance.id, + 'type': instance.type, + 'version': instance.version, + 'code': instance.code, + 'status': instance.status, + 'detail': instance.detail, +}; + +BuiltInBridges _$BuiltInBridgesFromJson(Map json) => + BuiltInBridges( + meek: (json['meek'] as List).map((e) => e as String).toList(), + meekAzure: (json['meek-azure'] as List) + .map((e) => e as String) + .toList(), + obfs4: (json['obfs4'] as List).map((e) => e as String).toList(), + snowflake: (json['snowflake'] as List) + .map((e) => e as String) + .toList(), + ); + +Map _$BuiltInBridgesToJson(BuiltInBridges instance) => + { + 'meek': instance.meek, + 'meek-azure': instance.meekAzure, + 'obfs4': instance.obfs4, + 'snowflake': instance.snowflake, + }; diff --git a/packages/pluggable_transports_proxy/lib/src/data/service/moat_api.dart b/packages/pluggable_transports_proxy/lib/src/data/service/moat_api.dart new file mode 100644 index 00000000..51aa48f1 --- /dev/null +++ b/packages/pluggable_transports_proxy/lib/src/data/service/moat_api.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:pluggable_transports_proxy/src/data/models/moat.dart'; + +class MoatApi { + static const String _baseUrl = + 'https://bridges.torproject.org/moat/circumvention/'; + + final http.Client _client; + + MoatApi(this._client); + + Future settings([SettingsRequest? request]) async { + final response = await _post('settings', request ?? SettingsRequest()); + return SettingsResponse.fromJson( + jsonDecode(response.body) as Map, + ); + } + + Future defaults([SettingsRequest? request]) async { + final response = await _post('defaults', request ?? SettingsRequest()); + return SettingsResponse.fromJson( + jsonDecode(response.body) as Map, + ); + } + + Future> map() async { + final response = await _get('map'); + final Map data = + jsonDecode(response.body) as Map; + return data.map( + (key, value) => MapEntry( + key, + SettingsResponse.fromJson(value as Map), + ), + ); + } + + Future builtin() async { + final response = await _get('builtin'); + return BuiltInBridges.fromJson( + jsonDecode(response.body) as Map, + ); + } + + Future> countries() async { + final response = await _get('countries'); + return List.from(jsonDecode(response.body) as List); + } + + Future _get(String endpoint) async { + final uri = Uri.parse('$_baseUrl$endpoint'); + return await _client.get(uri, headers: _headers); + } + + Future _post(String endpoint, Object body) async { + final uri = Uri.parse('$_baseUrl$endpoint'); + return await _client.post(uri, headers: _headers, body: jsonEncode(body)); + } + + Map get _headers => { + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + }; + + void dispose() { + _client.close(); + } +} diff --git a/packages/pluggable_transports_proxy/lib/src/data/service/moat_service.dart b/packages/pluggable_transports_proxy/lib/src/data/service/moat_service.dart new file mode 100644 index 00000000..3a9063d1 --- /dev/null +++ b/packages/pluggable_transports_proxy/lib/src/data/service/moat_service.dart @@ -0,0 +1,160 @@ +import 'dart:io'; + +import 'package:http/io_client.dart'; +import 'package:pluggable_transports_proxy/pluggable_transports_proxy.dart'; +import 'package:pluggable_transports_proxy/src/data/models/moat.dart'; +import 'package:pluggable_transports_proxy/src/data/service/moat_api.dart'; +import 'package:socks5_proxy/socks_client.dart'; + +const String _meekParameters = + 'url=https://1723079976.rsc.cdn77.org;front=www.phpmyadmin.net'; + +/// Manages MOAT API connections with persistent proxy setup. +class MoatService { + MoatApi? _api; + + /// Initializes the MeekLite proxy and MOAT API connection. + /// Must be called before using other methods. + Future initialize() async { + if (_api != null) return; + + final port = await IPtProxyController().start(ProxyType.meekLite, ""); + + final httpClient = HttpClient(); + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings( + InternetAddress.loopbackIPv4, + port, + username: _meekParameters, + password: '\u0000', + ), + ]); + + _api = MoatApi(IOClient(httpClient)); + } + + /// Disposes the API connection and stops the MeekLite proxy. + /// Should be called when done using the client. + Future dispose() async { + if (_api == null) return; + + _api!.dispose(); + await IPtProxyController().stop(ProxyType.meekLite); + _api = null; + } + + /// Ensures the client is initialized before API calls. + void _ensureInitialized() { + if (_api == null) { + throw StateError( + 'MoatClient must be initialized before use. Call initialize() first.', + ); + } + } + + /// Handles MOAT API errors and determines if PT is required. + bool _handleMoatErrors(List? errors) { + if (errors == null || errors.isEmpty) return false; + + final error = errors.first; + if (error.code == 404 || error.code == 406) { + // 404: Needs transport, but not the available ones + // 406: No country from IP address + return true; + } + throw error; + } + + /// Converts BuiltInBridges to Settings for requested transports. + List _convertBuiltinToSettings( + BuiltInBridges builtinBridges, + List transports, + ) { + final settings = []; + + for (final transport in transports) { + final bridgeStrings = switch (transport) { + TransportType.obfs4 => builtinBridges.obfs4, + TransportType.snowflake => builtinBridges.snowflake, + TransportType.meek => builtinBridges.meek, + TransportType.meekAzure => builtinBridges.meekAzure, + TransportType.webtunnel => [], // Not available in builtin + }; + + if (bridgeStrings.isNotEmpty) { + settings.add( + Setting( + bridge: Bridge( + type: transport.value, + source: 'builtin', + bridges: bridgeStrings, + ), + ), + ); + } + } + + return settings; + } + + /// Gets built-in bridges from the MOAT service endpoint. + Future?> getBuiltinBridges({ + List transports = const [ + TransportType.obfs4, + TransportType.snowflake, + TransportType.meek, + ], + }) async { + _ensureInitialized(); + + final builtinBridges = await _api!.builtin(); + final settings = _convertBuiltinToSettings(builtinBridges, transports); + return settings.isEmpty ? null : settings; + } + + /// Tries to automatically configure Pluggable Transports. + Future?> autoConf({ + String? country, + bool cannotConnectWithoutPt = false, + List transports = const [ + TransportType.obfs4, + TransportType.snowflake, + TransportType.webtunnel, + ], + }) async { + _ensureInitialized(); + + bool localCannotConnectWithoutPt = cannotConnectWithoutPt; + final request = SettingsRequest(country: country, transports: transports); + + var response = await _api!.settings(request); + + if (_handleMoatErrors(response.errors)) { + localCannotConnectWithoutPt = true; + } + + final hasSettings = response.settings?.isNotEmpty ?? false; + if (!hasSettings && !localCannotConnectWithoutPt) { + return null; + } + + if (hasSettings) { + return response.settings; + } + + response = await _api!.defaults(SettingsRequest(transports: transports)); + return response.settings; + } + + /// Gets the list of supported countries from the MOAT service. + Future> getCountries() async { + _ensureInitialized(); + return await _api!.countries(); + } + + /// Gets the country-to-settings map from the MOAT service. + Future> getMap() async { + _ensureInitialized(); + return await _api!.map(); + } +} diff --git a/packages/pluggable_transports_proxy/pubspec.yaml b/packages/pluggable_transports_proxy/pubspec.yaml index 482cd436..e5b149af 100644 --- a/packages/pluggable_transports_proxy/pubspec.yaml +++ b/packages/pluggable_transports_proxy/pubspec.yaml @@ -11,10 +11,18 @@ environment: dependencies: flutter: sdk: flutter + http: ^1.5.0 + json_annotation: ^4.9.0 + socks5_proxy: + git: + url: https://github.com/sylvieon/socks_dart.git + ref: 04550bc08f42cc1a35f1e870f651b93a4f9aceab dev_dependencies: + build_runner: ^2.4.15 flutter_test: sdk: flutter + json_serializable: ^6.9.5 lint: ^2.8.0 pigeon: ^26.0.1