From 8575312a9bcd73fec91490caf3d867cdd4cda20c Mon Sep 17 00:00:00 2001 From: Afshin Arani Date: Sun, 31 Mar 2024 17:27:57 +0300 Subject: [PATCH] Proxy,Tests: add TorProxy (#11) This commit adds a helper class that provides an HTTP proxy for easy usage of NOnion alongside things like HttpClient that don't support communicating over custom streams. --- NOnion.Tests/TorProxyTests.cs | 115 +++++++++++ NOnion/NOnion.fsproj | 1 + NOnion/Network/TorCircuit.fs | 7 + NOnion/Proxy/TorProxy.fs | 246 ++++++++++++++++++++++++ NOnion/Utility/HiddenServicesUtility.fs | 40 ++-- 5 files changed, 392 insertions(+), 17 deletions(-) create mode 100644 NOnion.Tests/TorProxyTests.cs create mode 100644 NOnion/Proxy/TorProxy.fs diff --git a/NOnion.Tests/TorProxyTests.cs b/NOnion.Tests/TorProxyTests.cs new file mode 100644 index 00000000..9c75bd34 --- /dev/null +++ b/NOnion.Tests/TorProxyTests.cs @@ -0,0 +1,115 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +using Newtonsoft.Json; +using NUnit.Framework; + +using NOnion.Proxy; + +namespace NOnion.Tests +{ + internal class TorProxyTests + { + private const int MaximumRetry = 3; + + private class TorProjectCheckResult + { + [JsonProperty("IsTor")] + internal bool IsTor { get; set; } + + [JsonProperty("IP")] + internal string IP { get; set; } + } + + [Test] + [Retry(MaximumRetry)] + public void CanProxyTorProjectExitNodeCheck() + { + Assert.DoesNotThrowAsync(ProxyTorProjectExitNodeCheck); + } + + private async Task ProxyTorProjectExitNodeCheck() + { + using (await TorProxy.StartAsync(IPAddress.Loopback, 20000)) + { + var handler = new HttpClientHandler + { + Proxy = new WebProxy("http://localhost:20000") + }; + + var client = new HttpClient(handler); + var resultStr = await client.GetStringAsync("https://check.torproject.org/api/ip"); + var result = JsonConvert.DeserializeObject(resultStr); + Assert.IsTrue(result.IsTor); + } + } + + [Test] + [Retry(MaximumRetry)] + public void CanProxyHttps() + { + Assert.DoesNotThrowAsync(ProxyHttps); + } + + private async Task ProxyHttps() + { + using (await TorProxy.StartAsync(IPAddress.Loopback, 20000)) + { + var handler = new HttpClientHandler + { + Proxy = new WebProxy("http://localhost:20000") + }; + + var client = new HttpClient(handler); + var googleResponse = await client.GetAsync("https://google.com"); + Assert.That(googleResponse.StatusCode > 0); + } + } + + [Test] + [Retry(MaximumRetry)] + public void CanProxyHttp() + { + Assert.DoesNotThrowAsync(ProxyHttp); + } + + private async Task ProxyHttp() + { + using (await TorProxy.StartAsync(IPAddress.Loopback, 20000)) + { + var handler = new HttpClientHandler + { + Proxy = new WebProxy("http://localhost:20000") + }; + + var client = new HttpClient(handler); + var googleResponse = await client.GetAsync("http://google.com/search?q=Http+Test"); + Assert.That(googleResponse.StatusCode > 0); + } + } + + [Test] + [Retry(MaximumRetry)] + public void CanProxyHiddenService() + { + Assert.DoesNotThrowAsync(ProxyHiddenService); + } + + private async Task ProxyHiddenService() + { + using (await TorProxy.StartAsync(IPAddress.Loopback, 20000)) + { + var handler = new HttpClientHandler + { + Proxy = new WebProxy("http://localhost:20000"), + ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true + }; + + var client = new HttpClient(handler); + var facebookResponse = await client.GetAsync("https://facebookwkhpilnemxj7asaniu7vnjjbiltxjqhye3mhbshg7kx5tfyd.onion"); + Assert.That(facebookResponse.StatusCode > 0); + } + } + } +} diff --git a/NOnion/NOnion.fsproj b/NOnion/NOnion.fsproj index c5e3b282..f93ff533 100644 --- a/NOnion/NOnion.fsproj +++ b/NOnion/NOnion.fsproj @@ -89,6 +89,7 @@ + diff --git a/NOnion/Network/TorCircuit.fs b/NOnion/Network/TorCircuit.fs index 39edec50..2c72bb75 100644 --- a/NOnion/Network/TorCircuit.fs +++ b/NOnion/Network/TorCircuit.fs @@ -1072,6 +1072,13 @@ and TorCircuit failwith "Should not happen: can't get circuitId for non-initialized circuit." + member __.IsActive = + match circuitState with + | Ready _ + | ReadyAsIntroductionPoint _ + | ReadyAsRendezvousPoint _ -> true + | _ -> false + member __.GetLastNode() = async { let! lastNodeResult = diff --git a/NOnion/Proxy/TorProxy.fs b/NOnion/Proxy/TorProxy.fs new file mode 100644 index 00000000..28c7dc74 --- /dev/null +++ b/NOnion/Proxy/TorProxy.fs @@ -0,0 +1,246 @@ +namespace NOnion.Proxy + +open FSharpx.Collections +open System +open System.IO +open System.Net +open System.Net.Sockets +open System.Text +open System.Threading + +open NOnion +open NOnion.Client +open NOnion.Network +open NOnion.Services + +type TorProxy private (listener: TcpListener, torClient: TorClient) = + let mutable lastActiveCircuitOpt: Option = None + + let handleConnection(client: TcpClient) = + async { + let! cancelToken = Async.CancellationToken + cancelToken.ThrowIfCancellationRequested() + + let stream = client.GetStream() + + let readHeaders() = + async { + let stringBuilder = StringBuilder() + // minimum request 16 bytes: GET / HTTP/1.1\r\n\r\n + let preReadLen = 18 + let! buffer = stream.AsyncRead preReadLen + + buffer + |> Encoding.ASCII.GetString + |> stringBuilder.Append + |> ignore + + let rec innerReadRest() = + async { + if stringBuilder.ToString().EndsWith("\r\n\r\n") then + return () + else + let! newByte = stream.AsyncRead 1 + + newByte + |> Encoding.ASCII.GetString + |> stringBuilder.Append + |> ignore + + return! innerReadRest() + } + + do! innerReadRest() + + return stringBuilder.ToString() + } + + let! headers = readHeaders() + + let headerLines = + headers.Split( + [| "\r\n" |], + StringSplitOptions.RemoveEmptyEntries + ) + + match Seq.tryHeadTail headerLines with + | Some(firstLine, restOfHeaders) -> + let firstLineParts = firstLine.Split(' ') + + let method = firstLineParts.[0] + let url = firstLineParts.[1] + let protocolVersion = firstLineParts.[2] + + if protocolVersion <> "HTTP/1.1" then + return failwith "TorProxy: protocol version mismatch" + + let rec copySourceToDestination + (source: Stream) + (dest: Stream) + = + async { + do! source.CopyToAsync dest |> Async.AwaitTask + + // CopyToAsync returns when source is closed so we can close dest + dest.Close() + } + + let createStreamToDestination(parsedUrl: Uri) = + async { + if parsedUrl.DnsSafeHost.EndsWith(".onion") then + let! client = + TorServiceClient.Connect + torClient + (sprintf + "%s:%i" + parsedUrl.DnsSafeHost + parsedUrl.Port) + + return! client.GetStream() + else + let! circuit = + match lastActiveCircuitOpt with + | Some lastActiveCircuit when + lastActiveCircuit.IsActive + -> + async { + TorLogger.Log + "TorProxy: we had active circuit, no need to recreate" + + return lastActiveCircuit + } + | _ -> + async { + TorLogger.Log + "TorProxy: we didn't have an active circuit, recreating..." + + let! circuit = + torClient.AsyncCreateCircuit + 3 + CircuitPurpose.Exit + None + + lastActiveCircuitOpt <- Some circuit + return circuit + } + + let torStream = new TorStream(circuit) + + do! + torStream.ConnectToOutside + parsedUrl.DnsSafeHost + parsedUrl.Port + |> Async.Ignore + + return torStream + } + + if method <> "CONNECT" then + let parsedUrl = Uri url + + use! torStream = createStreamToDestination parsedUrl + + let firstLineToRetransmit = + sprintf + "%s %s HTTP/1.1\r\n" + method + parsedUrl.PathAndQuery + + let headersToForwardLines = + restOfHeaders + |> Seq.filter(fun header -> + not(header.StartsWith "Proxy-") + ) + |> Seq.map(fun header -> sprintf "%s\r\n" header) + + let headersToForward = + String.Join(String.Empty, headersToForwardLines) + + do! + Encoding.ASCII.GetBytes firstLineToRetransmit + |> torStream.AsyncWrite + + do! + Encoding.ASCII.GetBytes headersToForward + |> torStream.AsyncWrite + + do! Encoding.ASCII.GetBytes "\r\n" |> torStream.AsyncWrite + + return! + [ + copySourceToDestination torStream stream + copySourceToDestination stream torStream + ] + |> Async.Parallel + |> Async.Ignore + else + let parsedUrl = Uri <| sprintf "http://%s" url + + use! torStream = createStreamToDestination parsedUrl + + let connectResponse = + "HTTP/1.1 200 Connection Established\r\nConnection: close\r\n\r\n" + + do! + Encoding.ASCII.GetBytes connectResponse + |> stream.AsyncWrite + + return! + [ + copySourceToDestination torStream stream + copySourceToDestination stream torStream + ] + |> Async.Parallel + |> Async.Ignore + | None -> + return failwith "TorProxy: incomplete http header detected" + + } + + let rec acceptConnections() = + async { + let! cancelToken = Async.CancellationToken + cancelToken.ThrowIfCancellationRequested() + + let! client = listener.AcceptTcpClientAsync() |> Async.AwaitTask + + Async.Start(handleConnection client, cancelToken) + + return! acceptConnections() + } + + let shutdownToken = new CancellationTokenSource() + + static member Start (localAddress: IPAddress) (port: int) = + async { + let! client = TorClient.AsyncBootstrapWithEmbeddedList None + let listener = TcpListener(localAddress, port) + let proxy = new TorProxy(listener, client) + proxy.StartListening() + return proxy + } + + static member StartAsync(localAddress: IPAddress, port: int) = + TorProxy.Start localAddress port |> Async.StartAsTask + + member private self.StartListening() = + listener.Start() + + Async.Start(acceptConnections(), shutdownToken.Token) + + member __.GetNewIdentity() = + async { + let! newCircuit = + torClient.AsyncCreateCircuit 3 CircuitPurpose.Exit None + + lastActiveCircuitOpt <- Some newCircuit + } + + member self.GetNewIdentityAsync() = + self.GetNewIdentity() |> Async.StartAsTask + + interface IDisposable with + member __.Dispose() = + shutdownToken.Cancel() + listener.Stop() + (torClient :> IDisposable).Dispose() diff --git a/NOnion/Utility/HiddenServicesUtility.fs b/NOnion/Utility/HiddenServicesUtility.fs index e729f32c..1281e1e0 100644 --- a/NOnion/Utility/HiddenServicesUtility.fs +++ b/NOnion/Utility/HiddenServicesUtility.fs @@ -110,20 +110,26 @@ module HiddenServicesUtility = //Add a fake protocol let parsedUrl = Uri(sprintf "http://%s" url) - //Remove .onion suffix and decode - let keyBytesOpt = - parsedUrl.DnsSafeHost.Split '.' - |> Seq.tryHead - |> Option.map Base32Util.DecodeBase32 - - // PublicKey (32 bytes) + Checksum (2 bytes) + Version (1 byte) - let expectedOnionUrlLength = - Constants.HiddenServices.OnionUrl.PublicKeyLength - + Constants.HiddenServices.OnionUrl.ChecksumLength - + 1 - - match keyBytesOpt with - | Some keyBytes when keyBytes.Length = expectedOnionUrlLength -> - keyBytes.[0 .. Constants.HiddenServices.OnionUrl.PublicKeyLength - 1], - parsedUrl.Port - | _ -> failwith "Invalid onion service url" + let urlParts = parsedUrl.DnsSafeHost.Split '.' + + if urlParts.Length < 2 then + failwith "Invalid onion service url" + else + //Remove subdomains and .onion suffix and decode + let keyBytesOpt = + urlParts + |> Seq.tryItem(urlParts.Length - 2) + |> Option.map Base32Util.DecodeBase32 + + // PublicKey (32 bytes) + Checksum (2 bytes) + Version (1 byte) + let expectedOnionUrlLength = + Constants.HiddenServices.OnionUrl.PublicKeyLength + + Constants.HiddenServices.OnionUrl.ChecksumLength + + 1 + + match keyBytesOpt with + | Some keyBytes when keyBytes.Length = expectedOnionUrlLength -> + keyBytes.[0 .. Constants.HiddenServices.OnionUrl.PublicKeyLength + - 1], + parsedUrl.Port + | _ -> failwith "Unable to decode onion service url"