Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Backend/Backend.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>246972d0-2477-4915-8d25-164c075b48f3</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>

<ItemGroup>
Expand All @@ -20,6 +19,7 @@

<ItemGroup>
<ProjectReference Include="..\Model\Model.csproj" />
<ProjectReference Include="..\SignalRGatewayTunnel.ServiceDefaults\SignalRGatewayTunnel.ServiceDefaults.csproj" />
</ItemGroup>

</Project>
24 changes: 0 additions & 24 deletions Backend/Dockerfile

This file was deleted.

7 changes: 6 additions & 1 deletion Backend/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
// Backend builds a web app for management use. Optional.
var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

builder.Services.AddSignalR()
.AddMessagePackProtocol(options =>
{
Expand All @@ -22,9 +24,12 @@
.AddHostedService<TunnelClient>()
.AddHttpClient<TunnelClient>();


var app = builder.Build();

app.MapDefaultEndpoints();

// Dummy endpoint for backend gateway. Normally the gatewat us forwarding to a destination service but
// it might also have its own endpoints for management etc.
app.MapGet("/", () => "This is the gateway backend");

app.Run();
30 changes: 6 additions & 24 deletions Backend/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -1,38 +1,20 @@
{
"profiles": {
"Backend": {
"http": {
"commandName": "Project",
"launchBrowser": false,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7208;http://localhost:5297"
"applicationUrl": "http://localhost:5297"
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"https": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_URLS": "https://+:443;http://+:80"
},
"publishAllPorts": true,
"useSSL": true
}
},
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:36258",
"sslPort": 44344
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7208;http://localhost:5297"
}
}
}
24 changes: 0 additions & 24 deletions Frontend/Dockerfile

This file was deleted.

2 changes: 1 addition & 1 deletion Frontend/Frontend.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>e498ca58-c3d8-46f1-b349-6db90480b0c6</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>

<ItemGroup>
Expand All @@ -18,6 +17,7 @@

<ItemGroup>
<ProjectReference Include="..\Model\Model.csproj" />
<ProjectReference Include="..\SignalRGatewayTunnel.ServiceDefaults\SignalRGatewayTunnel.ServiceDefaults.csproj" />
</ItemGroup>

</Project>
8 changes: 7 additions & 1 deletion Frontend/Hubs/TunnelHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ public class TunnelHub : Hub<ITunnel>
{
private readonly ConcurrentDictionary<string, string> _connections = new();

public async Task<ResponseMessage> SendHttpRequestAsync(RequestMessage request)
public async Task<ResponseMessage?> SendHttpRequestAsync(RequestMessage request)
{
// TODO: target/routing. Currently just sends to first client

if (_connections.IsEmpty)
{
Log.Warning("No clients connected to handle request");
return null;
}

string connectionId = _connections.FirstOrDefault().Value;
Log.Debug("Sending to: {ConnectionId}", connectionId);
return await Clients.Client(_connections.FirstOrDefault().Value).HttpRequest(request);
Expand Down
11 changes: 10 additions & 1 deletion Frontend/Middleware/TunnelMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,22 @@ public class TunnelMiddleware(RequestDelegate nextMiddleware, TunnelHub tunnelHu

public async Task Invoke(HttpContext context)
{
if (!context.Request.Path.StartsWithSegments("/gw-hub"))
var path = context.Request.Path;

if (!path.StartsWithSegments("/gw-hub") &&
!path.StartsWithSegments("/health") &&
!path.StartsWithSegments("/alive"))
{
var tunnelRequestMessage = await CreateTunnelMessage(context);

Log.Debug("Sending request {@Message}", tunnelRequestMessage);

var responseMessage = await _tunnelHub.SendHttpRequestAsync(tunnelRequestMessage);
if (responseMessage == null)
{
context.Response.StatusCode = StatusCodes.Status502BadGateway;
return;
}

context.Response.StatusCode = (int)responseMessage.StatusCode;
CopyFromResponseHeaders(context, responseMessage);
Expand Down
16 changes: 15 additions & 1 deletion Frontend/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
.CreateLogger();

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();
builder.Services
.AddCors()
.AddSingleton<TunnelHub>();
Expand All @@ -27,7 +29,19 @@

var app = builder.Build();

// app.UseHttpsRedirection();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}

app.UseHttpsRedirection();

// app.UseAntiforgery();

app.MapDefaultEndpoints();

app.UseCors(builder => builder
.AllowAnyOrigin()
.AllowAnyMethod()
Expand Down
30 changes: 6 additions & 24 deletions Frontend/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -1,38 +1,20 @@
{
"profiles": {
"Frontend": {
"http": {
"commandName": "Project",
"launchBrowser": false,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7175;http://localhost:5105"
"applicationUrl": "http://localhost:5105"
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"https": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_URLS": "https://+:443;http://+:80"
},
"publishAllPorts": true,
"useSSL": true
}
},
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:59540",
"sslPort": 44357
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7175;http://localhost:5105"
}
}
}
57 changes: 46 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,41 @@
# SignalRGatewayTunnel

Sample SignalR based API Gateway tunnel
[![Build](https://github.com/tjmoore/SignalRGatewayTunnel/actions/workflows/build.yml/badge.svg)](https://github.com/tjmoore/SignalRGatewayTunnel/actions/workflows/build.yml)

WORK IN PROGRESS
Sample SignalR based Gateway tunnel

**WORK IN PROGRESS**

This is a rough proof of concept of an API Gateway tunnel using SignalR. This shouldn't be relied upon as production ready, nor expect to be stable or secure.
This is a rough proof of concept of a Gateway tunnel using SignalR. **This shouldn't be relied upon as production ready, nor expect to be stable or secure**

The use case is for applications that reside in an on-premise or locked-down environment where it is not possible to open ports to expose the application endpoints. An application outside of that environment may need to connect to the application inside.
The structure is as follows:

Here a Backend service would run inside the locked environment, and a Frontend service runs externally and exposes a SignalR endpoint to listen on. The Backend connects to the Frontend and registers itself.
- External Client (browser or other HTTP client)
- Frontend Proxy (cloud hosted for example)
- Backend Gateway (inside a restricted network for example)
- Destination Backend Service (the destination service to be accessed)

The Frontend Proxy hosts a SignalR Hub that the Backend Gateway connects to as a client. The Backend Gateway registers itself with the Frontend Proxy, and listens for incoming requests coming back on the SignalR connection.
When a request is received by the Frontend Proxy, it packages the request into a message and sends it to the Backend Gateway via SignalR. The Backend Gateway then unpacks the message, makes the HTTP request to the Destination Backend Service, and sends the response back to the Frontend Proxy, which then returns it to the original client.

A use case may be for example, a corporate network that restricts inbound traffic, but allows outbound HTTPS traffic to the internet. They may not wish to open firewalls and provide routing to the internal service.
In this case, a Backend Gateway service runs inside the corporate network can connect out to a Frontend service hosted in the cloud, which then allows external clients to access the internal service via the Frontend Proxy.

Likewise useful for home environments for a service hosted in a NAS for example, that needs to be accessed externally without opening up home network firewalls.

This isn't intended as a backdoor or way to bypass security, but rather a framework to allow controlled access to internal services without opening up firewalls or exposing services directly to the internet.

Noting the security implications of exposing internal services externally, this should be done with care, and appropriate security measures in place.

**This example does not include any authentication or encryption other than what comes out of the box (HTTPS support for example)**

The Frontend accepts HTTP requests, packages these into messages and sends to the SignalR client (the Backend), which then unpacks and sends an HTTP request inside the locked environment to an internal endpoint. The HTTP response is packaged and returned to the Backend service.

While this kind of set up is also achievable with VPNs, SSH tunnels and similar, this provides a framework for a custom tunnel or reverse proxy that could be made to handle many client applications. For example a single endpoint externally that an application or users can access, with routing to multiple backends depending on some identifier in the request, DNS, etc.

This doesn't handle tunneling SignalR itself at present.
Tunnelling SignalR within the SignalR connection is not support/untested. Although potentially long-polling SignalR (HTTP) requests might work, but again not tested.

The example here currently only routes to the first client that registers.

Dockerfile configs are just defaults generated for the projects and untested.


## References

Expand All @@ -30,6 +45,26 @@ Loosely based Frontend middleware on https://auth0.com/blog/building-a-reverse-p

## Dependencies

Projects are targetting .NET 8, but minimum .NET 7 for SignalR Client Results support.
.NET 8 minimum

MessagePack for fast binary package transport https://github.com/MessagePack-CSharp/MessagePack-CSharp

## Running development mode

#### Visual Studio
Set SignalRGatewayTunnel.AppHost as start up project and run (F5)

#### Visual Studio Code
From Solution Explorer, right click SignalRGatewayTunnel.AppHost and select Debug -> Start New Instance

#### Command Line
Run `dotnet run --project SignalRGatewayTunnel.AppHost`

You may have to select the dashboard link shown in the console output to launch in the browser

This will run the .NET Aspire host, launching the components and dashboard in the browser showing the service status.

If the Backend Gateway has connected to the frontend successfully, browse to the Frontend Proxy URL (http://localhost:5105 or https://localhost:7175).
This will send the HTTP request in the browser via the gateway to the destination endpoint at http://localhost:9000 and return the response or timeout if destination is not running.


MessagePack for fast binary package transport https://github.com/MessagePack-CSharp/MessagePack-CSharp
12 changes: 12 additions & 0 deletions SignalRGatewayTunnel.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
var builder = DistributedApplication.CreateBuilder(args);

var frontend = builder.AddProject<Projects.Frontend>("frontend")
.WithExternalHttpEndpoints()
.WithHttpHealthCheck("/health");

builder.AddProject<Projects.Backend>("backend")
.WithHttpHealthCheck("/health")
.WithReference(frontend)
.WaitFor(frontend);

builder.Build().Run();
29 changes: 29 additions & 0 deletions SignalRGatewayTunnel.AppHost/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17046;http://localhost:15057",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21005",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22193"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15057",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19054",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20023"
}
}
}
}
Loading