Skip to content

Commit

Permalink
Add TCP implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
cristipufu committed Aug 10, 2024
1 parent aa9c5c2 commit a7d106b
Show file tree
Hide file tree
Showing 49 changed files with 1,800 additions and 965 deletions.
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

<br/>
<br/>
<details>
<summary>HTTP Connection</summary>

<br/>

![image info](https://github.com/cristipufu/ws-tunnel-signalr/blob/master/docs/http_tunneling.png)

</details>

<details>
<summary>TCP Overview</summary>

<br/>

![image info](https://github.com/cristipufu/ws-tunnel-signalr/blob/master/docs/tcp_tunneling_global.png)

</details>

<details>
<summary>TCP Connection</summary>

<br/>

![image info](https://github.com/cristipufu/ws-tunnel-signalr/blob/master/docs/tcp_tunneling.png)

</details>


25 changes: 24 additions & 1 deletion WebSocketTunnel.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,13 +31,28 @@ 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
EndGlobalSection
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}
Expand Down
Binary file added docs/tcp_tunneling.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions docs/tcp_tunneling.puml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@startuml
actor "External Client" as EC
participant "Public Tunneling Server\n(Multiple Pods)" as PTS
participant "Azure SignalR" as AS
participant "Tunneling Client" as TC
participant "Intranet Server" as IS

EC -> 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
Binary file added docs/tcp_tunneling_global.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions docs/tcp_tunneling_global.puml
Original file line number Diff line number Diff line change
@@ -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
25 changes: 0 additions & 25 deletions src/WebSocketTunnel.Client/HttpContentCallback.cs

This file was deleted.

10 changes: 10 additions & 0 deletions src/WebSocketTunnel.Client/HttpTunnel/HttpConnection.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
203 changes: 203 additions & 0 deletions src/WebSocketTunnel.Client/HttpTunnel/HttpTunnelClient.cs
Original file line number Diff line number Diff line change
@@ -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<HttpConnection>("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<HttpTunnelResponse?> 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<HttpTunnelResponse?>();

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<bool> 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);
}
}
}
}
Loading

0 comments on commit a7d106b

Please sign in to comment.