mirror of
https://github.com/cake-tech/cake_wallet.git
synced 2025-06-28 12:29:51 +00:00
CW-519 Enable built-in Tor (#1950)
* tor wip * Enable tor on iOS * Prevent app lag when node is exceptionally slow (usually over tor) * fix: logic in daemonBlockchainHeight refresh fix: storing tor state * Pin ledger_flutter_plus dependency to fix builds * bump arti version * wip * add single httpclient * route everything I was able to catch trough the built-in tor node * Enable proxy for http.Client [run tests] * add tor proxy support to cw_evm, cw_tron and cw_polygon [run tests] * remove log pollution, cleanup [skip slack] * fix tests not working in latest main [skip slack] [run tests] * remove cw_wownero import * fix build issues * migrate all remaining calls to use ProxyWrapper add a CI action to enforce using ProxyWrapper instead of http/http.dart to prevent leaks * fix tor background sync (will work on test builds after #2142 is merged and this PR is rebased on top) * wip [skip ci] * relicense to GPLv3 add socks5 license, build fixes * use ProxyWrapper instead of http in robinhood * Revert "relicense to GPLv3" * feat(cw_bitcoin): support socks proxy and CakeTor * fix(tor): migrate OCP and EVM over to ProxyWrapper() * chore: cleanup fix: show tor loading screen when app is starting * fix: tor switch properly dismisses fullscreen loading dialog fix: connectToNode after tor startup on app start * fix(tor): status check for xmr/wow/zano * fix(tor): onramper request fix * fix(api): ServicesResponse is now being cached and doesn't fetch data everytime DashboardViewModel is being rebuilt fix(tor): do not fallback to clearnet when tor failed. fix(tor): do not leak connections during app startup chore: refactor bootstrap() function to be separated into bootstrapOffline and bootstrapOnline fix(cw_bitcoin): migrate payjoin to use ProxyWrapper * [skip ci] remove print * address comments from review * fix: derusting tor implementation Instead of rust-based Arti I've moved back to the OG C++ tor implementation. This fixed all issues we had with Tor. - onion services now work - all requests are going through without random errors - we don't have to navigate a maze of multiple forks of multiple packages - fully working `torrc` config file (probably will be needed for Tari). - logging for Tor client - and so on. feat: network logging tab feat: use built-in proxy on Tails - this should resolve all issues for Tails users (needs testing though) * fix conflicts with main bump https to fix build issue relax store() call * fix(cw_wownero): tor connection fix(tor): connection issues * fix(cw_evm): add missing chainId fix(cw_core): solana rpc fix * feat: mark tor as experimental fix: drop anonpay onion authority fix: drop fiatapi onion authority fix: drop trocador onion authority fix: disable networkimage when tor is enabled fix: handle cakepay errors gracefully * fix re-formatting [skip ci] * changes from review * Delete android/.kotlin/sessions/kotlin-compiler-2468481326039681181.salive * fix missing imports * Update pubspec_base.yaml --------- Co-authored-by: OmarHatem <omarh.ismail1@gmail.com>
This commit is contained in:
parent
18c2ba9366
commit
5082dc20f3
139 changed files with 2754 additions and 878 deletions
|
@ -1,7 +1,7 @@
|
|||
import 'package:cw_core/utils/proxy_wrapper.dart';
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
// FIXME: Hardcoded values; Works only for monero
|
||||
|
||||
|
@ -234,10 +234,14 @@ int getHavenHeightByDate({required DateTime date}) {
|
|||
}
|
||||
|
||||
Future<int> getHavenCurrentHeight() async {
|
||||
final response = await http.get(Uri.parse('https://explorer.havenprotocol.org/api/networkinfo'));
|
||||
final req = await ProxyWrapper().getHttpClient()
|
||||
.getUrl(Uri.parse('https://explorer.havenprotocol.org/api/networkinfo'))
|
||||
.timeout(Duration(seconds: 15));
|
||||
final response = await req.close();
|
||||
final stringResponse = await response.transform(utf8.decoder).join();
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final info = jsonDecode(response.body);
|
||||
final info = jsonDecode(stringResponse);
|
||||
return info['data']['height'] as int;
|
||||
} else {
|
||||
throw Exception('Failed to load current blockchain height');
|
||||
|
@ -269,13 +273,13 @@ const bitcoinDates = {
|
|||
};
|
||||
|
||||
Future<int> getBitcoinHeightByDateAPI({required DateTime date}) async {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
"https://mempool.cakewallet.com/api/v1/mining/blocks/timestamp/${(date.millisecondsSinceEpoch / 1000).round()}",
|
||||
),
|
||||
);
|
||||
final req = await ProxyWrapper().getHttpClient()
|
||||
.getUrl(Uri.parse("https://mempool.cakewallet.com/api/v1/mining/blocks/timestamp/${(date.millisecondsSinceEpoch / 1000).round()}"))
|
||||
.timeout(Duration(seconds: 15));
|
||||
final response = await req.close();
|
||||
final stringResponse = await response.transform(utf8.decoder).join();
|
||||
|
||||
return jsonDecode(response.body)['height'] as int;
|
||||
return jsonDecode(stringResponse)['height'] as int;
|
||||
}
|
||||
|
||||
int getBitcoinHeightByDate({required DateTime date}) {
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import 'dart:io';
|
||||
import 'package:cw_core/keyable.dart';
|
||||
import 'package:cw_core/utils/proxy_socket/abstract.dart';
|
||||
import 'package:cw_core/utils/proxy_wrapper.dart';
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:cw_core/hive_type_ids.dart';
|
||||
import 'package:cw_core/wallet_type.dart';
|
||||
import 'package:http/io_client.dart' as ioc;
|
||||
import 'dart:math' as math;
|
||||
import 'package:convert/convert.dart';
|
||||
|
||||
|
@ -184,23 +184,17 @@ class Node extends HiveObject with Keyable {
|
|||
final body = {'jsonrpc': '2.0', 'id': '0', 'method': "getinfo"};
|
||||
|
||||
try {
|
||||
final authenticatingClient = HttpClient();
|
||||
authenticatingClient.badCertificateCallback =
|
||||
((X509Certificate cert, String host, int port) => true);
|
||||
|
||||
final http.Client client = ioc.IOClient(authenticatingClient);
|
||||
|
||||
final jsonBody = json.encode(body);
|
||||
|
||||
final response = await client.post(
|
||||
rpcUri,
|
||||
final response = await ProxyWrapper().post(
|
||||
clearnetUri: rpcUri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonBody,
|
||||
);
|
||||
|
||||
printV("node check response: ${response.body}");
|
||||
|
||||
|
||||
final resBody = json.decode(response.body) as Map<String, dynamic>;
|
||||
|
||||
return resBody['result']['height'] != null;
|
||||
} catch (e) {
|
||||
printV("error: $e");
|
||||
|
@ -218,11 +212,7 @@ class Node extends HiveObject with Keyable {
|
|||
final body = {'jsonrpc': '2.0', 'id': '0', 'method': methodName};
|
||||
|
||||
try {
|
||||
final authenticatingClient = HttpClient();
|
||||
authenticatingClient.badCertificateCallback =
|
||||
((X509Certificate cert, String host, int port) => true);
|
||||
|
||||
final http.Client client = ioc.IOClient(authenticatingClient);
|
||||
final client = ProxyWrapper().getHttpIOClient();
|
||||
|
||||
final jsonBody = json.encode(body);
|
||||
|
||||
|
@ -242,15 +232,15 @@ class Node extends HiveObject with Keyable {
|
|||
return !(response['offline'] as bool);
|
||||
}
|
||||
|
||||
printV("node check response: ${response.body}");
|
||||
final responseString = await response.body;
|
||||
|
||||
if ((response.body.contains("400 Bad Request") // Some other generic error
|
||||
if ((responseString.contains("400 Bad Request") // Some other generic error
|
||||
||
|
||||
response.body.contains("plain HTTP request was sent to HTTPS port") // Cloudflare
|
||||
responseString.contains("plain HTTP request was sent to HTTPS port") // Cloudflare
|
||||
||
|
||||
response.headers["location"] != null // Generic reverse proxy
|
||||
||
|
||||
response.body
|
||||
responseString
|
||||
.contains("301 Moved Permanently") // Poorly configured generic reverse proxy
|
||||
) &&
|
||||
!(useSSL ?? false)) {
|
||||
|
@ -277,15 +267,16 @@ class Node extends HiveObject with Keyable {
|
|||
}
|
||||
|
||||
Future<bool> requestNodeWithProxy() async {
|
||||
if (!isValidProxyAddress /* && !Tor.instance.enabled*/) {
|
||||
if (!isValidProxyAddress && !CakeTor.instance.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String? proxy = socksProxyAddress;
|
||||
|
||||
// if ((proxy?.isEmpty ?? true) && Tor.instance.enabled) {
|
||||
// proxy = "${InternetAddress.loopbackIPv4.address}:${Tor.instance.port}";
|
||||
// }
|
||||
if ((proxy?.isEmpty ?? true) && CakeTor.instance.enabled) {
|
||||
proxy = "${InternetAddress.loopbackIPv4.address}:${CakeTor.instance.port}";
|
||||
}
|
||||
printV("proxy: $proxy");
|
||||
if (proxy == null) {
|
||||
return false;
|
||||
}
|
||||
|
@ -305,13 +296,9 @@ class Node extends HiveObject with Keyable {
|
|||
// you try to communicate with it
|
||||
Future<bool> requestElectrumServer() async {
|
||||
try {
|
||||
final Socket socket;
|
||||
if (useSSL == true) {
|
||||
socket = await SecureSocket.connect(uri.host, uri.port,
|
||||
timeout: Duration(seconds: 5), onBadCertificate: (_) => true);
|
||||
} else {
|
||||
socket = await Socket.connect(uri.host, uri.port, timeout: Duration(seconds: 5));
|
||||
}
|
||||
final ProxySocket socket;
|
||||
socket = await ProxyWrapper().getSocksSocket(useSSL ?? false, uri.host, uri.port);
|
||||
|
||||
|
||||
socket.destroy();
|
||||
return true;
|
||||
|
@ -322,8 +309,8 @@ class Node extends HiveObject with Keyable {
|
|||
|
||||
Future<bool> requestNanoNode() async {
|
||||
try {
|
||||
final response = await http.post(
|
||||
uri,
|
||||
final response = await ProxyWrapper().post(
|
||||
clearnetUri: uri,
|
||||
headers: {"Content-Type": "application/json", "nano-app": "cake-wallet"},
|
||||
body: jsonEncode(
|
||||
{
|
||||
|
@ -332,7 +319,8 @@ class Node extends HiveObject with Keyable {
|
|||
},
|
||||
),
|
||||
);
|
||||
final data = await jsonDecode(response.body);
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (response.statusCode != 200 ||
|
||||
data["error"] != null ||
|
||||
data["balance"] == null ||
|
||||
|
@ -348,13 +336,14 @@ class Node extends HiveObject with Keyable {
|
|||
|
||||
Future<bool> requestEthereumServer() async {
|
||||
try {
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
final req = await ProxyWrapper().getHttpClient()
|
||||
.getUrl(uri,)
|
||||
.timeout(Duration(seconds: 15));
|
||||
final response = await req.close();
|
||||
|
||||
return response.statusCode >= 200 && response.statusCode < 300;
|
||||
} catch (_) {
|
||||
} catch (err) {
|
||||
printV("Failed to request ethereum server: $err");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -462,7 +451,7 @@ class DaemonRpc {
|
|||
|
||||
/// Perform a JSON-RPC call with Digest Authentication.
|
||||
Future<Map<String, dynamic>> call(String method, Map<String, dynamic> params) async {
|
||||
final http.Client client = http.Client();
|
||||
final client = ProxyWrapper().getHttpIOClient();
|
||||
final DigestAuth digestAuth = DigestAuth(username, password);
|
||||
|
||||
// Initial request to get the `WWW-Authenticate` header.
|
||||
|
|
|
@ -1,20 +1,19 @@
|
|||
import 'dart:convert';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:cw_core/utils/proxy_wrapper.dart';
|
||||
import 'package:on_chain/solana/solana.dart';
|
||||
|
||||
class SolanaRPCHTTPService implements SolanaJSONRPCService {
|
||||
SolanaRPCHTTPService(
|
||||
{required this.url, Client? client, this.defaultRequestTimeout = const Duration(seconds: 30)})
|
||||
: client = client ?? Client();
|
||||
{required this.url,
|
||||
this.defaultRequestTimeout = const Duration(seconds: 30)});
|
||||
@override
|
||||
final String url;
|
||||
final Client client;
|
||||
final Duration defaultRequestTimeout;
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> call(SolanaRequestDetails params, [Duration? timeout]) async {
|
||||
final response = await client.post(
|
||||
Uri.parse(url),
|
||||
Future<Map<String, dynamic>> call(SolanaRequestDetails params,
|
||||
[Duration? timeout]) async {
|
||||
final response = await ProxyWrapper().post(
|
||||
clearnetUri: Uri.parse(url),
|
||||
body: params.toRequestBody(),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
29
cw_core/lib/utils/proxy_logger/abstract.dart
Normal file
29
cw_core/lib/utils/proxy_logger/abstract.dart
Normal file
|
@ -0,0 +1,29 @@
|
|||
import 'dart:typed_data';
|
||||
import 'package:http/http.dart' as very_insecure_http_do_not_use;
|
||||
|
||||
enum RequestNetwork {
|
||||
clearnet,
|
||||
tor,
|
||||
}
|
||||
|
||||
enum RequestMethod {
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
delete,
|
||||
|
||||
newHttpClient,
|
||||
newHttpIOClient,
|
||||
newProxySocket,
|
||||
}
|
||||
|
||||
abstract class ProxyLogger {
|
||||
void log({
|
||||
required Uri? uri,
|
||||
required RequestMethod method,
|
||||
required Uint8List body,
|
||||
required very_insecure_http_do_not_use.Response? response,
|
||||
required RequestNetwork network,
|
||||
required String? error,
|
||||
});
|
||||
}
|
63
cw_core/lib/utils/proxy_logger/memory_proxy_logger.dart
Normal file
63
cw_core/lib/utils/proxy_logger/memory_proxy_logger.dart
Normal file
|
@ -0,0 +1,63 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cw_core/utils/proxy_logger/abstract.dart';
|
||||
import 'package:http/http.dart' as very_insecure_http_do_not_use;
|
||||
|
||||
class MemoryProxyLoggerEntry {
|
||||
MemoryProxyLoggerEntry({
|
||||
required this.trace,
|
||||
required this.uri,
|
||||
required this.body,
|
||||
required this.network,
|
||||
required this.method,
|
||||
required this.response,
|
||||
required this.error,
|
||||
}) : time = DateTime.now();
|
||||
|
||||
final StackTrace trace;
|
||||
final Uri? uri;
|
||||
final Uint8List body;
|
||||
final RequestNetwork network;
|
||||
final very_insecure_http_do_not_use.Response? response;
|
||||
final RequestMethod method;
|
||||
final String? error;
|
||||
final DateTime time;
|
||||
@override
|
||||
String toString() => """MemoryProxyLoggerEntry(
|
||||
uri: $uri,
|
||||
body: $body,
|
||||
network: $network,
|
||||
method: $method,
|
||||
response:
|
||||
code: ${response?.statusCode},
|
||||
headers: ${response?.headers},
|
||||
body: ${response?.body},
|
||||
error: $error,
|
||||
time: $time,
|
||||
trace: ${trace}
|
||||
);""";
|
||||
}
|
||||
|
||||
class MemoryProxyLogger implements ProxyLogger {
|
||||
static List<MemoryProxyLoggerEntry> logs = [];
|
||||
@override
|
||||
void log({
|
||||
required Uri? uri,
|
||||
required RequestMethod method,
|
||||
required Uint8List body,
|
||||
required very_insecure_http_do_not_use.Response? response,
|
||||
required RequestNetwork network,
|
||||
required String? error,
|
||||
}) {
|
||||
final trace = StackTrace.current;
|
||||
logs.add(MemoryProxyLoggerEntry(
|
||||
method: method,
|
||||
trace: trace,
|
||||
uri: uri,
|
||||
body: body,
|
||||
network: network,
|
||||
response: response,
|
||||
error: error,),
|
||||
);
|
||||
}
|
||||
}
|
17
cw_core/lib/utils/proxy_logger/silent_logger.dart
Normal file
17
cw_core/lib/utils/proxy_logger/silent_logger.dart
Normal file
|
@ -0,0 +1,17 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cw_core/utils/proxy_logger/abstract.dart';
|
||||
import 'package:http/http.dart' as very_insecure_http_do_not_use;
|
||||
|
||||
// we are not doing anything
|
||||
class SilentProxyLogger implements ProxyLogger {
|
||||
@override
|
||||
void log({
|
||||
required Uri? uri,
|
||||
required RequestMethod method,
|
||||
required Uint8List body,
|
||||
required very_insecure_http_do_not_use.Response? response,
|
||||
required RequestNetwork network,
|
||||
required String? error,
|
||||
}) {}
|
||||
}
|
47
cw_core/lib/utils/proxy_socket/abstract.dart
Normal file
47
cw_core/lib/utils/proxy_socket/abstract.dart
Normal file
|
@ -0,0 +1,47 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cw_core/utils/proxy_socket/insecure.dart';
|
||||
import 'package:cw_core/utils/proxy_socket/secure.dart';
|
||||
import 'package:cw_core/utils/proxy_socket/socks.dart';
|
||||
import 'package:cw_core/utils/proxy_wrapper.dart';
|
||||
import 'package:socks_socket/socks_socket.dart';
|
||||
|
||||
class ProxyAddress {
|
||||
final String host;
|
||||
final int port;
|
||||
|
||||
ProxyAddress({required this.host, required this.port});
|
||||
}
|
||||
|
||||
abstract class ProxySocket {
|
||||
static Future<ProxySocket> connect(bool sslEnabled, ProxyAddress address, {Duration? connectionTimeout}) async {
|
||||
if (CakeTor.instance.started) {
|
||||
var socksSocket = await SOCKSSocket.create(
|
||||
proxyHost: InternetAddress.loopbackIPv4.address,
|
||||
proxyPort: CakeTor.instance.port,
|
||||
sslEnabled: sslEnabled,
|
||||
);
|
||||
await socksSocket.connect();
|
||||
await socksSocket.connectTo(address.host, address.port);
|
||||
return ProxySocketSocks(socksSocket);
|
||||
}
|
||||
if (sslEnabled == false) {
|
||||
return ProxySocketInsecure(await Socket.connect(address.host, address.port, timeout: connectionTimeout));
|
||||
} else {
|
||||
return ProxySocketSecure(await SecureSocket.connect(
|
||||
address.host,
|
||||
address.port,
|
||||
timeout: connectionTimeout,
|
||||
onBadCertificate: (_) => true,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close();
|
||||
Future<void> destroy();
|
||||
Future<void> write(String data);
|
||||
StreamSubscription<List<int>> listen(Function(Uint8List event) onData, {Function (Object error)? onError, Function ()? onDone, bool cancelOnError = true});
|
||||
ProxyAddress get address;
|
||||
}
|
34
cw_core/lib/utils/proxy_socket/insecure.dart
Normal file
34
cw_core/lib/utils/proxy_socket/insecure.dart
Normal file
|
@ -0,0 +1,34 @@
|
|||
|
||||
import 'package:cw_core/utils/proxy_socket/abstract.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:io';
|
||||
|
||||
class ProxySocketInsecure implements ProxySocket {
|
||||
final Socket socket;
|
||||
|
||||
ProxySocketInsecure(this.socket);
|
||||
|
||||
ProxyAddress get address => ProxyAddress(host: socket.remoteAddress.host, port: socket.remotePort);
|
||||
|
||||
@override
|
||||
Future<void> close() => socket.close();
|
||||
|
||||
@override
|
||||
Future<void> destroy() async => socket.destroy();
|
||||
|
||||
@override
|
||||
Future<void> write(String data) async => socket.write(data);
|
||||
|
||||
@override
|
||||
StreamSubscription<List<int>> listen(Function(Uint8List event) onData, {Function(Object error)? onError, Function()? onDone, bool cancelOnError = true}) {
|
||||
return socket.listen(
|
||||
(data) {
|
||||
onData(Uint8List.fromList(data));
|
||||
},
|
||||
onError: onError,
|
||||
onDone: onDone,
|
||||
cancelOnError: cancelOnError,
|
||||
);
|
||||
}
|
||||
}
|
34
cw_core/lib/utils/proxy_socket/secure.dart
Normal file
34
cw_core/lib/utils/proxy_socket/secure.dart
Normal file
|
@ -0,0 +1,34 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cw_core/utils/proxy_socket/abstract.dart';
|
||||
|
||||
class ProxySocketSecure implements ProxySocket {
|
||||
final SecureSocket socket;
|
||||
|
||||
ProxySocketSecure(this.socket);
|
||||
|
||||
ProxyAddress get address => ProxyAddress(host: socket.remoteAddress.host, port: socket.remotePort);
|
||||
|
||||
@override
|
||||
Future<void> close() => socket.close();
|
||||
|
||||
@override
|
||||
Future<void> destroy() async => socket.destroy();
|
||||
|
||||
@override
|
||||
Future<void> write(String data) async => socket.write(data);
|
||||
|
||||
@override
|
||||
StreamSubscription<List<int>> listen(Function(Uint8List event) onData, {Function(Object error)? onError, Function()? onDone, bool cancelOnError = true}) {
|
||||
return socket.listen(
|
||||
(data) {
|
||||
onData(Uint8List.fromList(data));
|
||||
},
|
||||
onError: onError,
|
||||
onDone: onDone,
|
||||
cancelOnError: cancelOnError,
|
||||
);
|
||||
}
|
||||
}
|
36
cw_core/lib/utils/proxy_socket/socks.dart
Normal file
36
cw_core/lib/utils/proxy_socket/socks.dart
Normal file
|
@ -0,0 +1,36 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cw_core/utils/proxy_socket/abstract.dart';
|
||||
import 'package:socks_socket/socks_socket.dart';
|
||||
|
||||
class ProxySocketSocks implements ProxySocket {
|
||||
final SOCKSSocket socket;
|
||||
|
||||
ProxySocketSocks(this.socket);
|
||||
|
||||
@override
|
||||
ProxyAddress get address => ProxyAddress(host: socket.proxyHost, port: socket.proxyPort);
|
||||
|
||||
@override
|
||||
Future<void> close() => socket.close();
|
||||
|
||||
@override
|
||||
Future<void> destroy() => close();
|
||||
|
||||
@override
|
||||
Future<void> write(String data) async => socket.write(data);
|
||||
|
||||
@override
|
||||
StreamSubscription<List<int>> listen(Function(Uint8List event) onData, {Function(Object error)? onError, Function()? onDone, bool cancelOnError = true}) {
|
||||
return socket.listen(
|
||||
(data) {
|
||||
onData(Uint8List.fromList(data));
|
||||
},
|
||||
onError: onError,
|
||||
onDone: onDone,
|
||||
cancelOnError: cancelOnError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
447
cw_core/lib/utils/proxy_wrapper.dart
Normal file
447
cw_core/lib/utils/proxy_wrapper.dart
Normal file
|
@ -0,0 +1,447 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:cw_core/utils/proxy_logger/abstract.dart';
|
||||
import 'package:cw_core/utils/proxy_socket/abstract.dart';
|
||||
import 'package:cw_core/utils/tor/abstract.dart';
|
||||
import 'package:cw_core/utils/tor/android.dart';
|
||||
import 'package:cw_core/utils/tor/disabled.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:socks5_proxy/socks_client.dart';
|
||||
import 'package:http/io_client.dart' as ioc;
|
||||
|
||||
class ProxyWrapper {
|
||||
static final ProxyWrapper _proxyWrapper = ProxyWrapper._internal();
|
||||
static ProxyLogger? logger;
|
||||
|
||||
factory ProxyWrapper() {
|
||||
return _proxyWrapper;
|
||||
}
|
||||
|
||||
ProxyWrapper._internal();
|
||||
Future<ProxySocket> getSocksSocket(bool sslEnabled, String host, int port, {Duration? connectionTimeout}) async {
|
||||
logger?.log(
|
||||
uri: Uri(
|
||||
scheme: sslEnabled ? "https" : "http",
|
||||
host: host,
|
||||
port: port,
|
||||
),
|
||||
method: RequestMethod.newProxySocket,
|
||||
body: Uint8List(0),
|
||||
response: null,
|
||||
network: requestNetwork(),
|
||||
error: null
|
||||
);
|
||||
return ProxySocket.connect(sslEnabled, ProxyAddress(host: host, port: port), connectionTimeout: connectionTimeout);
|
||||
}
|
||||
|
||||
RequestNetwork requestNetwork() {
|
||||
return CakeTor.instance.started ? RequestNetwork.tor : RequestNetwork.clearnet;
|
||||
}
|
||||
|
||||
ioc.IOClient getHttpIOClient({int? portOverride, bool internal = false}) {
|
||||
if (!internal) {
|
||||
logger?.log(
|
||||
uri: null,
|
||||
method: RequestMethod.newHttpIOClient,
|
||||
body: Uint8List(0),
|
||||
response: null,
|
||||
network: requestNetwork(),
|
||||
error: null,
|
||||
);
|
||||
}
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
final httpClient = ProxyWrapper().getHttpClient(portOverride: portOverride, internal: true);
|
||||
return ioc.IOClient(httpClient);
|
||||
}
|
||||
|
||||
int getPort() => CakeTor.instance.port;
|
||||
|
||||
@Deprecated('Use ProxyWrapper().get/post/put methods instead, and provide proper clearnet and onion uri.')
|
||||
HttpClient getHttpClient({int? portOverride, bool internal = false}) {
|
||||
if (!internal) {
|
||||
logger?.log(
|
||||
uri: null,
|
||||
method: RequestMethod.newProxySocket,
|
||||
body: Uint8List(0),
|
||||
response: null,
|
||||
network: requestNetwork(),
|
||||
error: null
|
||||
);
|
||||
}
|
||||
if (CakeTor.instance.started) {
|
||||
// Assign connection factory.
|
||||
final client = HttpClient();
|
||||
SocksTCPClient.assignToHttpClient(client, [
|
||||
ProxySettings(
|
||||
InternetAddress.loopbackIPv4,
|
||||
CakeTor.instance.port,
|
||||
password: null,
|
||||
),
|
||||
]);
|
||||
return client;
|
||||
} else {
|
||||
return HttpClient();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Future<Response> _make({
|
||||
required RequestMethod method,
|
||||
required ioc.IOClient client,
|
||||
required Uri uri,
|
||||
required Map<String, String>? headers,
|
||||
String? body,
|
||||
}) async {
|
||||
Object? error;
|
||||
Response? resp;
|
||||
try {
|
||||
switch (method) {
|
||||
case RequestMethod.get:
|
||||
resp = await client. get(
|
||||
uri,
|
||||
headers: headers,
|
||||
);
|
||||
break;
|
||||
case RequestMethod.delete:
|
||||
resp = await client.delete(
|
||||
uri,
|
||||
headers: headers,
|
||||
body: body,
|
||||
);
|
||||
break;
|
||||
case RequestMethod.post:
|
||||
resp = await client.post(
|
||||
uri,
|
||||
headers: headers,
|
||||
body: body,
|
||||
);
|
||||
break;
|
||||
case RequestMethod.put:
|
||||
resp = await client.put(
|
||||
uri,
|
||||
headers: headers,
|
||||
body: body,
|
||||
);
|
||||
break;
|
||||
case RequestMethod.newHttpClient:
|
||||
case RequestMethod.newHttpIOClient:
|
||||
case RequestMethod.newProxySocket:
|
||||
throw UnimplementedError();
|
||||
}
|
||||
return resp;
|
||||
} catch (e) {
|
||||
error = e;
|
||||
rethrow;
|
||||
} finally {
|
||||
logger?.log(
|
||||
uri: uri,
|
||||
method: RequestMethod.get,
|
||||
body: utf8.encode(body ?? ''),
|
||||
response: resp,
|
||||
network: requestNetwork(),
|
||||
error: error?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> get({
|
||||
Map<String, String>? headers,
|
||||
int? portOverride,
|
||||
Uri? clearnetUri,
|
||||
Uri? onionUri,
|
||||
}) async {
|
||||
ioc.IOClient? torClient;
|
||||
bool torEnabled = CakeTor.instance.started;
|
||||
|
||||
if (CakeTor.instance.started) {
|
||||
torEnabled = true;
|
||||
} else {
|
||||
torEnabled = false;
|
||||
}
|
||||
|
||||
// if tor is enabled, try to connect to the onion url first:
|
||||
if (torEnabled) {
|
||||
try {
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
torClient = await getHttpIOClient(portOverride: portOverride, internal: true);
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (onionUri != null) {
|
||||
try {
|
||||
return await _make(
|
||||
method: RequestMethod.get,
|
||||
client: torClient,
|
||||
uri: onionUri,
|
||||
headers: headers,
|
||||
);
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
if (clearnetUri != null) {
|
||||
try {
|
||||
return await _make(
|
||||
method: RequestMethod.get,
|
||||
client: torClient,
|
||||
uri: clearnetUri,
|
||||
headers: headers,
|
||||
);
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clearnetUri != null) {
|
||||
try {
|
||||
return HttpOverrides.runZoned(
|
||||
() async {
|
||||
return await _make(
|
||||
method: RequestMethod.get,
|
||||
client: ioc.IOClient(),
|
||||
uri: clearnetUri,
|
||||
headers: headers,
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
// we weren't able to get a response:
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
throw Exception("Unable to connect to server");
|
||||
}
|
||||
|
||||
|
||||
Future<Response> post({
|
||||
Map<String, String>? headers,
|
||||
int? portOverride,
|
||||
Uri? clearnetUri,
|
||||
Uri? onionUri,
|
||||
String? body,
|
||||
bool allowMitmMoneroBypassSSLCheck = false,
|
||||
}) async {
|
||||
HttpClient? torHttpClient;
|
||||
HttpClient cleatnetHttpClient = HttpClient();
|
||||
if (allowMitmMoneroBypassSSLCheck) {
|
||||
cleatnetHttpClient.badCertificateCallback =
|
||||
((X509Certificate cert, String host, int port) => true);
|
||||
}
|
||||
|
||||
ioc.IOClient clearnetClient = ioc.IOClient(cleatnetHttpClient);
|
||||
|
||||
|
||||
bool torEnabled = CakeTor.instance.started;
|
||||
|
||||
if (torEnabled) {
|
||||
try {
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
torHttpClient = await getHttpClient(portOverride: portOverride);
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
if (allowMitmMoneroBypassSSLCheck) {
|
||||
torHttpClient.badCertificateCallback =
|
||||
((X509Certificate cert, String host, int port) => true);
|
||||
}
|
||||
if (onionUri != null) {
|
||||
try {
|
||||
return await _make(
|
||||
method: RequestMethod.post,
|
||||
client: ioc.IOClient(torHttpClient),
|
||||
uri: onionUri,
|
||||
headers: headers,
|
||||
body: body,
|
||||
);
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
if (clearnetUri != null) {
|
||||
try {
|
||||
return await _make(
|
||||
method: RequestMethod.post,
|
||||
client: ioc.IOClient(torHttpClient),
|
||||
uri: clearnetUri,
|
||||
headers: headers,
|
||||
body: body,
|
||||
);
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clearnetUri != null) {
|
||||
try {
|
||||
return HttpOverrides.runZoned(
|
||||
() async {
|
||||
return await _make(
|
||||
method: RequestMethod.post,
|
||||
client: clearnetClient,
|
||||
uri: clearnetUri,
|
||||
headers: headers,
|
||||
body: body,
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
throw Exception("Unable to connect to server");
|
||||
}
|
||||
|
||||
Future<Response> put({
|
||||
Map<String, String>? headers,
|
||||
int? portOverride,
|
||||
Uri? clearnetUri,
|
||||
Uri? onionUri,
|
||||
String? body,
|
||||
}) async {
|
||||
ioc.IOClient? torClient;
|
||||
bool torEnabled = CakeTor.instance.started;
|
||||
|
||||
if (torEnabled) {
|
||||
try {
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
torClient = await getHttpIOClient(portOverride: portOverride, internal: true);
|
||||
} catch (_) {}
|
||||
|
||||
if (onionUri != null) {
|
||||
try {
|
||||
return await _make(
|
||||
method: RequestMethod.put,
|
||||
client: torClient!,
|
||||
uri: onionUri,
|
||||
headers: headers,
|
||||
body: body,
|
||||
);
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
if (clearnetUri != null) {
|
||||
try {
|
||||
return await _make(
|
||||
method: RequestMethod.put,
|
||||
client: torClient!,
|
||||
uri: clearnetUri,
|
||||
headers: headers,
|
||||
body: body,
|
||||
);
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clearnetUri != null) {
|
||||
try {
|
||||
return HttpOverrides.runZoned(
|
||||
() async {
|
||||
return await _make(
|
||||
method: RequestMethod.put,
|
||||
client: ioc.IOClient(),
|
||||
uri: clearnetUri,
|
||||
headers: headers,
|
||||
body: body,
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
// we weren't able to get a response:
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
throw Exception("Unable to connect to server");
|
||||
}
|
||||
|
||||
Future<Response> delete({
|
||||
Map<String, String>? headers,
|
||||
int? portOverride,
|
||||
Uri? clearnetUri,
|
||||
Uri? onionUri,
|
||||
}) async {
|
||||
ioc.IOClient? torClient;
|
||||
bool torEnabled = CakeTor.instance.started;
|
||||
|
||||
if (CakeTor.instance.started) {
|
||||
torEnabled = true;
|
||||
} else {
|
||||
torEnabled = false;
|
||||
}
|
||||
|
||||
// if tor is enabled, try to connect to the onion url first:
|
||||
if (torEnabled) {
|
||||
try {
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
torClient = await getHttpIOClient(portOverride: portOverride, internal: true);
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (onionUri != null) {
|
||||
try {
|
||||
return await _make(
|
||||
method: RequestMethod.delete,
|
||||
client: torClient,
|
||||
uri: onionUri,
|
||||
headers: headers,
|
||||
);
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
if (clearnetUri != null) {
|
||||
try {
|
||||
return await _make(
|
||||
method: RequestMethod.delete,
|
||||
client: torClient,
|
||||
uri: clearnetUri,
|
||||
headers: headers,
|
||||
);
|
||||
} catch (_) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clearnetUri != null) {
|
||||
try {
|
||||
return HttpOverrides.runZoned(
|
||||
() async {
|
||||
return await _make(
|
||||
method: RequestMethod.delete,
|
||||
client: ioc.IOClient(),
|
||||
uri: clearnetUri,
|
||||
headers: headers,
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
// we weren't able to get a response:
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
throw Exception("Unable to connect to server");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CakeTor {
|
||||
static final CakeTorInstance instance = CakeTorInstance.getInstance();
|
||||
}
|
38
cw_core/lib/utils/tor/abstract.dart
Normal file
38
cw_core/lib/utils/tor/abstract.dart
Normal file
|
@ -0,0 +1,38 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
import 'package:cw_core/utils/tor/android.dart';
|
||||
import 'package:cw_core/utils/tor/disabled.dart';
|
||||
import 'package:cw_core/utils/tor/tails.dart';
|
||||
|
||||
abstract class CakeTorInstance {
|
||||
bool get started;
|
||||
|
||||
int get port => -1;
|
||||
|
||||
bool get enabled => false;
|
||||
|
||||
bool get bootstrapped => false;
|
||||
|
||||
Future<void> start();
|
||||
Future<void> stop();
|
||||
|
||||
static CakeTorInstance getInstance() {
|
||||
if (Platform.isAndroid) {
|
||||
return CakeTorAndroid();
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
try {
|
||||
final os = File("/etc/os-release").readAsLinesSync();
|
||||
for (var line in os) {
|
||||
if (!line.startsWith("ID=")) continue;
|
||||
if (!line.contains("tails")) continue;
|
||||
return CakeTorTails();
|
||||
}
|
||||
} catch (e) {
|
||||
printV("Failed to identify linux version - /etc/os-release missing");
|
||||
}
|
||||
}
|
||||
return CakeTorDisabled();
|
||||
}
|
||||
}
|
73
cw_core/lib/utils/tor/android.dart
Normal file
73
cw_core/lib/utils/tor/android.dart
Normal file
|
@ -0,0 +1,73 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
import 'package:cw_core/utils/tor/abstract.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:tor_binary/tor_binary_platform_interface.dart';
|
||||
|
||||
class CakeTorAndroid implements CakeTorInstance {
|
||||
@override
|
||||
bool get bootstrapped => _proc != null;
|
||||
|
||||
@override
|
||||
bool get enabled => _proc != null;
|
||||
|
||||
@override
|
||||
int get port => 42142;
|
||||
|
||||
@override
|
||||
Future<void> start() async {
|
||||
await _runEmbeddedTor();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get started => _proc != null;
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
_proc?.kill();
|
||||
await _proc?.exitCode;
|
||||
_proc = null;
|
||||
}
|
||||
|
||||
static Process? _proc;
|
||||
|
||||
Future<void> _runEmbeddedTor() async {
|
||||
final dir = await getApplicationCacheDirectory();
|
||||
|
||||
final torBinPath = p.join((await TorBinaryPlatform.instance.getBinaryPath())!, "libtor.so");
|
||||
printV("torPath: $torBinPath");
|
||||
|
||||
if (started) {
|
||||
printV("Proxy is running");
|
||||
return;
|
||||
}
|
||||
|
||||
printV("Starting embedded tor");
|
||||
printV("app docs: $dir");
|
||||
final torrc = """
|
||||
SocksPort $port
|
||||
Log notice file ${p.join(dir.path, "tor.log")}
|
||||
RunAsDaemon 0
|
||||
DataDirectory ${p.join(dir.path, "tor-data")}
|
||||
""";
|
||||
final torrcPath = p.join(dir.absolute.path, "torrc");
|
||||
File(torrcPath).writeAsStringSync(torrc);
|
||||
|
||||
if (_proc != null) {
|
||||
try {
|
||||
_proc?.kill();
|
||||
await _proc?.exitCode;
|
||||
_proc = null;
|
||||
} catch (e) {
|
||||
printV(e);
|
||||
}
|
||||
}
|
||||
printV("path: $torBinPath -f $torrcPath");
|
||||
_proc = await Process.start(torBinPath, ["-f", torrcPath]);
|
||||
_proc?.stdout.transform(utf8.decoder).forEach(printV);
|
||||
_proc?.stderr.transform(utf8.decoder).forEach(printV);
|
||||
}
|
||||
}
|
21
cw_core/lib/utils/tor/disabled.dart
Normal file
21
cw_core/lib/utils/tor/disabled.dart
Normal file
|
@ -0,0 +1,21 @@
|
|||
import 'package:cw_core/utils/tor/abstract.dart';
|
||||
|
||||
class CakeTorDisabled implements CakeTorInstance {
|
||||
@override
|
||||
bool get bootstrapped => false;
|
||||
|
||||
@override
|
||||
bool get enabled => false;
|
||||
|
||||
@override
|
||||
int get port => -1;
|
||||
|
||||
@override
|
||||
Future<void> start() => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
bool get started => false;
|
||||
|
||||
@override
|
||||
Future<void> stop() => throw UnimplementedError();
|
||||
}
|
21
cw_core/lib/utils/tor/tails.dart
Normal file
21
cw_core/lib/utils/tor/tails.dart
Normal file
|
@ -0,0 +1,21 @@
|
|||
import 'package:cw_core/utils/tor/abstract.dart';
|
||||
|
||||
class CakeTorTails implements CakeTorInstance {
|
||||
@override
|
||||
bool get bootstrapped => true;
|
||||
|
||||
@override
|
||||
bool get enabled => true;
|
||||
|
||||
@override
|
||||
int get port => 9150;
|
||||
|
||||
@override
|
||||
Future<void> start() async {}
|
||||
|
||||
@override
|
||||
bool get started => true;
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
}
|
|
@ -479,7 +479,7 @@ packages:
|
|||
description:
|
||||
path: "."
|
||||
ref: cake-update-v2
|
||||
resolved-ref: "01cbbacbb05d2113aafa8b7c4a2bb766f749d8d8"
|
||||
resolved-ref: "096865a8c6b89c260beadfec04f7e184c40a3273"
|
||||
url: "https://github.com/cake-tech/on_chain.git"
|
||||
source: git
|
||||
version: "3.7.0"
|
||||
|
@ -635,11 +635,21 @@ packages:
|
|||
socks5_proxy:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: socks5_proxy
|
||||
sha256: "616818a0ea1064a4823b53c9f7eaf8da64ed82dcd51ed71371c7e54751ed5053"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.6"
|
||||
path: "."
|
||||
ref: "27ad7c2efae8d7460325c74b90f660085cbd0685"
|
||||
resolved-ref: "27ad7c2efae8d7460325c74b90f660085cbd0685"
|
||||
url: "https://github.com/LacticWhale/socks_dart"
|
||||
source: git
|
||||
version: "2.1.0"
|
||||
socks_socket:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: e6232c53c1595469931ababa878759a067c02e94
|
||||
resolved-ref: e6232c53c1595469931ababa878759a067c02e94
|
||||
url: "https://github.com/sneurlax/socks_socket"
|
||||
source: git
|
||||
version: "1.1.1"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -716,10 +726,19 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
version: "1.0.1"
|
||||
tor_binary:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: cb811c610871a9517d47134b87c2f590c15c96c5
|
||||
resolved-ref: cb811c610871a9517d47134b87c2f590c15c96c5
|
||||
url: "https://github.com/MrCyjaneK/flutter-tor_binary"
|
||||
source: git
|
||||
version: "4.7.14"
|
||||
tuple:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -25,16 +25,23 @@ dependencies:
|
|||
url: https://github.com/cake-tech/cake_backup.git
|
||||
ref: main
|
||||
version: 1.0.0
|
||||
socks5_proxy: ^1.0.4
|
||||
socks5_proxy:
|
||||
git:
|
||||
url: https://github.com/LacticWhale/socks_dart
|
||||
ref: 27ad7c2efae8d7460325c74b90f660085cbd0685
|
||||
unorm_dart: ^0.3.0
|
||||
on_chain:
|
||||
git:
|
||||
url: https://github.com/cake-tech/on_chain.git
|
||||
ref: cake-update-v2
|
||||
# tor:
|
||||
# git:
|
||||
# url: https://github.com/cake-tech/tor.git
|
||||
# ref: main
|
||||
socks_socket:
|
||||
git:
|
||||
url: https://github.com/sneurlax/socks_socket
|
||||
ref: e6232c53c1595469931ababa878759a067c02e94
|
||||
tor_binary:
|
||||
git:
|
||||
url: https://github.com/MrCyjaneK/flutter-tor_binary
|
||||
ref: cb811c610871a9517d47134b87c2f590c15c96c5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue