From a7d106b5938975ee564e026f2bd640dbd5d40263 Mon Sep 17 00:00:00 2001 From: Cristian Pufu Date: Wed, 7 Aug 2024 13:50:59 +0300 Subject: [PATCH] Add TCP implementation --- README.md | 35 +- WebSocketTunnel.sln | 25 +- docs/tcp_tunneling.png | Bin 0 -> 52496 bytes docs/tcp_tunneling.puml | 24 ++ docs/tcp_tunneling_global.png | Bin 0 -> 45636 bytes docs/tcp_tunneling_global.puml | 23 ++ .../HttpContentCallback.cs | 25 -- .../HttpTunnel/HttpConnection.cs | 10 + .../HttpTunnel/HttpTunnelClient.cs | 203 +++++++++++ .../HttpTunnel/HttpTunnelRequest.cs | 10 + .../HttpTunnel/HttpTunnelResponse.cs | 10 + src/WebSocketTunnel.Client/Program.cs | 292 +++------------- src/WebSocketTunnel.Client/RequestMetadata.cs | 18 - .../ResponseMetadata.cs | 16 - .../TcpTunnel/TcpConnection.cs | 7 + .../TcpTunnel/TcpTunnelClient.cs | 218 ++++++++++++ .../TcpTunnel/TcpTunnelRequest.cs | 12 + .../TcpTunnel/TcpTunnelResponse.cs | 10 + src/WebSocketTunnel.Client/Tunnel.cs | 10 - src/WebSocketTunnel.Client/TunnelResponse.cs | 11 - .../WebSocketTunnel.Client.csproj | 2 +- src/WebSocketTunnel.Server/Extensions.cs | 46 +++ .../HttpTunnel/HttpAppExtensions.cs | 317 ++++++++++++++++++ .../HttpTunnel/HttpConnection.cs | 13 + .../HttpTunnel/HttpDefferedRequest.cs | 33 ++ .../HttpTunnel/HttpRequestsQueue.cs | 87 +++++ .../HttpTunnel/HttpTunnelHub.cs | 36 ++ .../HttpTunnel/HttpTunnelStore.cs | 24 ++ src/WebSocketTunnel.Server/Program.cs | 294 +--------------- .../Request/DefferedRequest.cs | 34 -- .../Request/IRequestsQueue.cs | 11 - .../Request/RequestMetadata.cs | 18 - .../Request/RequestsQueue.cs | 90 ----- .../Request/ResponseMetadata.cs | 16 - .../Request/ResponseText.cs | 17 - .../TcpTunnel/TcpAppExtensions.cs | 9 + .../TcpTunnel/TcpClientStore.cs | 98 ++++++ .../TcpTunnel/TcpConnection.cs | 7 + .../TcpTunnel/TcpTunnelHub.cs | 212 ++++++++++++ .../TcpTunnel/TcpTunnelStore.cs | 27 ++ .../Tunnel/DnsBuilder.cs | 13 - .../Tunnel/TunnelHub.cs | 132 -------- .../Tunnel/TunnelStore.cs | 25 -- test/Test.TcpClient/Program.cs | 66 ++++ test/Test.TcpClient/Test.TcpClient.csproj | 10 + test/Test.TcpForwarder/Program.cs | 65 ++++ .../Test.TcpForwarder.csproj | 10 + test/Test.TcpServer/Program.cs | 84 +++++ test/Test.TcpServer/Test.TcpServer.csproj | 10 + 49 files changed, 1800 insertions(+), 965 deletions(-) create mode 100644 docs/tcp_tunneling.png create mode 100644 docs/tcp_tunneling.puml create mode 100644 docs/tcp_tunneling_global.png create mode 100644 docs/tcp_tunneling_global.puml delete mode 100644 src/WebSocketTunnel.Client/HttpContentCallback.cs create mode 100644 src/WebSocketTunnel.Client/HttpTunnel/HttpConnection.cs create mode 100644 src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelClient.cs create mode 100644 src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelRequest.cs create mode 100644 src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelResponse.cs delete mode 100644 src/WebSocketTunnel.Client/RequestMetadata.cs delete mode 100644 src/WebSocketTunnel.Client/ResponseMetadata.cs create mode 100644 src/WebSocketTunnel.Client/TcpTunnel/TcpConnection.cs create mode 100644 src/WebSocketTunnel.Client/TcpTunnel/TcpTunnelClient.cs create mode 100644 src/WebSocketTunnel.Client/TcpTunnel/TcpTunnelRequest.cs create mode 100644 src/WebSocketTunnel.Client/TcpTunnel/TcpTunnelResponse.cs delete mode 100644 src/WebSocketTunnel.Client/Tunnel.cs delete mode 100644 src/WebSocketTunnel.Client/TunnelResponse.cs create mode 100644 src/WebSocketTunnel.Server/Extensions.cs create mode 100644 src/WebSocketTunnel.Server/HttpTunnel/HttpAppExtensions.cs create mode 100644 src/WebSocketTunnel.Server/HttpTunnel/HttpConnection.cs create mode 100644 src/WebSocketTunnel.Server/HttpTunnel/HttpDefferedRequest.cs create mode 100644 src/WebSocketTunnel.Server/HttpTunnel/HttpRequestsQueue.cs create mode 100644 src/WebSocketTunnel.Server/HttpTunnel/HttpTunnelHub.cs create mode 100644 src/WebSocketTunnel.Server/HttpTunnel/HttpTunnelStore.cs delete mode 100644 src/WebSocketTunnel.Server/Request/DefferedRequest.cs delete mode 100644 src/WebSocketTunnel.Server/Request/IRequestsQueue.cs delete mode 100644 src/WebSocketTunnel.Server/Request/RequestMetadata.cs delete mode 100644 src/WebSocketTunnel.Server/Request/RequestsQueue.cs delete mode 100644 src/WebSocketTunnel.Server/Request/ResponseMetadata.cs delete mode 100644 src/WebSocketTunnel.Server/Request/ResponseText.cs create mode 100644 src/WebSocketTunnel.Server/TcpTunnel/TcpAppExtensions.cs create mode 100644 src/WebSocketTunnel.Server/TcpTunnel/TcpClientStore.cs create mode 100644 src/WebSocketTunnel.Server/TcpTunnel/TcpConnection.cs create mode 100644 src/WebSocketTunnel.Server/TcpTunnel/TcpTunnelHub.cs create mode 100644 src/WebSocketTunnel.Server/TcpTunnel/TcpTunnelStore.cs delete mode 100644 src/WebSocketTunnel.Server/Tunnel/DnsBuilder.cs delete mode 100644 src/WebSocketTunnel.Server/Tunnel/TunnelHub.cs delete mode 100644 src/WebSocketTunnel.Server/Tunnel/TunnelStore.cs create mode 100644 test/Test.TcpClient/Program.cs create mode 100644 test/Test.TcpClient/Test.TcpClient.csproj create mode 100644 test/Test.TcpForwarder/Program.cs create mode 100644 test/Test.TcpForwarder/Test.TcpForwarder.csproj create mode 100644 test/Test.TcpServer/Program.cs create mode 100644 test/Test.TcpServer/Test.TcpServer.csproj diff --git a/README.md b/README.md index 20ca8ba..996d769 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ Tunnelite is a .NET tool that allows you to create a secure tunnel from a public URL to your local application running on your machine. +## Use Cases + +- Exposing locally-hosted web applications to the internet for testing or demo purposes. +- Quickly sharing dev builds during hackathons. +- Testing and debugging webhook integrations. +- Providing internet access to services running behind firewalls without exposing incoming ports. + ## Installation To install Tunnelite as a global tool, use the following command: @@ -23,6 +30,32 @@ This command returns a public URL with an auto-generated subdomain, such as `htt Tunnelite works by establishing a websocket connection to the public server and streaming all incoming data to your local application, effectively forwarding requests from the public URL to your local server. -
+
+
+ HTTP Connection + +
![image info](https://github.com/cristipufu/ws-tunnel-signalr/blob/master/docs/http_tunneling.png) + +
+ +
+ TCP Overview + +
+ +![image info](https://github.com/cristipufu/ws-tunnel-signalr/blob/master/docs/tcp_tunneling_global.png) + +
+ +
+ TCP Connection + +
+ +![image info](https://github.com/cristipufu/ws-tunnel-signalr/blob/master/docs/tcp_tunneling.png) + +
+ + diff --git a/WebSocketTunnel.sln b/WebSocketTunnel.sln index 2466b48..97e6243 100644 --- a/WebSocketTunnel.sln +++ b/WebSocketTunnel.sln @@ -3,12 +3,20 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.10.35013.160 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebSocketTunnel.Server", "src\WebSocketTunnel.Server\WebSocketTunnel.Server.csproj", "{F4F5BED8-351A-4F10-B82C-F79190CF1034}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebSocketTunnel.Server", "src\WebSocketTunnel.Server\WebSocketTunnel.Server.csproj", "{F4F5BED8-351A-4F10-B82C-F79190CF1034}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebSocketTunnel.Client", "src\WebSocketTunnel.Client\WebSocketTunnel.Client.csproj", "{959412E1-F444-4834-B457-47452236C9B3}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{AFD8CF9B-F8B9-47C9-BEAB-FC0161D2AFB8}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{230DD554-CD20-4A77-874B-E9BA59A2DC18}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.TcpClient", "test\Test.TcpClient\Test.TcpClient.csproj", "{6C9A59E4-2278-4CCA-8C88-59B183EBE9C1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.TcpServer", "test\Test.TcpServer\Test.TcpServer.csproj", "{9E529D00-D5E7-4E2E-80AD-83B793B77DF8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.TcpForwarder", "test\Test.TcpForwarder\Test.TcpForwarder.csproj", "{BCC3AD29-688B-4482-B952-F0095D104020}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -23,6 +31,18 @@ Global {959412E1-F444-4834-B457-47452236C9B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {959412E1-F444-4834-B457-47452236C9B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {959412E1-F444-4834-B457-47452236C9B3}.Release|Any CPU.Build.0 = Release|Any CPU + {6C9A59E4-2278-4CCA-8C88-59B183EBE9C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C9A59E4-2278-4CCA-8C88-59B183EBE9C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C9A59E4-2278-4CCA-8C88-59B183EBE9C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C9A59E4-2278-4CCA-8C88-59B183EBE9C1}.Release|Any CPU.Build.0 = Release|Any CPU + {9E529D00-D5E7-4E2E-80AD-83B793B77DF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E529D00-D5E7-4E2E-80AD-83B793B77DF8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E529D00-D5E7-4E2E-80AD-83B793B77DF8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E529D00-D5E7-4E2E-80AD-83B793B77DF8}.Release|Any CPU.Build.0 = Release|Any CPU + {BCC3AD29-688B-4482-B952-F0095D104020}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCC3AD29-688B-4482-B952-F0095D104020}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCC3AD29-688B-4482-B952-F0095D104020}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCC3AD29-688B-4482-B952-F0095D104020}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -30,6 +50,9 @@ Global GlobalSection(NestedProjects) = preSolution {F4F5BED8-351A-4F10-B82C-F79190CF1034} = {AFD8CF9B-F8B9-47C9-BEAB-FC0161D2AFB8} {959412E1-F444-4834-B457-47452236C9B3} = {AFD8CF9B-F8B9-47C9-BEAB-FC0161D2AFB8} + {6C9A59E4-2278-4CCA-8C88-59B183EBE9C1} = {230DD554-CD20-4A77-874B-E9BA59A2DC18} + {9E529D00-D5E7-4E2E-80AD-83B793B77DF8} = {230DD554-CD20-4A77-874B-E9BA59A2DC18} + {BCC3AD29-688B-4482-B952-F0095D104020} = {230DD554-CD20-4A77-874B-E9BA59A2DC18} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E385A6C3-BECA-4888-B12B-31AA9984F86F} diff --git a/docs/tcp_tunneling.png b/docs/tcp_tunneling.png new file mode 100644 index 0000000000000000000000000000000000000000..da8bd4cbc82997733df39ca3121a4abad5fa074a GIT binary patch literal 52496 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 PTS: 1. TCP Connection +PTS -> AS: 2. Notify Client (NewTcpConnection) +AS --> TC: 3. WSS Notification (contains connection details) +TC -> IS: 4. Open TCP Connection +TC -> PTS: 5. Start StreamIncomingAsync +PTS --> TC: 6. Stream TCP Data (incoming) +TC -> IS: 7. Forward Incoming Data +IS --> TC: 8. Send Outgoing Data +TC -> PTS: 9. StreamOutgoingAsync (with outgoing data) +PTS --> EC: 10. Forward Outgoing Data + +note right of PTS + Steps 5-6 and 9-10 use + bi-directional streaming + over SignalR +end note +@enduml \ No newline at end of file diff --git a/docs/tcp_tunneling_global.png b/docs/tcp_tunneling_global.png new file mode 100644 index 0000000000000000000000000000000000000000..522326012fa7729de24f996552c1e20ea9da2f9d GIT binary patch literal 45636 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� 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 literal 0 HcmV?d00001 diff --git a/docs/tcp_tunneling_global.puml b/docs/tcp_tunneling_global.puml new file mode 100644 index 0000000..29a1ff4 --- /dev/null +++ b/docs/tcp_tunneling_global.puml @@ -0,0 +1,23 @@ +@startuml +actor "Tunneling Client" as TC +box "Public Tunneling Server" #LightBlue + participant "WebSocket Handler" as WS + participant "TCP Listener" as TL +end box +actor "External Client" as EC + +TC -> WS: Connect via WebSocket +TC -> WS: Register tunnel request +WS -> TL: Start listening on random port +TL --> WS: Port number +WS --> TC: Tunnel registered (port number) + +... Some time later ... + +EC -> TL: Connect to generated port +TL -> WS: New TCP connection +WS --> TC: Notify of new TCP connection +TC -> WS: Begin tunneling data +WS <-> TC: Bi-directional data transfer +TL <-> EC: Bi-directional data transfer +@enduml \ No newline at end of file diff --git a/src/WebSocketTunnel.Client/HttpContentCallback.cs b/src/WebSocketTunnel.Client/HttpContentCallback.cs deleted file mode 100644 index 922912d..0000000 --- a/src/WebSocketTunnel.Client/HttpContentCallback.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Net; - -namespace WebSocketTunnel.Client -{ - public class HttpContentCallback(Func callback) : HttpContent - { - private readonly Func _callback = callback; - - protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) - { - return SerializeToStreamAsync(stream, context, CancellationToken.None); - } - - protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken token) - { - return _callback(stream, token); - } - - protected override bool TryComputeLength(out long length) - { - length = 0; - return false; - } - } -} diff --git a/src/WebSocketTunnel.Client/HttpTunnel/HttpConnection.cs b/src/WebSocketTunnel.Client/HttpTunnel/HttpConnection.cs new file mode 100644 index 0000000..12cd9e7 --- /dev/null +++ b/src/WebSocketTunnel.Client/HttpTunnel/HttpConnection.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace WebSocketTunnel.Client.HttpTunnel; + +public class HttpConnection +{ + public Guid RequestId { get; set; } + public string Method { get; set; } + public string ContentType { get; set; } + public string Path { get; set; } +} diff --git a/src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelClient.cs b/src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelClient.cs new file mode 100644 index 0000000..84000e8 --- /dev/null +++ b/src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelClient.cs @@ -0,0 +1,203 @@ +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Net.Http.Headers; +using System.Net.Http.Json; + +namespace WebSocketTunnel.Client.HttpTunnel; + +public class HttpTunnelClient +{ + private static readonly HttpClientHandler LocalHttpClientHandler = new() + { + ServerCertificateCustomValidationCallback = (message, cert, chain, sslPolicyErrors) => true, + }; + private static readonly HttpClient ServerHttpClient = new(); + private static readonly HttpClient LocalHttpClient = new(LocalHttpClientHandler); + + private HttpTunnelResponse? _currentTunnel = null; + private readonly HubConnection Connection; + private readonly HttpTunnelRequest Tunnel; + + public HttpTunnelClient(HttpTunnelRequest tunnel, LogLevel logLevel) + { + Tunnel = tunnel; + + Connection = new HubConnectionBuilder() + .WithUrl($"{tunnel.PublicUrl}/wsshttptunnel?clientId={tunnel.ClientId}") + .AddMessagePackProtocol() + .ConfigureLogging(logging => + { + logging.SetMinimumLevel(logLevel); + logging.AddConsole(); + }) + .WithAutomaticReconnect() + .Build(); + + Connection.On("NewHttpConnection", (httpConnection) => + { + Console.WriteLine($"Received http tunneling request: [{httpConnection.Method}]{httpConnection.Path}"); + + _ = TunnelConnectionAsync(httpConnection); + + return Task.CompletedTask; + }); + + Connection.Reconnected += async connectionId => + { + Console.WriteLine($"Reconnected. New ConnectionId {connectionId}"); + + _currentTunnel = await RegisterTunnelAsync(tunnel); + }; + + Connection.Closed += async (error) => + { + Console.WriteLine("Connection closed... reconnecting"); + + await Task.Delay(new Random().Next(0, 5) * 1000); + + if (await ConnectWithRetryAsync(Connection, CancellationToken.None)) + { + _currentTunnel = await RegisterTunnelAsync(tunnel); + } + }; + } + + public async Task ConnectAsync() + { + if (await ConnectWithRetryAsync(Connection, CancellationToken.None)) + { + _currentTunnel = await RegisterTunnelAsync(Tunnel); + } + } + + private async Task TunnelConnectionAsync(HttpConnection httpConnection) + { + var publicUrl = Tunnel.PublicUrl; + + var requestUrl = $"{publicUrl}/tunnelite/request/{httpConnection.RequestId}"; + + try + { + // Start the request to the public server + using var publicResponse = await ServerHttpClient.GetAsync(requestUrl, HttpCompletionOption.ResponseHeadersRead); + + publicResponse.EnsureSuccessStatusCode(); + + // Prepare the request to the local server + using var localRequest = new HttpRequestMessage(new HttpMethod(httpConnection.Method), httpConnection.Path); + + // Copy headers from public response to local request + foreach (var (key, value) in publicResponse.Headers) + { + if (key.StartsWith("X-TR-")) + { + localRequest.Headers.TryAddWithoutValidation(key[5..], value); + } + } + + // Set the content of the local request to stream the data from the public response + localRequest.Content = new StreamContent(await publicResponse.Content.ReadAsStreamAsync()); + + if (httpConnection.ContentType != null) + { + localRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(httpConnection.ContentType); + } + + // Send the request to the local server and get the response + using var localResponse = await LocalHttpClient.SendAsync(localRequest); + + // Prepare the request back to the public server + using var publicRequest = new HttpRequestMessage(HttpMethod.Post, requestUrl); + + // Set the status code + publicRequest.Headers.Add("X-T-Status", ((int)localResponse.StatusCode).ToString()); + + // Copy headers from local response to public request + foreach (var (key, value) in localResponse.Headers) + { + publicRequest.Headers.TryAddWithoutValidation($"X-TR-{key}", value); + } + + // Copy content headers from local response to public request + foreach (var (key, value) in localResponse.Content.Headers) + { + publicRequest.Headers.TryAddWithoutValidation($"X-TC-{key}", value); + } + + // Set the content of the public request to stream from the local response + publicRequest.Content = new StreamContent(await localResponse.Content.ReadAsStreamAsync()); + + // Send the response back to the public server + using var response = await ServerHttpClient.SendAsync(publicRequest); + + response.EnsureSuccessStatusCode(); + } + catch (Exception ex) + { + Console.WriteLine($"Unexpected error tunneling request: {ex.Message}"); + + using var errorRequest = new HttpRequestMessage(HttpMethod.Delete, requestUrl); + using var response = await ServerHttpClient.SendAsync(errorRequest); + } + } + + private async Task RegisterTunnelAsync(HttpTunnelRequest tunnel) + { + tunnel.Subdomain = _currentTunnel?.Subdomain; + + HttpTunnelResponse? tunnelResponse = null; + + while (tunnelResponse == null) + { + try + { + var response = await ServerHttpClient.PostAsJsonAsync($"{Tunnel.PublicUrl}/tunnelite/tunnel", tunnel); + + tunnelResponse = await response.Content.ReadFromJsonAsync(); + + if (response.IsSuccessStatusCode) + { + Console.WriteLine($"Tunnel created successfully: {tunnelResponse!.TunnelUrl}"); + } + else + { + Console.WriteLine($"{tunnelResponse!.Message}:{tunnelResponse.Error}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"An error occurred while registering the tunnel {ex.Message}"); + + await Task.Delay(5000); + } + } + + return tunnelResponse; + } + + private async Task ConnectWithRetryAsync(HubConnection connection, CancellationToken token) + { + while (true) + { + try + { + await connection.StartAsync(token); + + Console.WriteLine($"Client connected to SignalR hub. ConnectionId: {connection.ConnectionId}"); + + return true; + } + catch when (token.IsCancellationRequested) + { + return false; + } + catch + { + Console.WriteLine($"Cannot connect to WebSocket server on {Tunnel.PublicUrl}"); + + await Task.Delay(5000, token); + } + } + } +} diff --git a/src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelRequest.cs b/src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelRequest.cs new file mode 100644 index 0000000..f711738 --- /dev/null +++ b/src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelRequest.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace WebSocketTunnel.Client.HttpTunnel; + +public class HttpTunnelRequest +{ + public string Subdomain { get; set; } + public Guid? ClientId { get; set; } + public string LocalUrl { get; set; } + public string PublicUrl { get; set; } +} diff --git a/src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelResponse.cs b/src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelResponse.cs new file mode 100644 index 0000000..35654d7 --- /dev/null +++ b/src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelResponse.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace WebSocketTunnel.Client.HttpTunnel; + +public class HttpTunnelResponse +{ + public string TunnelUrl { get; set; } + public string Subdomain { get; set; } + public string Error { get; set; } + public string Message { get; set; } +} diff --git a/src/WebSocketTunnel.Client/Program.cs b/src/WebSocketTunnel.Client/Program.cs index 9d2c96b..0b90054 100644 --- a/src/WebSocketTunnel.Client/Program.cs +++ b/src/WebSocketTunnel.Client/Program.cs @@ -1,299 +1,107 @@ using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.CommandLine; -using System.Net.Http.Headers; -using System.Net.Http.Json; +using WebSocketTunnel.Client.TcpTunnel; +using WebSocketTunnel.Client.HttpTunnel; namespace WebSocketTunnel.Client; public class Program { - private static HubConnection? Connection; private static readonly Guid ClientId = Guid.NewGuid(); - private static readonly string Server = "https://tunnelite.com"; - //private static readonly string Server = "https://localhost:7193"; - private static readonly int ChunkSize = 512 * 1024; // 512KB - - private static readonly HttpClientHandler LocalHttpClientHandler = new() - { - ServerCertificateCustomValidationCallback = (message, cert, chain, sslPolicyErrors) => true, - }; - private static readonly HttpClient ServerHttpClient = new(); - private static readonly HttpClient LocalHttpClient = new(LocalHttpClientHandler); - public static async Task Main(string[] args) { var localUrlArgument = new Argument("localUrl", "The local URL to tunnel to."); + var logLevelOption = new Option( "--log", () => LogLevel.Warning, "The logging level (e.g., Trace, Debug, Information, Warning, Error, Critical)"); + var publicUrlOption = new Option( + "--publicUrl", + () => "https://tunnelite.com", + "The public server URL."); + var rootCommand = new RootCommand { localUrlArgument, + publicUrlOption, logLevelOption }; rootCommand.Description = "CLI tool to create a tunnel to a local server."; - rootCommand.SetHandler(async (string localUrl, LogLevel logLevel) => + rootCommand.SetHandler(async (string localUrl, string publicUrl, LogLevel logLevel) => { - await ConnectToServerAsync(localUrl, Server, ClientId, logLevel); - - }, localUrlArgument, logLevelOption); - - await rootCommand.InvokeAsync(args); - - Console.ReadLine(); - } - - private static async Task RegisterTunnelAsync(string localUrl, string publicUrl, Guid clientId, TunnelResponse? existingTunnel) - { - var response = await ServerHttpClient.PostAsJsonAsync( - $"{publicUrl}/tunnelite/tunnel", - new Tunnel - { - LocalUrl = localUrl, - ClientId = clientId, - Subdomain = existingTunnel?.Subdomain, - }); - - var content = await response.Content.ReadFromJsonAsync(); - - if (response.IsSuccessStatusCode) - { - Console.WriteLine($"Tunnel created successfully: {content!.TunnelUrl}"); - } - else - { - Console.WriteLine($"{content!.Message}:{content.Error}"); - } - - return content; - } - - private static async Task ConnectToServerAsync(string localUrl, string publicUrl, Guid clientId, LogLevel logLevel) - { - TunnelResponse? tunnel = null; - - Connection = new HubConnectionBuilder() - .WithUrl($"{publicUrl}/wstunnel?clientId={clientId}", options => + if (string.IsNullOrWhiteSpace(localUrl)) { - options.TransportMaxBufferSize = ChunkSize; - options.ApplicationMaxBufferSize = ChunkSize; - }) - .AddMessagePackProtocol() - .ConfigureLogging(logging => - { - logging.SetMinimumLevel(logLevel); - logging.AddConsole(); - }) - .WithAutomaticReconnect() - .Build(); - - Connection.On("StartTunnelRequest", async (requestMetadata) => - { - Console.WriteLine($"Received tunneling request: [{requestMetadata.Method}]{requestMetadata.Path}"); - - await TunnelRequestWithHttpAsync(publicUrl, requestMetadata); - //await TunnelRequestWithWssAsync(requestMetadata); - }); - - Connection.Reconnected += async connectionId => - { - Console.WriteLine($"Reconnected. New ConnectionId {connectionId}"); - - tunnel = await RegisterTunnelAsync(localUrl, publicUrl, clientId, tunnel); - }; - - Connection.Closed += async (error) => - { - Console.WriteLine("Connection closed... reconnecting"); - - await Task.Delay(new Random().Next(0, 5) * 1000); - - if (await ConnectWithRetryAsync(Connection, CancellationToken.None)) - { - tunnel = await RegisterTunnelAsync(localUrl, publicUrl, clientId, tunnel); + Console.WriteLine("Error: Local URL is required."); + return; } - }; - - if (await ConnectWithRetryAsync(Connection, CancellationToken.None)) - { - tunnel = await RegisterTunnelAsync(localUrl, publicUrl, clientId, tunnel); - } - } - - private static async Task TunnelRequestWithHttpAsync(string publicUrl, RequestMetadata requestMetadata) - { - try - { - // Start the request to the public server - using var publicResponse = await ServerHttpClient.GetAsync( - $"{publicUrl}/tunnelite/request/{requestMetadata.RequestId}", - HttpCompletionOption.ResponseHeadersRead); - - publicResponse.EnsureSuccessStatusCode(); - - // Prepare the request to the local server - var localRequest = new HttpRequestMessage(new HttpMethod(requestMetadata.Method), requestMetadata.Path); - // Copy headers from public response to local request - foreach (var header in publicResponse.Headers) + Uri uri; + try { - if (header.Key.StartsWith("X-TR-")) - { - localRequest.Headers.TryAddWithoutValidation(header.Key[5..], header.Value); - } + uri = new Uri(localUrl); } - - // Set the content of the local request to stream the data from the public response - localRequest.Content = new StreamContent(await publicResponse.Content.ReadAsStreamAsync()); - - if (requestMetadata.ContentType != null) + catch (UriFormatException) { - localRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(requestMetadata.ContentType); + Console.WriteLine("Error: Invalid URL format."); + return; } - // Send the request to the local server and get the response - using var localResponse = await LocalHttpClient.SendAsync(localRequest); + var scheme = uri.Scheme.ToLowerInvariant(); - // Prepare the request back to the public server - var publicRequest = new HttpRequestMessage(HttpMethod.Post, $"{publicUrl}/tunnelite/request/{requestMetadata.RequestId}"); + publicUrl = publicUrl.TrimEnd(['/']); - // Set the status code - publicRequest.Headers.Add("X-T-Status", ((int)localResponse.StatusCode).ToString()); - - // Copy headers from local response to public request - foreach (var header in localResponse.Headers) + switch (scheme) { - publicRequest.Headers.TryAddWithoutValidation($"X-TR-{header.Key}", header.Value); - } + case "tcp": - // Copy content headers from local response to public request - foreach (var header in localResponse.Content.Headers) - { - publicRequest.Headers.TryAddWithoutValidation($"X-TC-{header.Key}", header.Value); - } + var tcpTunnel = new TcpTunnelRequest + { + ClientId = ClientId, + LocalUrl = localUrl, + PublicUrl = publicUrl, + Host = uri.Host, + LocalPort = uri.Port, + }; - // Set the content of the public request to stream from the local response - publicRequest.Content = new StreamContent(await localResponse.Content.ReadAsStreamAsync()); + var tcpTunnelClient = new TcpTunnelClient(tcpTunnel, logLevel); - // Send the response back to the public server - using var response = await ServerHttpClient.SendAsync(publicRequest); + await tcpTunnelClient.ConnectAsync(); - response.EnsureSuccessStatusCode(); - } - catch (Exception ex) - { - Console.WriteLine($"Unexpected error tunneling request: {ex.Message}"); + break; - // todo replace wss - await Connection!.SendAsync("CompleteWithErrorAsync", requestMetadata, ex.Message); - } - } + case "http": + case "https": - private static async Task TunnelRequestWithWssAsync(RequestMetadata requestMetadata) - { - if (Connection == null) - { - return; - } - - try - { - var requestBody = Connection.StreamAsync("StreamRequestBodyAsync", requestMetadata.RequestId); - - // Forward the request to the local server - var requestMessage = new HttpRequestMessage(new HttpMethod(requestMetadata.Method), requestMetadata.Path) - { - Content = new HttpContentCallback(async (stream, token) => - { - await foreach (var chunk in requestBody.WithCancellation(token)) + var httpTunnel = new HttpTunnelRequest { - await stream.WriteAsync(chunk, token); - } - }) - }; + ClientId = ClientId, + LocalUrl = localUrl, + PublicUrl = publicUrl, + }; - foreach (var header in requestMetadata.Headers) - { - requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value); - } + var httpTunnelClient = new HttpTunnelClient(httpTunnel, logLevel); - if (requestMetadata.ContentType != null) - { - requestMessage.Content.Headers.ContentType = new MediaTypeHeaderValue(requestMetadata.ContentType); - } + await httpTunnelClient.ConnectAsync(); - var response = await LocalHttpClient.SendAsync(requestMessage); + break; - var responseMetadata = new ResponseMetadata - { - RequestId = requestMetadata.RequestId, - StatusCode = response.StatusCode, - Headers = response.Headers.ToDictionary(x => x.Key, x => string.Join(", ", x.Value)), - ContentHeaders = response.Content.Headers.ToDictionary(x => x.Key, x => string.Join(", ", x.Value)), - }; + default: - // Stream the response back to the server - await Connection.InvokeAsync("StreamResponseBodyAsync", responseMetadata, StreamResponseBodyAsync(await response.Content.ReadAsStreamAsync())); - } - catch (Exception ex) - { - Console.WriteLine($"Unexpected error tunneling request: {ex.Message}"); + Console.WriteLine("Error: Unsupported protocol. Use tcp:// or http(s)://"); - await Connection.SendAsync("CompleteWithErrorAsync", requestMetadata, ex.Message); - } - } - - private static async IAsyncEnumerable StreamResponseBodyAsync(Stream response) - { - var buffer = new byte[ChunkSize]; - int bytesRead; - - while ((bytesRead = await response.ReadAsync(buffer)) > 0) - { - if (bytesRead == buffer.Length) - { - yield return buffer; + return; } - else - { - var chunk = new byte[bytesRead]; - - Array.Copy(buffer, chunk, bytesRead); - - yield return chunk; - } - } - } - - private static async Task ConnectWithRetryAsync(HubConnection connection, CancellationToken token) - { - while (true) - { - try - { - await connection.StartAsync(token); - Console.WriteLine($"Client connected to SignalR hub. ConnectionId: {connection.ConnectionId}"); + }, localUrlArgument, publicUrlOption, logLevelOption); - return true; - } - catch when (token.IsCancellationRequested) - { - return false; - } - catch - { - Console.WriteLine($"Cannot connect to WebSocket server on {Server}"); + await rootCommand.InvokeAsync(args); - await Task.Delay(5000, token); - } - } + Console.ReadLine(); } } diff --git a/src/WebSocketTunnel.Client/RequestMetadata.cs b/src/WebSocketTunnel.Client/RequestMetadata.cs deleted file mode 100644 index 949840b..0000000 --- a/src/WebSocketTunnel.Client/RequestMetadata.cs +++ /dev/null @@ -1,18 +0,0 @@ -#nullable disable -namespace WebSocketTunnel.Client -{ - public class RequestMetadata - { - public Guid RequestId { get; set; } - - public string Method { get; set; } - - public string ContentType { get; set; } - - public long? ContentLength { get; set; } - - public Dictionary Headers { get; set; } - - public string Path { get; set; } - } -} diff --git a/src/WebSocketTunnel.Client/ResponseMetadata.cs b/src/WebSocketTunnel.Client/ResponseMetadata.cs deleted file mode 100644 index ad29549..0000000 --- a/src/WebSocketTunnel.Client/ResponseMetadata.cs +++ /dev/null @@ -1,16 +0,0 @@ -#nullable disable -using System.Net; - -namespace WebSocketTunnel.Client -{ - public class ResponseMetadata - { - public Guid RequestId { get; set; } - - public Dictionary Headers { get; set; } - - public Dictionary ContentHeaders { get; set; } - - public HttpStatusCode StatusCode { get; set; } - } -} diff --git a/src/WebSocketTunnel.Client/TcpTunnel/TcpConnection.cs b/src/WebSocketTunnel.Client/TcpTunnel/TcpConnection.cs new file mode 100644 index 0000000..fa27d72 --- /dev/null +++ b/src/WebSocketTunnel.Client/TcpTunnel/TcpConnection.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace WebSocketTunnel.Client.TcpTunnel; + +public class TcpConnection +{ + public Guid RequestId { get; set; } +} diff --git a/src/WebSocketTunnel.Client/TcpTunnel/TcpTunnelClient.cs b/src/WebSocketTunnel.Client/TcpTunnel/TcpTunnelClient.cs new file mode 100644 index 0000000..9769a4a --- /dev/null +++ b/src/WebSocketTunnel.Client/TcpTunnel/TcpTunnelClient.cs @@ -0,0 +1,218 @@ +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Buffers; +using System.Net.Sockets; +using System.Runtime.CompilerServices; + +namespace WebSocketTunnel.Client.TcpTunnel; + +public class TcpTunnelClient +{ + private readonly HubConnection Connection; + private readonly TcpTunnelRequest Tunnel; + private TcpTunnelResponse? _currentTunnel = null; + + public TcpTunnelClient(TcpTunnelRequest tunnel, LogLevel logLevel) + { + Tunnel = tunnel; + + Connection = new HubConnectionBuilder() + .WithUrl($"{Tunnel.PublicUrl}/wsstcptunnel?clientId={tunnel.ClientId}") + .AddMessagePackProtocol() + .ConfigureLogging(logging => + { + logging.SetMinimumLevel(logLevel); + logging.AddConsole(); + }) + .WithAutomaticReconnect() + .Build(); + + Connection.On("NewTcpConnection", (tcpConnection) => + { + Console.WriteLine($"New TCP Connection {tcpConnection.RequestId}"); + + _ = HandleNewTcpConnectionAsync(tcpConnection); + + return Task.CompletedTask; + }); + + Connection.On("TcpTunnelClosed", async (errorMessage) => + { + Console.WriteLine($"TCP Tunnel closed by server: {errorMessage}"); + + _currentTunnel = await RegisterTunnelAsync(tunnel); + }); + + Connection.Reconnected += async connectionId => + { + Console.WriteLine($"Reconnected. New ConnectionId {connectionId}"); + + _currentTunnel = await RegisterTunnelAsync(tunnel); + }; + + Connection.Closed += async (error) => + { + Console.WriteLine("Connection closed... reconnecting"); + + await Task.Delay(new Random().Next(0, 5) * 1000); + + if (await ConnectWithRetryAsync(Connection, CancellationToken.None)) + { + _currentTunnel = await RegisterTunnelAsync(tunnel); + } + }; + } + + public async Task ConnectAsync() + { + if (await ConnectWithRetryAsync(Connection, CancellationToken.None)) + { + _currentTunnel = await RegisterTunnelAsync(Tunnel); + } + } + + public async Task RegisterTunnelAsync(TcpTunnelRequest tunnel) + { + tunnel.PublicPort = _currentTunnel?.Port; + + TcpTunnelResponse? tunnelResponse = null; + + while (tunnelResponse == null) + { + try + { + tunnelResponse = await Connection.InvokeAsync("RegisterTunnelAsync", tunnel); + + if (string.IsNullOrEmpty(tunnelResponse.Error)) + { + Console.WriteLine($"Tunnel created successfully: {tunnelResponse!.TunnelUrl}"); + } + else + { + Console.WriteLine($"{tunnelResponse!.Message}:{tunnelResponse.Error}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"An error occurred while registering the tunnel {ex.Message}"); + + await Task.Delay(5000); + } + } + + return tunnelResponse; + } + + private async Task HandleNewTcpConnectionAsync(TcpConnection tcpConnection) + { + using var localClient = new TcpClient(); + using var cts = new CancellationTokenSource(); + + try + { + await localClient.ConnectAsync(Tunnel.Host, Tunnel.LocalPort); + + var incomingTask = StreamIncomingAsync(localClient, tcpConnection, cts.Token); + var outgoingTask = StreamOutgoingAsync(localClient, tcpConnection, cts.Token); + + await Task.WhenAny(incomingTask, outgoingTask); + } + catch (Exception ex) + { + Console.WriteLine($"Error handling TCP connection {ex.Message}"); + } + finally + { + cts.Cancel(); + + Console.WriteLine($"TCP Connection {tcpConnection.RequestId} done."); + } + } + + private async Task StreamIncomingAsync(TcpClient localClient, TcpConnection tcpConnection, CancellationToken cancellationToken) + { + try + { + var incomingTcpStream = Connection.StreamAsync>("StreamIncomingAsync", tcpConnection, cancellationToken: cancellationToken); + + var localTcpStream = localClient.GetStream(); + + await foreach (var chunk in incomingTcpStream.WithCancellation(cancellationToken)) + { + await localTcpStream.WriteAsync(chunk, cancellationToken); + } + } + catch (OperationCanceledException) + { + // ignore + } + catch (IOException ex) when (ex.InnerException is SocketException) + { + // ignore + } + catch (Exception) + { + // ignore + } + finally + { + Console.WriteLine($"Writing data to TCP connection {tcpConnection.RequestId} finished."); + } + } + + private async Task StreamOutgoingAsync(TcpClient localClient, TcpConnection tcpConnection, CancellationToken cancellationToken) + { + await Connection.InvokeAsync("StreamOutgoingAsync", StreamLocalTcpAsync(localClient, tcpConnection, cancellationToken), tcpConnection, cancellationToken: cancellationToken); + } + + private static async IAsyncEnumerable> StreamLocalTcpAsync(TcpClient localClient, TcpConnection tcpConnection, [EnumeratorCancellation] CancellationToken cancellationToken) + { + const int chunkSize = 32 * 1024; + + byte[] buffer = ArrayPool.Shared.Rent(chunkSize); + + try + { + var tcpStream = localClient.GetStream(); + + int bytesRead; + while (!cancellationToken.IsCancellationRequested && + (bytesRead = await tcpStream.ReadAsync(buffer, cancellationToken)) > 0) + { + yield return new ReadOnlyMemory(buffer, 0, bytesRead); + } + } + finally + { + Console.WriteLine($"Reading data from TCP connection {tcpConnection.RequestId} finished."); + + ArrayPool.Shared.Return(buffer); + } + } + + private async Task ConnectWithRetryAsync(HubConnection connection, CancellationToken token) + { + while (true) + { + try + { + await connection.StartAsync(token); + + Console.WriteLine($"Client connected to SignalR hub. ConnectionId: {connection.ConnectionId}"); + + return true; + } + catch when (token.IsCancellationRequested) + { + return false; + } + catch + { + Console.WriteLine($"Cannot connect to WebSocket server on {Tunnel.PublicUrl}"); + + await Task.Delay(5000, token); + } + } + } +} diff --git a/src/WebSocketTunnel.Client/TcpTunnel/TcpTunnelRequest.cs b/src/WebSocketTunnel.Client/TcpTunnel/TcpTunnelRequest.cs new file mode 100644 index 0000000..139ff7d --- /dev/null +++ b/src/WebSocketTunnel.Client/TcpTunnel/TcpTunnelRequest.cs @@ -0,0 +1,12 @@ +#nullable disable +namespace WebSocketTunnel.Client.TcpTunnel; + +public class TcpTunnelRequest +{ + public int LocalPort { get; set; } + public int? PublicPort { get; set; } + public string Host { get; set; } + public Guid ClientId { get; set; } + public string LocalUrl { get; set; } + public string PublicUrl { get; set; } +} diff --git a/src/WebSocketTunnel.Client/TcpTunnel/TcpTunnelResponse.cs b/src/WebSocketTunnel.Client/TcpTunnel/TcpTunnelResponse.cs new file mode 100644 index 0000000..7210f8c --- /dev/null +++ b/src/WebSocketTunnel.Client/TcpTunnel/TcpTunnelResponse.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace WebSocketTunnel.Client.TcpTunnel; + +public class TcpTunnelResponse +{ + public string TunnelUrl { get; set; } + public int Port { get; set; } + public string Error { get; set; } + public string Message { get; set; } +} diff --git a/src/WebSocketTunnel.Client/Tunnel.cs b/src/WebSocketTunnel.Client/Tunnel.cs deleted file mode 100644 index c939d01..0000000 --- a/src/WebSocketTunnel.Client/Tunnel.cs +++ /dev/null @@ -1,10 +0,0 @@ -#nullable disable -namespace WebSocketTunnel.Client -{ - public class Tunnel - { - public string Subdomain { get; set; } - public Guid? ClientId { get; set; } - public string LocalUrl { get; set; } - } -} diff --git a/src/WebSocketTunnel.Client/TunnelResponse.cs b/src/WebSocketTunnel.Client/TunnelResponse.cs deleted file mode 100644 index 6aa9c8c..0000000 --- a/src/WebSocketTunnel.Client/TunnelResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -#nullable disable -namespace WebSocketTunnel.Client -{ - public class TunnelResponse - { - public string TunnelUrl { get; set; } - public string Subdomain { get; set; } - public string Error { get; set; } - public string Message { get; set; } - } -} diff --git a/src/WebSocketTunnel.Client/WebSocketTunnel.Client.csproj b/src/WebSocketTunnel.Client/WebSocketTunnel.Client.csproj index 1888572..54ad820 100644 --- a/src/WebSocketTunnel.Client/WebSocketTunnel.Client.csproj +++ b/src/WebSocketTunnel.Client/WebSocketTunnel.Client.csproj @@ -16,7 +16,7 @@ Tool for tunneling URLs https://github.com/cristipufu/ws-tunnel-signalr https://github.com/cristipufu/ws-tunnel-signalr - 1.0.4 + 1.1.0 diff --git a/src/WebSocketTunnel.Server/Extensions.cs b/src/WebSocketTunnel.Server/Extensions.cs new file mode 100644 index 0000000..5253c73 --- /dev/null +++ b/src/WebSocketTunnel.Server/Extensions.cs @@ -0,0 +1,46 @@ +using WebSocketTunnel.Server.HttpTunnel; +using WebSocketTunnel.Server.TcpTunnel; + +namespace WebSocketTunnel.Server; + +public static class Extensions +{ + public static void AddHttpTunneling(this WebApplicationBuilder builder) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + + public static void AddTcpTunneling(this WebApplicationBuilder builder) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + + public static void ConfigureSignalR(this WebApplicationBuilder builder) + { + var signalRConnectionString = builder.Configuration.GetConnectionString("AzureSignalR"); + + var signalRBuilder = builder.Services.AddSignalR(hubOptions => + { + hubOptions.EnableDetailedErrors = true; + }).AddMessagePackProtocol(); + + if (!string.IsNullOrEmpty(signalRConnectionString)) + { + signalRBuilder.AddAzureSignalR(opt => + { + opt.ConnectionString = signalRConnectionString; + }); + } + } + + public static void UseFavicon(this WebApplication app) + { + app.MapGet("/favicon.ico", async context => + { + context.Response.ContentType = "image/x-icon"; + await context.Response.SendFileAsync("wwwroot/favicon.ico"); + }); + } +} diff --git a/src/WebSocketTunnel.Server/HttpTunnel/HttpAppExtensions.cs b/src/WebSocketTunnel.Server/HttpTunnel/HttpAppExtensions.cs new file mode 100644 index 0000000..057d3db --- /dev/null +++ b/src/WebSocketTunnel.Server/HttpTunnel/HttpAppExtensions.cs @@ -0,0 +1,317 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; + +namespace WebSocketTunnel.Server.HttpTunnel; + +public static class HttpAppExtensions +{ + public static void UseHttpTunneling(this WebApplication app) + { + app.MapPost("/tunnelite/tunnel", async (HttpContext context, [FromBody] HttpTunnelRequest payload, HttpTunnelStore tunnelStore, ILogger logger) => + { + try + { + if (string.IsNullOrEmpty(payload.LocalUrl)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Missing or invalid 'LocalUrl' property."); + return; + } + + if (payload.ClientId == Guid.Empty) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Missing or invalid 'ClientId' property."); + return; + } + + if (string.IsNullOrEmpty(payload.Subdomain)) + { + payload.Subdomain = RandomSubdomain(); + } + else + { + // don't hijack existing subdomain from another client + if (tunnelStore.Tunnels.TryGetValue(payload.Subdomain, out var tunnel)) + { + if (tunnel.ClientId != payload.ClientId) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Missing or invalid 'Subdomain' property."); + return; + } + } + } + + payload.LocalUrl = payload.LocalUrl.TrimEnd(['/']); + + tunnelStore.Tunnels.AddOrUpdate(payload.Subdomain, payload, (key, oldValue) => payload); + tunnelStore.Clients.AddOrUpdate(payload.ClientId, payload.Subdomain, (key, oldValue) => payload.Subdomain); + + var tunnelUrl = $"{context.Request.Scheme}://{payload.Subdomain}.{context.Request.Host}{context.Request.PathBase}"; + + context.Response.StatusCode = StatusCodes.Status201Created; + await context.Response.WriteAsJsonAsync(new + { + TunnelUrl = tunnelUrl, + payload.Subdomain, + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error creating tunnel: {Message}", ex.Message); + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(new + { + Message = "An error occurred while creating the tunnel", + Error = ex.Message, + }); + } + }); + + app.MapGet("/tunnelite/request/{requestId}", async (HttpContext context, [FromRoute] Guid requestId, HttpRequestsQueue requestsQueue, ILogger logger) => + { + try + { + var deferredHttpContext = requestsQueue.GetHttpContext(requestId); + + if (deferredHttpContext == null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + // Send method + context.Response.Headers.Append("X-T-Method", deferredHttpContext.Request.Method); + + // Send headers + foreach (var header in deferredHttpContext.Request.Headers) + { + context.Response.Headers.Append($"X-TR-{header.Key}", header.Value.ToString()); + } + + // Stream the body + await deferredHttpContext.Request.Body.CopyToAsync(context.Response.Body); + } + catch (Exception ex) + { + logger.LogError(ex, "Error fetching request body: {Message}", ex.Message); + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(new + { + Message = "An error occurred while fetching the request body", + Error = ex.Message, + }); + } + }); + + app.MapPost("/tunnelite/request/{requestId}", async (HttpContext context, [FromRoute] Guid requestId, HttpRequestsQueue requestsQueue, ILogger logger) => + { + try + { + var deferredHttpContext = requestsQueue.GetHttpContext(requestId); + + if (deferredHttpContext == null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + // Set the status code + if (context.Request.Headers.TryGetValue("X-T-Status", out var statusCodeHeader) + && int.TryParse(statusCodeHeader, out var statusCode)) + { + deferredHttpContext.Response.StatusCode = statusCode; + } + else + { + deferredHttpContext.Response.StatusCode = 200; // Default to 200 OK if not specified + } + + // Copy headers from the tunneling client's request to the deferred response + + foreach (var header in context.Request.Headers) + { + if (header.Key.StartsWith("X-TR-")) + { + var headerKey = header.Key[5..]; // Remove "X-TR-" prefix + + if (!NotAllowedHeaders.Contains(headerKey)) + { + deferredHttpContext.Response.Headers.TryAdd(headerKey, header.Value); + } + } + + if (header.Key.StartsWith("X-TC-")) + { + var headerKey = header.Key[5..]; // Remove "X-TR-" prefix + + if (!NotAllowedHeaders.Contains(headerKey)) + { + deferredHttpContext.Response.Headers.TryAdd(headerKey, header.Value); + } + } + } + + // Stream the body from the tunneling client's request to the deferred response + await context.Request.Body.CopyToAsync(deferredHttpContext.Response.Body); + + // Complete the deferred response + await requestsQueue.CompleteAsync(requestId); + + // Send a confirmation response to the tunneling client + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsJsonAsync(new { Message = "Ok" }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error forwarding response body: {Message}", ex.Message); + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(new + { + Message = "An error occurred while forwarding the response body", + Error = ex.Message, + }); + } + }); + + app.MapDelete("/tunnelite/request/{requestId}", async (HttpContext context, [FromRoute] Guid requestId, HttpRequestsQueue requestsQueue, ILogger logger) => + { + try + { + var deferredHttpContext = requestsQueue.GetHttpContext(requestId); + + if (deferredHttpContext == null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + deferredHttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + await deferredHttpContext.Response.WriteAsJsonAsync(new + { + Message = "An error occurred while tunneling the request", + }); + + // Complete the deferred response + await requestsQueue.CompleteAsync(requestId); + + // Send a confirmation response to the tunneling client + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsJsonAsync(new { Message = "Ok" }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error forwarding response body: {Message}", ex.Message); + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(new + { + Message = "An error occurred while forwarding the response body", + Error = ex.Message, + }); + } + }); + + var supportedMethods = new[] { "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD" }; + + app.MapMethods( + pattern: "/", + httpMethods: supportedMethods, + handler: (HttpContext context, IHubContext hubContext, HttpRequestsQueue requestsQueue, HttpTunnelStore connectionStore, ILogger logger) => + TunnelRequestAsync(context, hubContext, requestsQueue, connectionStore, path: string.Empty, logger)); + + app.MapMethods( + pattern: "/{**path}", + httpMethods: supportedMethods, + handler: (HttpContext context, IHubContext hubContext, HttpRequestsQueue requestsQueue, HttpTunnelStore connectionStore, string path, ILogger logger) => + TunnelRequestAsync(context, hubContext, requestsQueue, connectionStore, path, logger)); + + app.MapHub("/wsshttptunnel"); + } + + static async Task TunnelRequestAsync(HttpContext context, IHubContext hubContext, HttpRequestsQueue requestsQueue, HttpTunnelStore tunnelStore, string path, ILogger logger) + { + try + { + HttpTunnelRequest? tunnel = null; + + var subdomain = context.Request.Host.Host.Split('.')[0]; + + if (subdomain.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + { + tunnel = tunnelStore.Tunnels.FirstOrDefault().Value; + } + else if (subdomain.Equals("tunnelite")) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsync(NotFound); + return; + } + else + { + tunnelStore.Tunnels.TryGetValue(subdomain, out tunnel); + } + + if (tunnel == null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsync(NotFound); + return; + } + + if (!tunnelStore.Connections.TryGetValue(tunnel!.ClientId, out var connectionId)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsync(NotFound); + return; + } + + var requestId = Guid.NewGuid(); + + var completionTask = requestsQueue.WaitForCompletionAsync(requestId, context, timeout: TimeSpan.FromSeconds(30), context.RequestAborted); + + await hubContext.Clients.Client(connectionId).SendAsync("NewHttpConnection", new HttpConnection + { + RequestId = requestId, + ContentType = context.Request.ContentType, + Method = context.Request.Method, + Path = $"{tunnel.LocalUrl}/{path}{context.Request.QueryString}", + }); + + await completionTask; + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing request tunnel: {Message}", ex.Message); + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsync("An error occurred while processing the tunnel."); + } + } + + static string RandomSubdomain(int length = 8) + { + Random random = new(); + const string chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + return new(Enumerable.Repeat(chars, length) + .Select(s => s[random.Next(s.Length)]).ToArray()); + } + + static readonly string[] NotAllowedHeaders = ["Connection", "Transfer-Encoding", "Keep-Alive", "Upgrade", "Proxy-Connection"]; + + const string NotFound = @" Oops! Lost in the Tunnel? +< ------------------------ > +< It seems you've wandered > +< into a mysterious realm > +< ------------------------ > + \ ^__^ + \ (oo)\_______ + (__)\ )\/\ + ||----w | + || || +"; +} \ No newline at end of file diff --git a/src/WebSocketTunnel.Server/HttpTunnel/HttpConnection.cs b/src/WebSocketTunnel.Server/HttpTunnel/HttpConnection.cs new file mode 100644 index 0000000..4d77290 --- /dev/null +++ b/src/WebSocketTunnel.Server/HttpTunnel/HttpConnection.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace WebSocketTunnel.Server.HttpTunnel; + +public class HttpConnection +{ + public Guid RequestId { get; set; } + + public string Method { get; set; } + + public string ContentType { get; set; } + + public string Path { get; set; } +} diff --git a/src/WebSocketTunnel.Server/HttpTunnel/HttpDefferedRequest.cs b/src/WebSocketTunnel.Server/HttpTunnel/HttpDefferedRequest.cs new file mode 100644 index 0000000..299098a --- /dev/null +++ b/src/WebSocketTunnel.Server/HttpTunnel/HttpDefferedRequest.cs @@ -0,0 +1,33 @@ +namespace WebSocketTunnel.Server.HttpTunnel; + +public class HttpDefferedRequest : IDisposable +{ + public HttpContext? HttpContext { get; set; } + + public Guid RequestId { get; set; } + + public TaskCompletionSource? TaskCompletionSource { get; set; } + + public CancellationTokenSource? TimeoutCancellationTokenSource { get; set; } + + public CancellationTokenSource? CancellationTokenSource { get; set; } + + public CancellationTokenRegistration CancellationTokenRegistration { get; set; } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + HttpContext = null; + TimeoutCancellationTokenSource?.Dispose(); + CancellationTokenSource?.Dispose(); + CancellationTokenRegistration.Dispose(); + } + } +} diff --git a/src/WebSocketTunnel.Server/HttpTunnel/HttpRequestsQueue.cs b/src/WebSocketTunnel.Server/HttpTunnel/HttpRequestsQueue.cs new file mode 100644 index 0000000..ec9b4f1 --- /dev/null +++ b/src/WebSocketTunnel.Server/HttpTunnel/HttpRequestsQueue.cs @@ -0,0 +1,87 @@ +using System.Collections.Concurrent; + +namespace WebSocketTunnel.Server.HttpTunnel; + +public class HttpRequestsQueue +{ + public ConcurrentDictionary PendingRequests = new(); + + public virtual Task WaitForCompletionAsync(Guid requestId, HttpContext context, TimeSpan? timeout = null, CancellationToken cancellationToken = default) + { + HttpDefferedRequest request = new() + { + HttpContext = context, + RequestId = requestId, + TimeoutCancellationTokenSource = timeout.HasValue ? new CancellationTokenSource(timeout.Value) : new CancellationTokenSource(), + TaskCompletionSource = new TaskCompletionSource(), + }; + + // Wait until caller cancels or timeout expires + request.CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + request.TimeoutCancellationTokenSource.Token); + + PendingRequests.TryAdd(request.RequestId, request); + + if (request.CancellationTokenSource.Token.CanBeCanceled) + { + request.CancellationTokenRegistration = request.CancellationTokenSource.Token.Register(obj => + { + // When the request gets canceled + var request = (HttpDefferedRequest)obj!; + + if (request.TimeoutCancellationTokenSource!.IsCancellationRequested) + { + request.TaskCompletionSource!.TrySetResult(); + } + else + { + // Canceled by caller + request.TaskCompletionSource!.TrySetCanceled(request.CancellationTokenSource!.Token); + } + + PendingRequests.TryRemove(request.RequestId, out var _); + + request.Dispose(); + + }, request); + } + + return request.TaskCompletionSource.Task; + } + + public virtual HttpContext? GetHttpContext(Guid requestId) + { + if (!PendingRequests.TryGetValue(requestId, out var request)) + { + return null; + } + + return request.HttpContext; + } + + public virtual Task CompleteAsync(Guid requestId) + { + if (!PendingRequests.TryRemove(requestId, out var request)) + { + return Task.CompletedTask; + } + + if (!request.TaskCompletionSource!.Task.IsCompleted) + { + // Try to complete the task + if (request.TaskCompletionSource?.TrySetResult() == false) + { + // The request was canceled + } + } + else + { + // The request was canceled while pending + } + + request.Dispose(); + + return Task.CompletedTask; + } +} diff --git a/src/WebSocketTunnel.Server/HttpTunnel/HttpTunnelHub.cs b/src/WebSocketTunnel.Server/HttpTunnel/HttpTunnelHub.cs new file mode 100644 index 0000000..3b456e8 --- /dev/null +++ b/src/WebSocketTunnel.Server/HttpTunnel/HttpTunnelHub.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.SignalR; + +namespace WebSocketTunnel.Server.HttpTunnel; + +public class HttpTunnelHub(HttpTunnelStore tunnelStore) : Hub +{ + private readonly HttpTunnelStore _tunnelStore = tunnelStore; + + public override Task OnConnectedAsync() + { + var clientId = GetClientId(Context); + + _tunnelStore.Connections.AddOrUpdate(clientId, Context.ConnectionId, (key, oldValue) => Context.ConnectionId); + + return base.OnConnectedAsync(); + } + + public override Task OnDisconnectedAsync(Exception? exception) + { + var clientId = GetClientId(Context); + + if (_tunnelStore.Clients.TryGetValue(clientId, out var subdomain)) + { + _tunnelStore.Tunnels.Remove(subdomain, out var _); + _tunnelStore.Connections.Remove(clientId, out var _); + _tunnelStore.Clients.Remove(clientId, out _); + } + + return base.OnDisconnectedAsync(exception); + } + + private static Guid GetClientId(HubCallerContext context) + { + return Guid.Parse(context.GetHttpContext()!.Request.Query["clientId"].ToString()); + } +} diff --git a/src/WebSocketTunnel.Server/HttpTunnel/HttpTunnelStore.cs b/src/WebSocketTunnel.Server/HttpTunnel/HttpTunnelStore.cs new file mode 100644 index 0000000..775d600 --- /dev/null +++ b/src/WebSocketTunnel.Server/HttpTunnel/HttpTunnelStore.cs @@ -0,0 +1,24 @@ +using System.Collections.Concurrent; + +namespace WebSocketTunnel.Server.HttpTunnel; + +public class HttpTunnelStore +{ + // subdomain, [clientId, localUrl] + public ConcurrentDictionary Tunnels = new(); + + // clientId, connectionId + public ConcurrentDictionary Connections = new(); + + // clientId, subdomain + public ConcurrentDictionary Clients = new(); +} + +public class HttpTunnelRequest +{ + public string? Subdomain { get; set; } + + public Guid ClientId { get; set; } + + public string? LocalUrl { get; set; } +} diff --git a/src/WebSocketTunnel.Server/Program.cs b/src/WebSocketTunnel.Server/Program.cs index f905a2a..e60f653 100644 --- a/src/WebSocketTunnel.Server/Program.cs +++ b/src/WebSocketTunnel.Server/Program.cs @@ -1,299 +1,25 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; -using WebSocketTunnel.Server.Request; -using WebSocketTunnel.Server.Tunnel; +using WebSocketTunnel.Server; +using WebSocketTunnel.Server.HttpTunnel; +using WebSocketTunnel.Server.TcpTunnel; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.AddHttpTunneling(); -var signalRConnectionString = builder.Configuration.GetConnectionString("AzureSignalR"); +builder.AddTcpTunneling(); -var signalRBuilder = builder.Services.AddSignalR() - .AddMessagePackProtocol(); - -if (!string.IsNullOrEmpty(signalRConnectionString)) -{ - signalRBuilder.AddAzureSignalR(opt => - { - opt.ConnectionString = signalRConnectionString; - }); -} - -builder.Services.Configure(options => -{ - options.MaximumReceiveMessageSize = 1024 * 1024; // 1MB - options.MaximumParallelInvocationsPerClient = 128; - options.StreamBufferCapacity = 128; -}); +builder.ConfigureSignalR(); var app = builder.Build(); app.UseStaticFiles(); -app.UseHttpsRedirection(); - -app.MapPost("/tunnelite/tunnel", async (HttpContext context, [FromBody] Tunnel payload, TunnelStore tunnelStore, ILogger logger) => -{ - try - { - if (string.IsNullOrEmpty(payload.LocalUrl)) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Missing or invalid 'LocalUrl' property."); - return; - } - - if (payload.ClientId == Guid.Empty) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Missing or invalid 'ClientId' property."); - return; - } - - if (string.IsNullOrEmpty(payload.Subdomain)) - { - payload.Subdomain = DnsBuilder.RandomSubdomain(); - } - else - { - // don't hijack existing subdomain from another client - if (tunnelStore.Tunnels.TryGetValue(payload.Subdomain, out var tunnel)) - { - if (tunnel.ClientId != payload.ClientId) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Missing or invalid 'Subdomain' property."); - return; - } - } - } - - payload.LocalUrl = payload.LocalUrl.TrimEnd(['/']); - - tunnelStore.Tunnels.AddOrUpdate(payload.Subdomain, payload, (key, oldValue) => payload); - tunnelStore.Clients.AddOrUpdate(payload.ClientId, payload.Subdomain, (key, oldValue) => payload.Subdomain); - - var tunnelUrl = $"{context.Request.Scheme}://{payload.Subdomain}.{context.Request.Host}{context.Request.PathBase}"; - - context.Response.StatusCode = StatusCodes.Status201Created; - await context.Response.WriteAsJsonAsync(new - { - TunnelUrl = tunnelUrl, - payload.Subdomain, - }); - } - catch (Exception ex) - { - logger.LogError(ex, "Error creating tunnel: {Message}", ex.Message); - - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - await context.Response.WriteAsJsonAsync(new - { - Message = "An error occurred while creating the tunnel", - Error = ex.Message, - }); - } -}); - -app.MapGet("/tunnelite/request/{requestId}", async (HttpContext context, [FromRoute] Guid requestId, RequestsQueue requestsQueue, ILogger logger) => -{ - try - { - var deferredHttpContext = requestsQueue.GetHttpContext(requestId); - - if (deferredHttpContext == null) - { - context.Response.StatusCode = StatusCodes.Status404NotFound; - return; - } - - // Send method - context.Response.Headers.Append("X-T-Method", deferredHttpContext.Request.Method); - - // Send headers - foreach (var header in deferredHttpContext.Request.Headers) - { - context.Response.Headers.Append($"X-TR-{header.Key}", header.Value.ToString()); - } - - // Stream the body - await deferredHttpContext.Request.Body.CopyToAsync(context.Response.Body); - } - catch (Exception ex) - { - logger.LogError(ex, "Error fetching request body: {Message}", ex.Message); - - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - await context.Response.WriteAsJsonAsync(new - { - Message = "An error occurred while fetching the request body", - Error = ex.Message, - }); - } -}); - -app.MapPost("/tunnelite/request/{requestId}", async (HttpContext context, [FromRoute] Guid requestId, RequestsQueue requestsQueue, ILogger logger) => -{ - try - { - var deferredHttpContext = requestsQueue.GetHttpContext(requestId); - - if (deferredHttpContext == null) - { - context.Response.StatusCode = StatusCodes.Status404NotFound; - return; - } - - // Set the status code - if (context.Request.Headers.TryGetValue("X-T-Status", out var statusCodeHeader) - && int.TryParse(statusCodeHeader, out var statusCode)) - { - deferredHttpContext.Response.StatusCode = statusCode; - } - else - { - deferredHttpContext.Response.StatusCode = 200; // Default to 200 OK if not specified - } - - // Copy headers from the tunneling client's request to the deferred response - var notAllowed = new string[] { "Connection", "Transfer-Encoding", "Keep-Alive", "Upgrade", "Proxy-Connection" }; +app.UseFavicon(); - foreach (var header in context.Request.Headers) - { - if (header.Key.StartsWith("X-TR-")) - { - var headerKey = header.Key[5..]; // Remove "X-TR-" prefix - - if (!notAllowed.Contains(headerKey)) - { - deferredHttpContext.Response.Headers.TryAdd(headerKey, header.Value); - } - } - - if (header.Key.StartsWith("X-TC-")) - { - var headerKey = header.Key[5..]; // Remove "X-TR-" prefix - - if (!notAllowed.Contains(headerKey)) - { - deferredHttpContext.Response.Headers.TryAdd(headerKey, header.Value); - } - } - } - - // Stream the body from the tunneling client's request to the deferred response - await context.Request.Body.CopyToAsync(deferredHttpContext.Response.Body); - - // Complete the deferred response - await requestsQueue.CompleteAsync(requestId); - - // Send a confirmation response to the tunneling client - context.Response.StatusCode = StatusCodes.Status200OK; - await context.Response.WriteAsJsonAsync(new { Message = "Ok" }); - } - catch (Exception ex) - { - logger.LogError(ex, "Error forwarding response body: {Message}", ex.Message); - - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - await context.Response.WriteAsJsonAsync(new - { - Message = "An error occurred while forwarding the response body", - Error = ex.Message, - }); - } -}); - -app.MapGet("/favicon.ico", async context => -{ - context.Response.ContentType = "image/x-icon"; - await context.Response.SendFileAsync("wwwroot/favicon.ico"); -}); - -var supportedMethods = new[] { "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD" }; - -app.MapMethods( - pattern: "/", - httpMethods: supportedMethods, - handler: (HttpContext context, IHubContext hubContext, RequestsQueue requestsQueue, TunnelStore connectionStore, ILogger logger) => - ProxyRequestAsync(context, hubContext, requestsQueue, connectionStore, path: string.Empty, logger)); - -app.MapMethods( - pattern: "/{**path}", - httpMethods: supportedMethods, - handler: (HttpContext context, IHubContext hubContext, RequestsQueue requestsQueue, TunnelStore connectionStore, string path, ILogger logger) => - ProxyRequestAsync(context, hubContext, requestsQueue, connectionStore, path, logger)); - -static async Task ProxyRequestAsync(HttpContext context, IHubContext hubContext, RequestsQueue requestsQueue, TunnelStore tunnelStore, string path, ILogger logger) -{ - try - { - Tunnel? tunnel = null; - - var subdomain = context.Request.Host.Host.Split('.')[0]; - - if (subdomain.Equals("localhost", StringComparison.OrdinalIgnoreCase)) - { - tunnel = tunnelStore.Tunnels.FirstOrDefault().Value; - } - else if (subdomain.Equals("tunnelite")) - { - context.Response.StatusCode = StatusCodes.Status404NotFound; - await context.Response.WriteAsync(ResponseText.NotFound); - return; - } - else - { - tunnelStore.Tunnels.TryGetValue(subdomain, out tunnel); - } - - if (tunnel == null) - { - context.Response.StatusCode = StatusCodes.Status404NotFound; - await context.Response.WriteAsync(ResponseText.NotFound); - return; - } - - if (!tunnelStore.Connections.TryGetValue(tunnel!.ClientId, out var connectionId)) - { - context.Response.StatusCode = StatusCodes.Status404NotFound; - await context.Response.WriteAsync(ResponseText.NotFound); - return; - } - - var requestId = Guid.NewGuid(); - - var completionTask = requestsQueue.WaitForAsync(requestId, context, timeout: null, context.RequestAborted); - - await hubContext.Clients.Client(connectionId).SendAsync("StartTunnelRequest", new RequestMetadata - { - RequestId = requestId, - ContentType = context.Request.ContentType, - ContentLength = context.Request.ContentLength, - Headers = context.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()), - Method = context.Request.Method, - Path = $"{tunnel.LocalUrl}/{path}{context.Request.QueryString}", - }); - - await completionTask; - } - catch (Exception ex) - { - logger.LogError(ex, "Error processing request tunnel: {Message}", ex.Message); - - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - await context.Response.WriteAsync("An error occurred while processing the tunnel."); - } -} +app.UseHttpsRedirection(); -const int BufferSize = 512 * 1024; // 512KB +app.UseHttpTunneling(); -app.MapHub("/wstunnel", (opt) => -{ - opt.TransportMaxBufferSize = BufferSize; - opt.ApplicationMaxBufferSize = BufferSize; -}); +app.UseTcpTunneling(); app.Run(); \ No newline at end of file diff --git a/src/WebSocketTunnel.Server/Request/DefferedRequest.cs b/src/WebSocketTunnel.Server/Request/DefferedRequest.cs deleted file mode 100644 index e6deefb..0000000 --- a/src/WebSocketTunnel.Server/Request/DefferedRequest.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace WebSocketTunnel.Server.Request -{ - public class DefferedRequest : IDisposable - { - public HttpContext? HttpContext { get; set; } - - public Guid RequestId { get; set; } - - public TaskCompletionSource? TaskCompletionSource { get; set; } - - public CancellationTokenSource? TimeoutCancellationTokenSource { get; set; } - - public CancellationTokenSource? CancellationTokenSource { get; set; } - - public CancellationTokenRegistration CancellationTokenRegistration { get; set; } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - HttpContext = null; - TimeoutCancellationTokenSource?.Dispose(); - CancellationTokenSource?.Dispose(); - CancellationTokenRegistration.Dispose(); - } - } - } -} diff --git a/src/WebSocketTunnel.Server/Request/IRequestsQueue.cs b/src/WebSocketTunnel.Server/Request/IRequestsQueue.cs deleted file mode 100644 index 79775aa..0000000 --- a/src/WebSocketTunnel.Server/Request/IRequestsQueue.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace WebSocketTunnel.Server.Request -{ - public interface IRequestsQueue - { - Task WaitForAsync(Guid requestId, HttpContext context, TimeSpan? timeout = null, CancellationToken cancellationToken = default); - - HttpContext? GetHttpContext(Guid requestId); - - Task CompleteAsync(Guid requestId); - } -} diff --git a/src/WebSocketTunnel.Server/Request/RequestMetadata.cs b/src/WebSocketTunnel.Server/Request/RequestMetadata.cs deleted file mode 100644 index b4035a4..0000000 --- a/src/WebSocketTunnel.Server/Request/RequestMetadata.cs +++ /dev/null @@ -1,18 +0,0 @@ -#nullable disable -namespace WebSocketTunnel.Server.Request -{ - public class RequestMetadata - { - public Guid RequestId { get; set; } - - public string Method { get; set; } - - public string ContentType { get; set; } - - public long? ContentLength { get; set; } - - public Dictionary Headers { get; set; } - - public string Path { get; set; } - } -} diff --git a/src/WebSocketTunnel.Server/Request/RequestsQueue.cs b/src/WebSocketTunnel.Server/Request/RequestsQueue.cs deleted file mode 100644 index 12fa2dc..0000000 --- a/src/WebSocketTunnel.Server/Request/RequestsQueue.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Collections.Concurrent; - -namespace WebSocketTunnel.Server.Request -{ - public class RequestsQueue : IRequestsQueue - { - public ConcurrentDictionary PendingRequests = new(); - - public RequestsQueue() { } - - public virtual Task WaitForAsync(Guid requestId, HttpContext context, TimeSpan? timeout = null, CancellationToken cancellationToken = default) - { - DefferedRequest request = new() - { - HttpContext = context, - RequestId = requestId, - TimeoutCancellationTokenSource = timeout.HasValue ? new CancellationTokenSource(timeout.Value) : new CancellationTokenSource(), - TaskCompletionSource = new TaskCompletionSource(), - }; - - // Wait until caller cancels or timeout expires - request.CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - request.TimeoutCancellationTokenSource.Token); - - PendingRequests.TryAdd(request.RequestId, request); - - if (request.CancellationTokenSource.Token.CanBeCanceled) - { - request.CancellationTokenRegistration = request.CancellationTokenSource.Token.Register(obj => - { - // When the request gets canceled - var request = (DefferedRequest)obj!; - - if (request.TimeoutCancellationTokenSource!.IsCancellationRequested) - { - request.TaskCompletionSource!.TrySetResult(); - } - else - { - // Canceled by caller - request.TaskCompletionSource!.TrySetCanceled(request.CancellationTokenSource!.Token); - } - - PendingRequests.TryRemove(request.RequestId, out var _); - - request.Dispose(); - - }, request); - } - - return request.TaskCompletionSource.Task; - } - - public virtual HttpContext? GetHttpContext(Guid requestId) - { - if (!PendingRequests.TryGetValue(requestId, out var request)) - { - return null; - } - - return request.HttpContext; - } - - public virtual Task CompleteAsync(Guid requestId) - { - if (!PendingRequests.TryRemove(requestId, out var request)) - { - return Task.CompletedTask; - } - - if (!request.TaskCompletionSource!.Task.IsCompleted) - { - // Try to complete the task - if (request.TaskCompletionSource?.TrySetResult() == false) - { - // The request was canceled - } - } - else - { - // The request was canceled while pending - } - - request.Dispose(); - - return Task.CompletedTask; - } - } -} diff --git a/src/WebSocketTunnel.Server/Request/ResponseMetadata.cs b/src/WebSocketTunnel.Server/Request/ResponseMetadata.cs deleted file mode 100644 index 6afff21..0000000 --- a/src/WebSocketTunnel.Server/Request/ResponseMetadata.cs +++ /dev/null @@ -1,16 +0,0 @@ -#nullable disable -using System.Net; - -namespace WebSocketTunnel.Server.Request -{ - public class ResponseMetadata - { - public Guid RequestId { get; set; } - - public Dictionary Headers { get; set; } - - public Dictionary ContentHeaders { get; set; } - - public HttpStatusCode StatusCode { get; set; } - } -} diff --git a/src/WebSocketTunnel.Server/Request/ResponseText.cs b/src/WebSocketTunnel.Server/Request/ResponseText.cs deleted file mode 100644 index 0b7fb2e..0000000 --- a/src/WebSocketTunnel.Server/Request/ResponseText.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace WebSocketTunnel.Server.Request -{ - public static class ResponseText - { - public const string NotFound = @" Oops! Lost in the Tunnel? -< ------------------------ > -< It seems you've wandered > -< into a mysterious realm > -< ------------------------ > - \ ^__^ - \ (oo)\_______ - (__)\ )\/\ - ||----w | - || || -"; - } -} diff --git a/src/WebSocketTunnel.Server/TcpTunnel/TcpAppExtensions.cs b/src/WebSocketTunnel.Server/TcpTunnel/TcpAppExtensions.cs new file mode 100644 index 0000000..a70f1c4 --- /dev/null +++ b/src/WebSocketTunnel.Server/TcpTunnel/TcpAppExtensions.cs @@ -0,0 +1,9 @@ +namespace WebSocketTunnel.Server.TcpTunnel; + +public static class TcpAppExtensions +{ + public static void UseTcpTunneling(this WebApplication app) + { + app.MapHub("/wsstcptunnel"); + } +} diff --git a/src/WebSocketTunnel.Server/TcpTunnel/TcpClientStore.cs b/src/WebSocketTunnel.Server/TcpTunnel/TcpClientStore.cs new file mode 100644 index 0000000..c590f2d --- /dev/null +++ b/src/WebSocketTunnel.Server/TcpTunnel/TcpClientStore.cs @@ -0,0 +1,98 @@ +using System.Collections.Concurrent; +using System.Net.Sockets; + +#nullable disable +namespace WebSocketTunnel.Server.TcpTunnel; + +public class TcpClientStore +{ + // client, [requestId, TcpClient] + private readonly ConcurrentDictionary> _clientStore = new(); + // clientId, TcpListener + public ConcurrentDictionary _listenerStore = new(); + + public void AddTcpClient(Guid clientId, Guid requestId, TcpClient tcpClient) + { + _clientStore.AddOrUpdate( + clientId, + _ => new ConcurrentDictionary { [requestId] = tcpClient }, + (_, tcpClients) => + { + tcpClients[requestId] = tcpClient; + return tcpClients; + }); + } + + public TcpClient GetTcpClient(Guid clientId, Guid requestId) + { + if (!_clientStore.TryGetValue(clientId, out var tcpClients)) + { + return null; + } + + tcpClients.TryGetValue(requestId, out var tcpClient); + + return tcpClient; + } + + public void DisposeTcpClient(Guid clientId, Guid requestId) + { + if (!_clientStore.TryGetValue(clientId, out var tcpClients)) + { + return; + } + + if (!tcpClients.TryRemove(requestId, out var tcpClient)) + { + return; + } + + tcpClient?.Dispose(); + } + + public void AddTcpListener(Guid clientId, TcpListenerContext tcpListener) + { + _listenerStore.AddOrUpdate(clientId, tcpListener, (key, oldValue) => tcpListener); + } + + public void DisposeTcpListener(Guid clientId) + { + if (_clientStore.TryRemove(clientId, out var tcpClients)) + { + foreach (var client in tcpClients.Values) + { + client?.Dispose(); + } + } + + if (_listenerStore.TryRemove(clientId, out var listener)) + { + listener?.Dispose(); + } + } +} + +public class TcpListenerContext : IDisposable +{ + public TcpListener TcpListener { get; set; } + + public CancellationTokenSource CancellationTokenSource { get; set; } + + public Task AcceptConnectionsTask { get; set; } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + CancellationTokenSource?.Cancel(); + CancellationTokenSource?.Dispose(); + TcpListener?.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/WebSocketTunnel.Server/TcpTunnel/TcpConnection.cs b/src/WebSocketTunnel.Server/TcpTunnel/TcpConnection.cs new file mode 100644 index 0000000..77d0817 --- /dev/null +++ b/src/WebSocketTunnel.Server/TcpTunnel/TcpConnection.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace WebSocketTunnel.Server.TcpTunnel; + +public class TcpConnection +{ + public Guid RequestId { get; set; } +} diff --git a/src/WebSocketTunnel.Server/TcpTunnel/TcpTunnelHub.cs b/src/WebSocketTunnel.Server/TcpTunnel/TcpTunnelHub.cs new file mode 100644 index 0000000..cd0a824 --- /dev/null +++ b/src/WebSocketTunnel.Server/TcpTunnel/TcpTunnelHub.cs @@ -0,0 +1,212 @@ +using Microsoft.AspNetCore.SignalR; +using System.Buffers; +using System.Net; +using System.Net.Sockets; +using System.Threading.Channels; + +namespace WebSocketTunnel.Server.TcpTunnel; + +public class TcpTunnelHub(TcpTunnelStore tunnelStore, TcpClientStore tcpClientStore, IHubContext hubContext, ILogger logger) : Hub +{ + private readonly TcpTunnelStore _tunnelStore = tunnelStore; + private readonly TcpClientStore _tcpClientStore = tcpClientStore; + private readonly IHubContext _hubContext = hubContext; + private readonly ILogger _logger = logger; + + public override Task OnConnectedAsync() + { + var clientId = GetClientId(Context); + + _tunnelStore.Connections.AddOrUpdate(clientId, Context.ConnectionId, (key, oldValue) => Context.ConnectionId); + + return base.OnConnectedAsync(); + } + + public Task RegisterTunnelAsync(TcpTunnelRequest tunnel) + { + var response = new TcpTunnelResponse(); + var tcpListenerContext = new TcpListenerContext(); + + try + { + tcpListenerContext.TcpListener = new TcpListener(IPAddress.Any, tunnel.PublicPort ?? 0); + tcpListenerContext.CancellationTokenSource = new CancellationTokenSource(); + + tcpListenerContext.TcpListener.Start(); + + tunnel.PublicPort = ((IPEndPoint)tcpListenerContext.TcpListener.LocalEndpoint).Port; + + tcpListenerContext.AcceptConnectionsTask = AcceptConnectionsAsync( + tcpListenerContext.TcpListener, + tunnel.ClientId, + tcpListenerContext.CancellationTokenSource.Token); + + _tcpClientStore.AddTcpListener(tunnel.ClientId, tcpListenerContext); + + var httpContext = Context.GetHttpContext(); + var tunnelUrl = $"tcp://{httpContext!.Request.Host.Host}:{tunnel.PublicPort}"; + + response.Port = tunnel.PublicPort ?? 0; + response.TunnelUrl = tunnelUrl; + } + catch (Exception ex) + { + response.Message = "An error occurred while creating the tunnel"; + response.Error = ex.Message; + + tcpListenerContext.Dispose(); + } + + return Task.FromResult(response); + } + + public async IAsyncEnumerable> StreamIncomingAsync(TcpConnection tcpConnection) + { + var clientId = GetClientId(Context); + + var tcpClient = _tcpClientStore.GetTcpClient(clientId, tcpConnection.RequestId); + + if (tcpClient == null) + { + yield break; + } + + const int chunkSize = 32 * 1024; + + byte[] buffer = ArrayPool.Shared.Rent(chunkSize); + + try + { + var stream = tcpClient.GetStream(); + + int bytesRead; + + while (!Context.ConnectionAborted.IsCancellationRequested && + (bytesRead = await stream.ReadAsync(buffer, Context.ConnectionAborted)) > 0) + { + yield return new ReadOnlyMemory(buffer, 0, bytesRead); + } + } + finally + { + _logger.LogInformation("Done reading.. Closing TCP client {RequestId}", tcpConnection.RequestId); + + ArrayPool.Shared.Return(buffer); + + _tcpClientStore.DisposeTcpClient(clientId, tcpConnection.RequestId); + } + } + + public async Task StreamOutgoingAsync(TcpConnection tcpConnection, IAsyncEnumerable> stream) + { + var clientId = GetClientId(Context); + + var tcpClient = _tcpClientStore.GetTcpClient(clientId, tcpConnection.RequestId); + + if (tcpClient == null) + { + return; + } + + try + { + var tcpStream = tcpClient.GetStream(); + + await foreach (var chunk in stream.WithCancellation(Context.ConnectionAborted)) + { + await tcpStream.WriteAsync(chunk, Context.ConnectionAborted); + } + } + catch (OperationCanceledException) + { + // ignore + } + catch (ChannelClosedException) + { + // ignore + } + catch (IOException ex) when (ex.InnerException is SocketException) + { + // ignore + } + catch (Exception ex) when (ex.Message == "Stream canceled by client.") + { + // ignore + } + catch (Exception ex) + { + _logger.LogError(ex, "An unexpected error occurred while streaming outgoing data for {RequestId}", tcpConnection.RequestId); + } + finally + { + _logger.LogInformation("Done writing.. TCP client {RequestId}", tcpConnection.RequestId); + + _tcpClientStore.DisposeTcpClient(clientId, tcpConnection.RequestId); + } + } + + public override Task OnDisconnectedAsync(Exception? exception) + { + var clientId = GetClientId(Context); + + _tunnelStore.Connections.Remove(clientId, out var _); + + _tcpClientStore.DisposeTcpListener(clientId); + + return base.OnDisconnectedAsync(exception); + } + + private async Task AcceptConnectionsAsync(TcpListener listener, Guid clientId, CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + var tcpClient = await listener.AcceptTcpClientAsync(cancellationToken); + + if (cancellationToken.IsCancellationRequested) + { + tcpClient.Dispose(); + + break; + } + + if (!_tunnelStore.Connections.TryGetValue(clientId, out var connectionId)) + { + continue; + } + + var tcpConnection = new TcpConnection + { + RequestId = Guid.NewGuid(), + }; + + _logger.LogInformation("New TCP client connected {RequestId}", tcpConnection.RequestId); + + _tcpClientStore.AddTcpClient(clientId, tcpConnection.RequestId, tcpClient); + + await _hubContext.Clients.Client(connectionId).SendAsync("NewTcpConnection", tcpConnection, cancellationToken: cancellationToken); + } + } + catch (OperationCanceledException) + { + // client disconnected, tcp listener disposed + } + catch (Exception ex) + { + _logger.LogError(ex, "An error has occurred while listening for incoming TCP connections: {Message}", ex.Message); + + _tcpClientStore.DisposeTcpListener(clientId); + + if (_tunnelStore.Connections.TryGetValue(clientId, out var connectionId)) + { + await _hubContext.Clients.Client(connectionId).SendAsync("TcpTunnelClosed", ex.Message, cancellationToken: cancellationToken); + } + } + } + + private static Guid GetClientId(HubCallerContext context) + { + return Guid.Parse(context.GetHttpContext()!.Request.Query["clientId"].ToString()); + } +} \ No newline at end of file diff --git a/src/WebSocketTunnel.Server/TcpTunnel/TcpTunnelStore.cs b/src/WebSocketTunnel.Server/TcpTunnel/TcpTunnelStore.cs new file mode 100644 index 0000000..792bc9c --- /dev/null +++ b/src/WebSocketTunnel.Server/TcpTunnel/TcpTunnelStore.cs @@ -0,0 +1,27 @@ +using System.Collections.Concurrent; + +#nullable disable +namespace WebSocketTunnel.Server.TcpTunnel; + +public class TcpTunnelStore +{ + // clientId, connectionId + public ConcurrentDictionary Connections = new(); +} + +public class TcpTunnelRequest +{ + public int LocalPort { get; set; } + public int? PublicPort { get; set; } + public string Host { get; set; } + public Guid ClientId { get; set; } + public string LocalUrl { get; set; } +} + +public class TcpTunnelResponse +{ + public string TunnelUrl { get; set; } + public int Port { get; set; } + public string Error { get; set; } + public string Message { get; set; } +} diff --git a/src/WebSocketTunnel.Server/Tunnel/DnsBuilder.cs b/src/WebSocketTunnel.Server/Tunnel/DnsBuilder.cs deleted file mode 100644 index b8b9905..0000000 --- a/src/WebSocketTunnel.Server/Tunnel/DnsBuilder.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace WebSocketTunnel.Server.Tunnel -{ - public static class DnsBuilder - { - public static string RandomSubdomain(int length = 8) - { - Random random = new(); - const string chars = "abcdefghijklmnopqrstuvwxyz0123456789"; - return new(Enumerable.Repeat(chars, length) - .Select(s => s[random.Next(s.Length)]).ToArray()); - } - } -} diff --git a/src/WebSocketTunnel.Server/Tunnel/TunnelHub.cs b/src/WebSocketTunnel.Server/Tunnel/TunnelHub.cs deleted file mode 100644 index 58b1a3a..0000000 --- a/src/WebSocketTunnel.Server/Tunnel/TunnelHub.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Microsoft.AspNetCore.SignalR; -using System.Runtime.CompilerServices; -using WebSocketTunnel.Server.Request; - -namespace WebSocketTunnel.Server.Tunnel -{ - public class TunnelHub(RequestsQueue requestsQueue, TunnelStore tunnelStore) : Hub - { - private readonly RequestsQueue _requestsQueue = requestsQueue; - private readonly TunnelStore _tunnelStore = tunnelStore; - - public override Task OnConnectedAsync() - { - var clientId = Context.GetHttpContext()!.Request.Query["clientId"].ToString(); - - _tunnelStore.Connections.AddOrUpdate(Guid.Parse(clientId), Context.ConnectionId, (key, oldValue) => Context.ConnectionId); - - return base.OnConnectedAsync(); - } - - public override Task OnDisconnectedAsync(Exception? exception) - { - var clientIdQuery = Context.GetHttpContext()!.Request.Query["clientId"].ToString(); - - var clientId = Guid.Parse(clientIdQuery); - - if (_tunnelStore.Clients.TryGetValue(clientId, out var subdomain)) - { - _tunnelStore.Tunnels.Remove(subdomain, out var _); - _tunnelStore.Connections.Remove(clientId, out var _); - _tunnelStore.Clients.Remove(clientId, out _); - } - - return base.OnDisconnectedAsync(exception); - } - - public async IAsyncEnumerable StreamRequestBodyAsync(Guid requestId, [EnumeratorCancellation] CancellationToken cancellationToken) - { - var httpContext = _requestsQueue.GetHttpContext(requestId); - - if (httpContext == null) - { - yield break; - } - - const int chunkSize = 512 * 1024; // 512KB - - var buffer = new byte[chunkSize]; - int bytesRead; - - while ((bytesRead = await httpContext.Request.Body.ReadAsync(buffer, cancellationToken)) > 0) - { - if (bytesRead == buffer.Length) - { - yield return buffer; - } - else - { - var chunk = new byte[bytesRead]; - - Array.Copy(buffer, chunk, bytesRead); - - yield return chunk; - } - } - } - - public async Task StreamResponseBodyAsync(ResponseMetadata responseMetadata, IAsyncEnumerable stream) - { - var httpContext = _requestsQueue.GetHttpContext(responseMetadata.RequestId); - - if (httpContext == null) - { - return; - } - - try - { - httpContext.Response.StatusCode = (int)responseMetadata.StatusCode; - - var notAllowed = new string[] { "Connection", "Transfer-Encoding", "Keep-Alive", "Upgrade", "Proxy-Connection" }; - - foreach (var header in responseMetadata.Headers) - { - if (!notAllowed.Contains(header.Key)) - { - httpContext.Response.Headers.TryAdd(header.Key, header.Value); - } - } - - foreach (var header in responseMetadata.ContentHeaders) - { - if (!notAllowed.Contains(header.Key)) - { - httpContext.Response.Headers.TryAdd(header.Key, header.Value); - } - } - - var responseStream = httpContext.Response.Body; - - await foreach (var chunk in stream) - { - await responseStream.WriteAsync(chunk); - } - } - catch (Exception ex) - { - httpContext.Response.StatusCode = 500; - - await httpContext.Response.WriteAsync($"An error occurred while tunneling the request: {ex.Message}"); - } - - await _requestsQueue.CompleteAsync(responseMetadata.RequestId); - } - - public async Task CompleteWithErrorAsync(RequestMetadata requestMetadata, string message) - { - var httpContext = _requestsQueue.GetHttpContext(requestMetadata.RequestId); - - if (httpContext == null) - { - return; - } - - httpContext.Response.StatusCode = 500; - - await httpContext.Response.WriteAsync($"An error occurred while tunneling the request: {message}"); - - await _requestsQueue.CompleteAsync(requestMetadata.RequestId); - } - } -} diff --git a/src/WebSocketTunnel.Server/Tunnel/TunnelStore.cs b/src/WebSocketTunnel.Server/Tunnel/TunnelStore.cs deleted file mode 100644 index 8aca1cc..0000000 --- a/src/WebSocketTunnel.Server/Tunnel/TunnelStore.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Concurrent; - -namespace WebSocketTunnel.Server.Tunnel -{ - public class TunnelStore - { - // subdomain, [clientId, localUrl] - public ConcurrentDictionary Tunnels = new(); - - // clientId, connectionId - public ConcurrentDictionary Connections = new(); - - // clientId, subdomain - public ConcurrentDictionary Clients = new(); - } - - public class Tunnel - { - public string? Subdomain { get; set; } - - public Guid ClientId { get; set; } - - public string? LocalUrl { get; set; } - } -} diff --git a/test/Test.TcpClient/Program.cs b/test/Test.TcpClient/Program.cs new file mode 100644 index 0000000..1782115 --- /dev/null +++ b/test/Test.TcpClient/Program.cs @@ -0,0 +1,66 @@ +using System.Net.Sockets; +using System.Text; + +class TcpClientApp +{ + static async Task Main(string[] args) + { + if (args.Length != 2) + { + Console.WriteLine("Usage: TcpClientApp "); + return; + } + + string host = args[0]; + int port = int.Parse(args[1]); + + using var client = new TcpClient(); + try + { + await client.ConnectAsync(host, port); + Console.WriteLine($"Connected to {host}:{port}"); + + using NetworkStream stream = client.GetStream(); + + var sendTask = SendDataAsync(stream); + var receiveTask = ReceiveDataAsync(stream); + + await Task.WhenAll(sendTask, receiveTask); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + } + + static async Task SendDataAsync(NetworkStream stream) + { + var random = new Random(); + while (true) + { + string message = GenerateRandomString(random, 10); + byte[] buffer = Encoding.UTF8.GetBytes(message); + await stream.WriteAsync(buffer); + Console.WriteLine($"Sent: {message}"); + await Task.Delay(1000); + } + } + + static async Task ReceiveDataAsync(NetworkStream stream) + { + var buffer = new byte[1024]; + while (true) + { + int bytesRead = await stream.ReadAsync(buffer); + if (bytesRead == 0) break; + Console.WriteLine($"Received: {Encoding.UTF8.GetString(buffer, 0, bytesRead)}"); + } + } + + static string GenerateRandomString(Random random, int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[random.Next(s.Length)]).ToArray()); + } +} \ No newline at end of file diff --git a/test/Test.TcpClient/Test.TcpClient.csproj b/test/Test.TcpClient/Test.TcpClient.csproj new file mode 100644 index 0000000..2150e37 --- /dev/null +++ b/test/Test.TcpClient/Test.TcpClient.csproj @@ -0,0 +1,10 @@ + + + + Exe + net8.0 + enable + enable + + + diff --git a/test/Test.TcpForwarder/Program.cs b/test/Test.TcpForwarder/Program.cs new file mode 100644 index 0000000..e4953de --- /dev/null +++ b/test/Test.TcpForwarder/Program.cs @@ -0,0 +1,65 @@ +using System.Net; +using System.Net.Sockets; + +class Program +{ + static async Task Main(string[] args) + { + const int listenPort = 5000; + const int sqlServerPort = 1433; // SQL Server default port + const string sqlServerHost = "localhost"; + + var listener = new TcpListener(IPAddress.Any, listenPort); + listener.Start(); + Console.WriteLine($"Listening on port {listenPort}. Forwarding to {sqlServerHost}:{sqlServerPort}"); + + while (true) + { + var client = await listener.AcceptTcpClientAsync(); + _ = HandleClientAsync(client, sqlServerHost, sqlServerPort); + } + } + + static async Task HandleClientAsync(TcpClient client, string sqlServerHost, int sqlServerPort) + { + Console.WriteLine("New client connected"); + using var sqlServer = new TcpClient(); + try + { + await sqlServer.ConnectAsync(sqlServerHost, sqlServerPort); + + using var clientStream = client.GetStream(); + using var serverStream = sqlServer.GetStream(); + + var task1 = ForwardDataAsync(clientStream, serverStream, "ClientToServer"); + var task2 = ForwardDataAsync(serverStream, clientStream, "ServerToClient"); + + await Task.WhenAny(task1, task2); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + finally + { + client.Close(); + sqlServer.Close(); + Console.WriteLine("Client disconnected"); + } + } + + static async Task ForwardDataAsync(NetworkStream source, NetworkStream destination, string message) + { + var buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = await source.ReadAsync(buffer)) > 0) + { + Console.WriteLine($"{message} reading and sending"); + + await destination.WriteAsync(buffer, 0, bytesRead); + await destination.FlushAsync(); + } + + Console.WriteLine($"{message} done reading"); + } +} \ No newline at end of file diff --git a/test/Test.TcpForwarder/Test.TcpForwarder.csproj b/test/Test.TcpForwarder/Test.TcpForwarder.csproj new file mode 100644 index 0000000..2150e37 --- /dev/null +++ b/test/Test.TcpForwarder/Test.TcpForwarder.csproj @@ -0,0 +1,10 @@ + + + + Exe + net8.0 + enable + enable + + + diff --git a/test/Test.TcpServer/Program.cs b/test/Test.TcpServer/Program.cs new file mode 100644 index 0000000..466e68c --- /dev/null +++ b/test/Test.TcpServer/Program.cs @@ -0,0 +1,84 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +class TcpServerApp +{ + static async Task Main(string[] args) + { + if (args.Length != 1) + { + Console.WriteLine("Usage: TcpServerApp "); + return; + } + + int port = int.Parse(args[0]); + var listener = new TcpListener(IPAddress.Any, port); + + try + { + listener.Start(); + Console.WriteLine($"Server listening on port {port}"); + + while (true) + { + TcpClient client = await listener.AcceptTcpClientAsync(); + _ = HandleClientAsync(client); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + finally + { + listener.Stop(); + } + } + + static async Task HandleClientAsync(TcpClient client) + { + Console.WriteLine($"Client connected: {client.Client.RemoteEndPoint}"); + + using NetworkStream stream = client.GetStream(); + + var sendDataTask = SendDataAsync(stream); + var receiveDataTask = ReceiveDataAsync(stream); + + await Task.WhenAll(sendDataTask, receiveDataTask); + + client.Close(); + Console.WriteLine($"Client disconnected: {client.Client.RemoteEndPoint}"); + } + + static async Task SendDataAsync(NetworkStream stream) + { + var random = new Random(); + while (true) + { + string message = GenerateRandomString(random, 10); + byte[] buffer = Encoding.UTF8.GetBytes(message); + await stream.WriteAsync(buffer); + Console.WriteLine($"Sent: {message}"); + await Task.Delay(1000); + } + } + + static async Task ReceiveDataAsync(NetworkStream stream) + { + var buffer = new byte[1024]; + while (true) + { + int bytesRead = await stream.ReadAsync(buffer); + if (bytesRead == 0) break; + Console.WriteLine($"Received: {Encoding.UTF8.GetString(buffer, 0, bytesRead)}"); + } + } + + static string GenerateRandomString(Random random, int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[random.Next(s.Length)]).ToArray()); + } +} \ No newline at end of file diff --git a/test/Test.TcpServer/Test.TcpServer.csproj b/test/Test.TcpServer/Test.TcpServer.csproj new file mode 100644 index 0000000..2150e37 --- /dev/null +++ b/test/Test.TcpServer/Test.TcpServer.csproj @@ -0,0 +1,10 @@ + + + + Exe + net8.0 + enable + enable + + +