diff --git a/lib/api.dart b/lib/api.dart index 7c16a01..0a2fe67 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -23,6 +23,8 @@ const GetAuthRequestsPath = V0Prefix + "peers/auth_requests"; const GetMyPeerInfoPath = V0Prefix + "settings/peer_info"; const UpdateMyInfoPath = V0Prefix + "settings/update"; const ExportServerConfigPath = V0Prefix + "settings/export_server_config"; +const ListAvailableProxiesPath = V0Prefix + "settings/list_proxies"; +const UpdateProxySettingsPath = V0Prefix + "settings/set_proxy"; // Debug const GetP2pDebugInfoPath = V0Prefix + "debug/p2p_info"; @@ -45,7 +47,8 @@ Future fetchMyPeerInfo(http.Client client) async { return MyPeerInfo.fromJson(parsed); } catch (e) { print("error in fetchMyPeerInfo: '${e.toString()}'."); - return MyPeerInfo("", "", Duration.zero, "–", NetworkStats(0, 0, 0, 0), 0, 0, "", "", false); + return MyPeerInfo( + "", "", Duration.zero, "–", NetworkStats(0, 0, 0, 0), 0, 0, "", "", false, SOCKS5Info("", false, false, "", "")); } } @@ -61,6 +64,18 @@ Future?> fetchKnownPeers(http.Client client) async { } } +Future fetchAvailableProxies(http.Client client) async { + try { + final response = await client.get(Uri.parse(serverAddress + ListAvailableProxiesPath)); + final Map parsed = jsonDecode(response.body); + + return ListAvailableProxiesResponse.fromJson(parsed); + } catch (e) { + print("error in fetchAvailableProxies: '${e.toString()}'."); + return null; + } +} + Future?> fetchDebugInfo(http.Client client) async { final response = await client.get(Uri.parse(serverAddress + GetP2pDebugInfoPath)); // final parsed = await compute(jsonDecode, response.body); @@ -175,6 +190,25 @@ Future updateMySettings(http.Client client, String name) async { return ""; } +Future updateProxySettings(http.Client client, String usingPeerID) async { + var payload = { + "UsingPeerID": usingPeerID, + }; + + var request = http.Request("POST", Uri.parse(serverAddress + UpdateProxySettingsPath)); + request.headers.addAll({"Content-Type": "application/json"}); + request.body = jsonEncode(payload); + + final response = await client.send(request); + var responseBody = await response.stream.bytesToString(); + if (response.statusCode != 200) { + final Map parsed = jsonDecode(responseBody); + return ApiError.fromJson(parsed).error; + } + + return ""; +} + Future removePeer(http.Client client, String peerID) async { var payload = PeerIDRequest(peerID); diff --git a/lib/common.dart b/lib/common.dart index a0c0cec..18cb86a 100644 --- a/lib/common.dart +++ b/lib/common.dart @@ -153,3 +153,10 @@ String byteCountIEC(int b) { return "${format(val)} ${"KMGTPE"[exp]}iB"; } + +String formatBoolWithEmoji(bool val) { + if (val) { + return "✅"; + } + return "❌"; +} \ No newline at end of file diff --git a/lib/data_service.dart b/lib/data_service.dart index a937afc..c3a8c1b 100644 --- a/lib/data_service.dart +++ b/lib/data_service.dart @@ -80,10 +80,15 @@ var knownPeersDataService = ServerDataService?>(() { return fetchKnownPeers(http.Client()); }); +var availableProxiesDataService = ServerDataService(() { + return fetchAvailableProxies(http.Client()); +}); + Future fetchAllData() async { var futures = [ myPeerInfoDataService.fetchData(), knownPeersDataService.fetchData(), + availableProxiesDataService.fetchData(), ]; await Future.wait(futures); } diff --git a/lib/entities.dart b/lib/entities.dart index 12adc9d..d930404 100644 --- a/lib/entities.dart +++ b/lib/entities.dart @@ -13,12 +13,19 @@ class KnownPeer { final bool connected; final bool confirmed; final bool declined; + final bool weAllowUsingAsExitNode; + final bool allowedUsingAsExitNode; final DateTime lastSeen; final List connections; final NetworkStats networkStats; KnownPeer(this.peerID, this.displayName, this.version, this.ipAddr, this.connected, this.confirmed, this.lastSeen, - this.connections, this.networkStats, this.domainName, this.declined); + this.connections, + this.networkStats, + this.domainName, + this.declined, + this.weAllowUsingAsExitNode, + this.allowedUsingAsExitNode); factory KnownPeer.fromJson(Map json) => _$KnownPeerFromJson(json); @@ -63,9 +70,11 @@ class MyPeerInfo { final String reachability; final String awlDNSAddress; final bool isAwlDNSSetAsSystem; + @JsonKey(name: "SOCKS5") + final SOCKS5Info socks5; MyPeerInfo(this.peerID, this.name, this.uptime, this.serverVersion, this.networkStats, this.totalBootstrapPeers, - this.connectedBootstrapPeers, this.reachability, this.awlDNSAddress, this.isAwlDNSSetAsSystem); + this.connectedBootstrapPeers, this.reachability, this.awlDNSAddress, this.isAwlDNSSetAsSystem, this.socks5); factory MyPeerInfo.fromJson(Map json) => _$MyPeerInfoFromJson(json); @@ -76,6 +85,21 @@ class MyPeerInfo { static int? _durationToNanoseconds(Duration? duration) => duration == null ? null : duration.inMilliseconds * 1000; } +@JsonSerializable(fieldRename: FieldRename.pascal) +class SOCKS5Info { + final String listenAddress; + final bool proxyingEnabled; + final bool listenerEnabled; + final String usingPeerID; + final String usingPeerName; + + SOCKS5Info(this.listenAddress, this.proxyingEnabled, this.listenerEnabled, this.usingPeerID, this.usingPeerName); + + factory SOCKS5Info.fromJson(Map json) => _$SOCKS5InfoFromJson(json); + + Map toJson() => _$SOCKS5InfoToJson(this); +} + @JsonSerializable(fieldRename: FieldRename.pascal) class NetworkStats { final int totalIn; @@ -110,6 +134,29 @@ class FriendRequest { Map toJson() => _$FriendRequestToJson(this); } +@JsonSerializable(fieldRename: FieldRename.pascal) +class ListAvailableProxiesResponse { + final List proxies; + + ListAvailableProxiesResponse(this.proxies); + + factory ListAvailableProxiesResponse.fromJson(Map json) => _$ListAvailableProxiesResponseFromJson(json); + + Map toJson() => _$ListAvailableProxiesResponseToJson(this); +} + +@JsonSerializable(fieldRename: FieldRename.pascal) +class AvailableProxy { + final String peerID; + final String peerName; + + AvailableProxy(this.peerID, this.peerName); + + factory AvailableProxy.fromJson(Map json) => _$AvailableProxyFromJson(json); + + Map toJson() => _$AvailableProxyToJson(this); +} + @JsonSerializable(fieldRename: FieldRename.pascal) class FriendRequestReply { final String peerID; @@ -164,8 +211,9 @@ class KnownPeerConfig { final String alias; final String ipAddr; final String domainName; + final bool weAllowUsingAsExitNode; - KnownPeerConfig(this.peerId, this.name, this.alias, this.ipAddr, this.domainName); + KnownPeerConfig(this.peerId, this.name, this.alias, this.ipAddr, this.domainName, this.weAllowUsingAsExitNode); factory KnownPeerConfig.fromJson(Map json) => _$KnownPeerConfigFromJson(json); @@ -177,8 +225,9 @@ class UpdateKnownPeerConfigRequest { final String peerID; final String alias; final String domainName; + final bool allowUsingAsExitNode; - UpdateKnownPeerConfigRequest(this.peerID, this.alias, this.domainName); + UpdateKnownPeerConfigRequest(this.peerID, this.alias, this.domainName, this.allowUsingAsExitNode); factory UpdateKnownPeerConfigRequest.fromJson(Map json) => _$UpdateKnownPeerConfigRequestFromJson(json); diff --git a/lib/entities.g.dart b/lib/entities.g.dart index 4a38ad4..11dd00c 100644 --- a/lib/entities.g.dart +++ b/lib/entities.g.dart @@ -18,6 +18,8 @@ KnownPeer _$KnownPeerFromJson(Map json) => KnownPeer( NetworkStats.fromJson(json['NetworkStats'] as Map), json['DomainName'] as String, json['Declined'] as bool, + json['WeAllowUsingAsExitNode'] as bool, + json['AllowedUsingAsExitNode'] as bool, ); Map _$KnownPeerToJson(KnownPeer instance) => { @@ -29,6 +31,8 @@ Map _$KnownPeerToJson(KnownPeer instance) => { 'Connected': instance.connected, 'Confirmed': instance.confirmed, 'Declined': instance.declined, + 'WeAllowUsingAsExitNode': instance.weAllowUsingAsExitNode, + 'AllowedUsingAsExitNode': instance.allowedUsingAsExitNode, 'LastSeen': instance.lastSeen.toIso8601String(), 'Connections': instance.connections, 'NetworkStats': instance.networkStats, @@ -61,6 +65,7 @@ MyPeerInfo _$MyPeerInfoFromJson(Map json) => MyPeerInfo( json['Reachability'] as String, json['AwlDNSAddress'] as String, json['IsAwlDNSSetAsSystem'] as bool, + SOCKS5Info.fromJson(json['SOCKS5'] as Map), ); Map _$MyPeerInfoToJson(MyPeerInfo instance) => { @@ -74,6 +79,23 @@ Map _$MyPeerInfoToJson(MyPeerInfo instance) => json) => SOCKS5Info( + json['ListenAddress'] as String, + json['ProxyingEnabled'] as bool, + json['ListenerEnabled'] as bool, + json['UsingPeerID'] as String, + json['UsingPeerName'] as String, + ); + +Map _$SOCKS5InfoToJson(SOCKS5Info instance) => { + 'ListenAddress': instance.listenAddress, + 'ProxyingEnabled': instance.proxyingEnabled, + 'ListenerEnabled': instance.listenerEnabled, + 'UsingPeerID': instance.usingPeerID, + 'UsingPeerName': instance.usingPeerName, }; NetworkStats _$NetworkStatsFromJson(Map json) => NetworkStats( @@ -100,6 +122,24 @@ Map _$FriendRequestToJson(FriendRequest instance) => json) => ListAvailableProxiesResponse( + (json['Proxies'] as List).map((e) => AvailableProxy.fromJson(e as Map)).toList(), + ); + +Map _$ListAvailableProxiesResponseToJson(ListAvailableProxiesResponse instance) => { + 'Proxies': instance.proxies, + }; + +AvailableProxy _$AvailableProxyFromJson(Map json) => AvailableProxy( + json['PeerID'] as String, + json['PeerName'] as String, + ); + +Map _$AvailableProxyToJson(AvailableProxy instance) => { + 'PeerID': instance.peerID, + 'PeerName': instance.peerName, + }; + FriendRequestReply _$FriendRequestReplyFromJson(Map json) => FriendRequestReply( json['PeerID'] as String, json['Alias'] as String, @@ -144,6 +184,7 @@ KnownPeerConfig _$KnownPeerConfigFromJson(Map json) => KnownPee json['alias'] as String, json['ipAddr'] as String, json['domainName'] as String, + json['weAllowUsingAsExitNode'] as bool, ); Map _$KnownPeerConfigToJson(KnownPeerConfig instance) => { @@ -152,19 +193,21 @@ Map _$KnownPeerConfigToJson(KnownPeerConfig instance) => json) => - UpdateKnownPeerConfigRequest( +UpdateKnownPeerConfigRequest _$UpdateKnownPeerConfigRequestFromJson(Map json) => UpdateKnownPeerConfigRequest( json['PeerID'] as String, json['Alias'] as String, json['DomainName'] as String, + json['AllowUsingAsExitNode'] as bool, ); Map _$UpdateKnownPeerConfigRequestToJson(UpdateKnownPeerConfigRequest instance) => { 'PeerID': instance.peerID, 'Alias': instance.alias, 'DomainName': instance.domainName, + 'AllowUsingAsExitNode': instance.allowUsingAsExitNode, }; BlockedPeer _$BlockedPeerFromJson(Map json) => BlockedPeer( diff --git a/lib/info_tab.dart b/lib/info_tab.dart index 1467254..a50ce27 100644 --- a/lib/info_tab.dart +++ b/lib/info_tab.dart @@ -120,6 +120,28 @@ class _MyInfoPageState extends State { dnsColor = redColor; } + String socks5Text; + Color socks5Color; + if (_peerInfo!.socks5.listenerEnabled && _peerInfo!.socks5.listenAddress != "") { + socks5Text = "working"; + socks5Color = greenColor; + } else { + socks5Text = "not working"; + socks5Color = redColor; + } + var socks5UsingPeer = _peerInfo!.socks5.usingPeerName; + if (socks5UsingPeer == "") { + socks5UsingPeer = "None"; + } + + List socks5PeersList = ['None']; + var proxiesData = availableProxiesDataService.getData(); + if (proxiesData != null) { + for (var proxy in proxiesData.proxies) { + socks5PeersList.add(proxy.peerName); + } + } + String bootstrapText = "${_peerInfo!.connectedBootstrapPeers}/${_peerInfo!.totalBootstrapPeers}"; Color bootstrapColor; if (_peerInfo!.connectedBootstrapPeers == 0) { @@ -131,17 +153,81 @@ class _MyInfoPageState extends State { } return [ - _buildBodyItem(Icons.cloud_download_outlined, "Download rate ", _peerInfo!.networkStats.inAsString()), - _buildBodyItem(Icons.cloud_upload_outlined, "Upload rate ", _peerInfo!.networkStats.outAsString()), - _buildBodyItem(Icons.devices, "Bootstrap peers", bootstrapText, textColor: bootstrapColor), - _buildBodyItem(Icons.dns_outlined, "DNS", dnsText, textColor: dnsColor), - _buildBodyItem(Icons.my_location, "Reachability", reachabilityText, textColor: reachabilityColor), - _buildBodyItem(Icons.access_time, "Uptime", formatDuration(_peerInfo!.uptime)), - _buildBodyItem(Icons.label_outlined, "Server version ", _peerInfo!.serverVersion), + // TODO: organize output in sections: general, vpn, proxy, etc + _buildBodyItemText(Icons.cloud_download_outlined, "Download rate ", _peerInfo!.networkStats.inAsString()), + _buildBodyItemText(Icons.cloud_upload_outlined, "Upload rate ", _peerInfo!.networkStats.outAsString()), + _buildBodyItemText(Icons.devices, "Bootstrap peers", bootstrapText, textColor: bootstrapColor), + _buildBodyItemText(Icons.dns_outlined, "DNS", dnsText, textColor: dnsColor), + + _buildBodyItemText(Icons.router_outlined, "SOCKS5 Proxy", socks5Text, textColor: socks5Color), + if (_peerInfo!.socks5.listenerEnabled) + _buildBodyItemText(Icons.router_outlined, "SOCKS5 Proxy address", "${_peerInfo!.socks5.listenAddress}"), + // TODO: move to separate func + _buildBodyItemWidget( + Icons.router_outlined, + "SOCKS5 Proxy exit peer", + DropdownButton( + value: socks5UsingPeer, + onChanged: (String? value) async { + var usingPeerName = value ?? ""; + var usingPeerID = ""; + if (usingPeerName == "None") { + usingPeerID = ""; + } else { + var proxiesData = availableProxiesDataService.getData(); + + var found = proxiesData!.proxies.firstWhere((element) => element.peerName == usingPeerName); + usingPeerID = found.peerID; + } + + var response = await updateProxySettings(http.Client(), usingPeerID); + if (response != "") { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + backgroundColor: Colors.red, + content: Text("Failed to update proxy settings: $response"), + )); + + return; + } + + var futures = [ + myPeerInfoDataService.fetchData(), + availableProxiesDataService.fetchData(), + ]; + await Future.wait(futures); + + setState(() { + // to trigger rebuild after fetching + }); + }, + onTap: () { + availableProxiesDataService.fetchData(); + }, + items: socks5PeersList.map>((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + )), + + _buildBodyItemText(Icons.my_location, "Reachability", reachabilityText, textColor: reachabilityColor), + _buildBodyItemText(Icons.access_time, "Uptime", formatDuration(_peerInfo!.uptime)), + _buildBodyItemText(Icons.label_outlined, "Server version ", _peerInfo!.serverVersion), ]; } - Widget _buildBodyItem(IconData icon, String label, String text, {Color? textColor}) { + Widget _buildBodyItemText(IconData icon, String label, String text, {Color? textColor}) { + return _buildBodyItemWidget( + icon, + label, + SelectableText( + text, + style: TextStyle(color: textColor), + )); + } + + Widget _buildBodyItemWidget(IconData icon, String label, Widget child) { return Padding( padding: EdgeInsets.symmetric(horizontal: 0, vertical: 7), child: Row( @@ -157,10 +243,7 @@ class _MyInfoPageState extends State { ), Flexible( fit: FlexFit.loose, - child: SelectableText( - text, - style: TextStyle(color: textColor), - ), + child: child, ) ], ), diff --git a/lib/peer_settings_screen.dart b/lib/peer_settings_screen.dart index 6c3e3ab..fe0d3e9 100644 --- a/lib/peer_settings_screen.dart +++ b/lib/peer_settings_screen.dart @@ -18,6 +18,7 @@ class KnownPeerSettingsScreen extends StatefulWidget { class _KnownPeerSettingsScreenState extends State { late TextEditingController _aliasTextController; late TextEditingController _domainNameTextController; + late bool _weAllowUsingAsExitNode; bool _hasPeerConfig = false; late String _peerID; @@ -34,6 +35,7 @@ class _KnownPeerSettingsScreenState extends State { _aliasTextController = TextEditingController(text: peerConfig.alias); _domainNameTextController = TextEditingController(text: peerConfig.domainName); + _weAllowUsingAsExitNode = peerConfig.weAllowUsingAsExitNode; setState(() { _peerConfig = peerConfig; @@ -42,7 +44,8 @@ class _KnownPeerSettingsScreenState extends State { } Future _sendNewPeerConfig() async { - var payload = UpdateKnownPeerConfigRequest(_peerConfig.peerId, _aliasTextController.text, _domainNameTextController.text); + var payload = UpdateKnownPeerConfigRequest( + _peerConfig.peerId, _aliasTextController.text, _domainNameTextController.text, _weAllowUsingAsExitNode); var response = await updateKnownPeerConfig(http.Client(), payload); return response; @@ -178,6 +181,32 @@ class _KnownPeerSettingsScreenState extends State { ), ), ), + Padding( + padding: EdgeInsets.all(8.0), + child: FormField( + initialValue: false, + builder: (FormFieldState state) { + return CheckboxListTile( + title: const Text("Allow to use my device as exit node", textAlign: TextAlign.left), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + secondary: Padding( + padding: const EdgeInsets.all(8.0), + child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + message: + 'This allows peer to pass their traffic through your network via SOCKS5 proxy, for instance peer will have access to your local WiFi network', + child: Icon(Icons.info), + ), + ), + value: _weAllowUsingAsExitNode, + onChanged: (val) { + setState(() { + _weAllowUsingAsExitNode = val!; + }); + }); + }), + ) ], ), ); diff --git a/lib/peers_list_tab.dart b/lib/peers_list_tab.dart index e194054..02d717f 100644 --- a/lib/peers_list_tab.dart +++ b/lib/peers_list_tab.dart @@ -140,6 +140,8 @@ class _PeersListPageState extends State { if (item.connections.isNotEmpty) _buildBodyItem(Icons.place_outlined, "Connection", item.connections.join('\n\n')), if (item.version.isNotEmpty) _buildBodyItem(Icons.label_outlined, "Version", item.version), + _buildBodyItem(Icons.router_outlined, "Exit node", + "We allow: ${formatBoolWithEmoji(item.weAllowUsingAsExitNode)} Peer allowed us: ${formatBoolWithEmoji(item.allowedUsingAsExitNode)}"), if (item.networkStats.totalIn != 0) _buildBodyItem(Icons.cloud_download_outlined, "Download rate", item.networkStats.inAsString()), if (item.networkStats.totalOut != 0)