diff --git a/example/lib/main.dart b/example/lib/main.dart index 77a70d0..bb86765 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -3,16 +3,17 @@ // // SPDX-License-Identifier: MIT -// Example app deps, not necessarily needed for tor usage. +// Flutter dependencies not necessarily needed for tor usage: import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; -// Imports needed for tor usage: -import 'package:socks5_proxy/socks_client.dart'; // Just for example; can use any socks5 proxy package, pick your favorite. -import 'package:tor/tor.dart'; -import 'package:tor/socks_socket.dart'; // For socket connections +// Example application dependencies you can replace with any that works for you: +import 'package:socks5_proxy/socks_client.dart'; +import 'package:tor/socks_socket.dart'; +// The only real import needed for basic usage: +import 'package:tor/tor.dart'; // This would go at the top, but dart autoformatter doesn't like it there. void main() { runApp(const MyApp()); @@ -44,6 +45,32 @@ class _MyAppState extends State { final hostController = TextEditingController(text: 'https://icanhazip.com/'); // https://check.torproject.org is another good option. + // Set the default text for the onion input field. + final onionController = TextEditingController( + text: + 'https://cflarexljc3rw355ysrkrzwapozws6nre6xsy3n4yrj7taye3uiby3ad.onion'); + // See https://blog.cloudflare.com/cloudflare-onion-service/ for more options: + // cflarexljc3rw355ysrkrzwapozws6nre6xsy3n4yrj7taye3uiby3ad.onion + // cflarenuttlfuyn7imozr4atzvfbiw3ezgbdjdldmdx7srterayaozid.onion + // cflares35lvdlczhy3r6qbza5jjxbcplzvdveabhf7bsp7y4nzmn67yd.onion + // cflareusni3s7vwhq2f7gc4opsik7aa4t2ajedhzr42ez6uajaywh3qd.onion + // cflareki4v3lh674hq55k3n7xd4ibkwx3pnw67rr3gkpsonjmxbktxyd.onion + // cflarejlah424meosswvaeqzb54rtdetr4xva6mq2bm2hfcx5isaglid.onion + // cflaresuje2rb7w2u3w43pn4luxdi6o7oatv6r2zrfb5xvsugj35d2qd.onion + // cflareer7qekzp3zeyqvcfktxfrmncse4ilc7trbf6bp6yzdabxuload.onion + // cflareub6dtu7nvs3kqmoigcjdwap2azrkx5zohb2yk7gqjkwoyotwqd.onion + // cflare2nge4h4yqr3574crrd7k66lil3torzbisz6uciyuzqc2h2ykyd.onion + + final bitcoinOnionController = TextEditingController( + text: + 'qly7g5n5t3f3h23xvbp44vs6vpmayurno4basuu5rcvrupli7y2jmgid.onion:50001'); + // For more options, see https://bitnodes.io/nodes/addresses/?q=onion and + // https://sethforprivacy.com/about/ + + final moneroOnionController = TextEditingController( + text: + 'ucdouiihzwvb5edg3ezeufcs4yp26gq4x64n6b4kuffb7s7jxynnk7qd.onion:18081/json_rpc'); + Future startTor() async { await Tor.init(); @@ -235,6 +262,203 @@ class _MyAppState extends State { "Connect to bitcoin.stackwallet.com:50002 (SSL) via socks socket", ), ), + spacerSmall, + Row( + children: [ + // Bitcoin onion input field. + Expanded( + child: TextField( + controller: bitcoinOnionController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Bitcoin onion address to test', + ), + ), + ), + spacerSmall, + TextButton( + onPressed: torStarted + ? () async { + // Validate the onion address. + if (!onionController.text.contains(".onion")) { + print("Invalid onion address"); + return; + } else if (!onionController.text.contains(":")) { + print("Invalid onion address (needs port)"); + return; + } + + String domain = + bitcoinOnionController.text.split(":").first; + int port = int.parse( + bitcoinOnionController.text.split(":").last); + + // Instantiate a socks socket at localhost and on the port selected by the tor service. + var socksSocket = await SOCKSSocket.create( + proxyHost: InternetAddress.loopbackIPv4.address, + proxyPort: Tor.instance.port, + sslEnabled: !domain + .endsWith(".onion"), // For SSL connections. + ); + + // Connect to the socks instantiated above. + await socksSocket.connect(); + + // Connect to onion node via socks socket. + // + // Note that this is an SSL example. + await socksSocket.connectTo(domain, port); + + // Send a server features command to the connected socket, see method for more specific usage example.. + await socksSocket.sendServerFeaturesCommand(); + + // You should see a server response printed to the console. + // + // Example response: + // `flutter: secure responseData: { + // "id": "0", + // "jsonrpc": "2.0", + // "result": { + // "cashtokens": true, + // "dsproof": true, + // "genesis_hash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + // "hash_function": "sha256", + // "hosts": { + // "bitcoin.stackwallet.com": { + // "ssl_port": 50002, + // "tcp_port": 50001, + // "ws_port": 50003, + // "wss_port": 50004 + // } + // }, + // "protocol_max": "1.5", + // "protocol_min": "1.4", + // "pruning": null, + // "server_version": "Fulcrum 1.9.1" + // } + // } + + // Close the socket. + await socksSocket.close(); + } + + // A mutex should be added to this example to prevent + // multiple connections from being made at once. TODO + : null, + child: const Text( + "Test Bitcoin onion node connection", + ), + ), + ], + ), + spacerSmall, + Row( + children: [ + // Monero onion input field. + Expanded( + child: TextField( + controller: moneroOnionController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Monero onion address to test', + ), + ), + ), + spacerSmall, + TextButton( + onPressed: torStarted + ? () async { + // Validate the onion address. + if (!moneroOnionController.text + .contains(".onion")) { + print("Invalid onion address"); + return; + } else if (!moneroOnionController.text + .contains(":")) { + print("Invalid onion address (needs port)"); + return; + } + + final String host = + moneroOnionController.text.split(":").first; + final int port = int.parse(moneroOnionController + .text + .split(":") + .last + .split("/") + .first); + final String path = moneroOnionController.text + .split(":") + .last + .split("/") + .last; // Extract the path + + var socksSocket = await SOCKSSocket.create( + proxyHost: InternetAddress.loopbackIPv4.address, + proxyPort: Tor.instance.port, + sslEnabled: false, + ); + + await socksSocket.connect(); + await socksSocket.connectTo(host, port); + + final body = jsonEncode({ + "jsonrpc": "2.0", + "id": "0", + "method": "get_info", + }); + + final request = 'POST /$path HTTP/1.1\r\n' + 'Host: $host\r\n' + 'Content-Type: application/json\r\n' + 'Content-Length: ${body.length}\r\n' + '\r\n' + '$body'; + + socksSocket.write(request); + print("Request sent: $request"); + + await for (var response + in socksSocket.inputStream) { + final result = utf8.decode(response); + print("Response received: $result"); + break; + } + + // You should see a server response printed to the console. + // + // Example response: + // Host: ucdouiihzwvb5edg3ezeufcs4yp26gq4x64n6b4kuffb7s7jxynnk7qd.onion + // Content-Type: application/json + // Content-Length: 46 + // + // {"jsonrpc":"2.0","id":"0","method":"get_info"} + // flutter: Response received: HTTP/1.1 200 Ok + // Server: Epee-based + // Content-Length: 1434 + // Content-Type: application/json + // Last-Modified: Thu, 03 Oct 2024 23:08:19 GMT + // Accept-Ranges: bytes + // + // { + // "id": "0", + // "jsonrpc": "2.0", + // "result": { + // "adjusted_time": 1727996959, + // ... + + await socksSocket.close(); + } + + // A mutex should be added to this example to prevent + // multiple connections from being made at once. TODO + : null, + child: const Text( + "Test Monero onion node connection", + ), + ), + ], + ), ], ), ), diff --git a/example/pubspec.lock b/example/pubspec.lock index 3479fa2..1ff8c91 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -220,10 +220,10 @@ packages: dependency: "direct main" description: name: socks5_proxy - sha256: "1d21b5606169654bbf4cfb904e8e6ed897e9f763358709f87310c757096d909a" + sha256: e0cba6917cd374de6f6cb0ce081e50e6efc24c61644b8e9f20c8bf8b91bb0b75 url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.3+dev.3" source_span: dependency: transitive description: @@ -278,7 +278,7 @@ packages: path: ".." relative: true source: path - version: "0.0.7" + version: "0.0.8" vector_math: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 4e2700a..bd200ef 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -46,7 +46,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 - socks5_proxy: ^1.0.3+dev.3 + socks5_proxy: 1.0.3+dev.3 dev_dependencies: flutter_test: diff --git a/lib/socks_socket.dart b/lib/socks_socket.dart index aab8617..97c311d 100644 --- a/lib/socks_socket.dart +++ b/lib/socks_socket.dart @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 Foundation Devices Inc. +// SPDX-FileCopyrightText: 2024 Cypher Stack LLC // // SPDX-License-Identifier: MIT @@ -10,8 +10,7 @@ import 'package:flutter/foundation.dart'; /// A SOCKS5 socket. /// -/// This class is a wrapper around the Socket class that implements the -/// SOCKS5 protocol. It supports SSL and non-SSL connections. +/// A Dart 3 Socket wrapper that implements the SOCKS5 protocol. Now with SSL! /// /// Properties: /// - [proxyHost]: The host of the SOCKS5 proxy server. @@ -99,6 +98,26 @@ class SOCKSSocket { /// Private constructor. SOCKSSocket._(this.proxyHost, this.proxyPort, this.sslEnabled); + /// Provides a stream of data as List. + Stream> get inputStream => sslEnabled + ? _secureResponseController.stream + : _responseController.stream; + + /// Provides a StreamSink compatible with List for sending data. + StreamSink> get outputStream { + // Create a simple StreamSink wrapper for _socksSocket and + // _secureSocksSocket that accepts List and forwards it to write method. + var sink = StreamController>(); + sink.stream.listen((data) { + if (sslEnabled) { + _secureSocksSocket.add(data); + } else { + _socksSocket.add(data); + } + }); + return sink.sink; + } + /// Creates a SOCKS5 socket to the specified [proxyHost] and [proxyPort]. /// /// This method is a factory constructor that returns a Future that resolves @@ -163,7 +182,7 @@ class SOCKSSocket { }, onDone: () { // Close the response controller when the socket is closed. - _responseController.close(); + // _responseController.close(); }, ); } @@ -221,7 +240,7 @@ class SOCKSSocket { 'socks_socket.connectTo(): Failed to connect to target through SOCKS5 proxy.'); } - // Upgrade to SSL if needed + // Upgrade to SSL if needed. if (sslEnabled) { // Upgrade to SSL. _secureSocksSocket = await SecureSocket.secure( @@ -283,15 +302,19 @@ class SOCKSSocket { /// A Future that resolves to void. Future close() async { // Ensure all data is sent before closing. - // - // TODO test this. - if (sslEnabled) { + try { + if (sslEnabled) { + await _secureSocksSocket.flush(); + } await _socksSocket.flush(); - await _secureResponseController.close(); + } finally { + await _subscription?.cancel(); + await _socksSocket.close(); + _responseController.close(); + if (sslEnabled) { + _secureResponseController.close(); + } } - await _socksSocket.flush(); - await _responseController.close(); - return await _socksSocket.close(); } StreamSubscription> listen(