UFX.Relay connects two ASPNet Core Middleware pipelines using a single WebSocket connection therefor extending a cloud application to an on-premise application instance. This is similar to services like ngrok but rather than requiring an external 3rd party service, UFX.Relay is a self-contained pure ASPNet Core solution.
The sample Client and Server projects demonstrate how to use UFX.Relay to connect a cloud application to an on-premise application with simple association of agents using a TunnelId. A request to the server/forwarder with a TunnelId header will be forwarded to the corresponding client/listener that connects with the same TunnelId.
The Server/Forwarder end of UFX.Relay leverages YARP to forward ASPNet Core requests to the on-premise application via the WebSocket connection. At the lowest level YARP converts a HTTPContext to a HTTPClientRequest and sends it to the on-premise application via the WebSocket connection which uses a MultiplexingStream to allow multiple requests to be sent over a single connection. Note: This implementation uses YARP DirectForwarding to forward requests to the on-premise application, any YARP cluster configuration will not be used.
UFX.Relay comprises three components:
- Forwarder
- Listener
- Tunnel
This uses YARP DirectForwarding to forward requests over the tunnel connection to be received by the listener.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTunnelForwarder();
var app = builder.Build();
app.MapTunnelForwarder();
app.Run();The listener received requests over the tunnel from the forwarder and injects them into the ASPNet Core pipeline.
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.AddTunnelListener(options =>
{
options.DefaultTunnelId = "123";
});If you expect repeated connection failures (e.g., temporary network issues or misconfiguration), you can enable exponential backoff for reconnect attempts.
When enabled, the delay between reconnect attempts increases progressively after each failed attempt, up to a configurable maximum.
Once a connection succeeds, the interval resets to the base ReconnectInterval.
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.AddTunnelListener(options =>
{
options.DefaultTunnelId = "123";
// Enable exponential backoff for reconnect attempts
options.EnableReconnectBackoff = true;
// Cap the maximum backoff delay (default: 2 minutes)
options.MaxReconnectInterval = TimeSpan.FromMinutes(5);
});The Tunnel is a logical layer on top of a WebSocket connection that allows for multiple requests to be multiplexed over a single connection. The tunnel has both a Client and Host end, the Forwarder and Listener can use either the Tunnel Client or Tunnel Host. Typically, the forwarder would be used with the Tunnel Host and the listener with the Tunnel Client for a ngrok replacement scenario. However, if the Tunnel Client and Host are swapped this would allow for connection aggregation of multiple on-prem connections via a single connection to a cloud service.
The client requires the TunnelHost and TunnelId to be specified in order to connect to the Tunnel Host.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTunnelClient(options =>
options with
{
TunnelHost = "wss://localhost:7400",
TunnelId = "123"
});The tunnel host is added as a minimal api endpoint to the application pipeline accepting websocket connections on /tunnel/{tunnelId} by default.
var app = builder.Build();
app.MapTunnelHost();
app.Run();The sample projects demonstrate how to use UFX.Relay to connect a cloud application to an on-premise application with simple association of agents using a static RelayId, this in effect creates a static tunnel between the client and server. Once the sample Client and Server projects have started requests to https://localhost:7200/ will be forwarded to the client application.
The sample server hosts on https://localhost:7200/ and the client hosts on https://localhost:7100.
The sample client opens a websocket connection to the server using wss://localhost:7200/relay/123
Example responses can be tested as follows:
A request to https://localhost:7200/server is handled by the server and a request to https://localhost:7200/client is forwarded to the client and returned via the server.
The minimal configuration for the client is as follows:
builder.WebHost.AddTunnelListener(options => { options.DefaultTunnelId = "123"; });
builder.Services.AddTunnelClient(options =>
options with
{
TunnelHost = "wss://localhost:7200"
});This will create a Kestrel Listener that will inject requests (from the forwarder) into the client ASPNet Core pipeline received over the WebSocket connection to the server (i.e. wss://localhost:7200)
When a code based listener is added to Kestrel it will disable the use of the default Kestrel listener configuration derived from ASPNETCORE_URLS environment variable and -url command line argument. If you require the default listener to be enabled you can set the includeDefaultUrls parameter to true as follows:
builder.WebHost.AddTunnelListener(options =>
{
options.DefaultTunnelId = "123";
}, includeDefaultUrls: true);The sample uses a simple association of agents using a static TunnelId '123'
When using UFX.Relay Client with a Blazor app behind a reverse proxy, additional configuration is needed.
In Program.cs, configure forwarded headers:
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.All,
// Optional: Add KnownProxies / KnownNetworks for secure forwarding
});In App.razor, ensure correct resolution of relative URLs:
<!--
We need to set the base, so that the app can resolve relative URLs correctly.
This needs to be done dynamically, as we can access the app via the reverse-proxy-tunnel and via the local http-endpoint
-->
<base href="@(NavigationManager.BaseUri)" />The minimal configuration for the server is as follows:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTunnelForwarder();
var app = builder.Build();
app.MapTunnelHost();
app.Run();Request sent to the server with a TunnelId header will be forwarded to the corresponding listener that connects with the same TunnelId. If a DefaultTunnelId is set in the configuration then requests without a TunnelId header will be forwarded to the listener with the DefaultTunnelId. Combining this with the DefaultTunnelId option in the listener configuration allows for simple association of an agent using a static TunnelId statically linking the forwarder and listener.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTunnelForwarder(options =>
{
options.DefaultTunnelId = "123";
});
builder.Services.AddRelayHost();
var app = builder.Build();
app.MapTunnelHost();
app.Run();It is also possible to use a transformer (courtesy of YARP) to modify the behaviour of the Forwarder, the following example demonstrates how to use the transformer to enable the default forwarders (i.e. Forwarded-For):
builder.Services.AddTunnelForwarder(options =>
{
options.Transformer = transformBuilderContext =>
{
transformBuilderContext.UseDefaultForwarders = true;
};
});To extract the TunnelId from a path segment and transform the request path:
TunnelPathPrefixTransformer prefixTransformer = new TunnelPathPrefixTransformer("ufx");
builder.Services.AddTunnelForwarder(options =>
{
options.DefaultTunnelId = "123";
options.TunnelIdFromContext = prefixTransformer.GetTunnelIdFromContext;
options.Transformer = context =>
{
// Remove /ufx/{tunnelId} from the request path before forwarding
context.RequestTransforms.Add(prefixTransformer);
};
});This enables forwarding using URLs like /ufx/{tunnelId}/.
Important
Ensure to set UseForwardedHeaders and base href in the Blazor app as mentioned above.
To configure TunnelClientOptions at runtime in Blazor Server, the preferred method is to use the built-in ITunnelClientOptionsStore. This allows you to declaratively modify the tunnel settings (such as TunnelHost, TunnelId and IsEnabled) based on user input or query parameters.
@inject ITunnelClientOptionsStore tunnelClientOptionsStore
// ...
@code {
// ...
protected override void OnInitialized()
{
var options = tunnelClientOptionsStore.Current;
tunnelHost = options.TunnelHost;
tunnelId = options.TunnelId;
isEnabled = options.IsEnabled;
// ...
}
private void Apply()
{
tunnelClientOptionsStore.Update(current =>
{
current.TunnelHost = tunnelHost;
current.TunnelId = tunnelId;
current.IsEnabled = isEnabled;
return current;
});
}
}This enables full user-driven control over the tunnel connection in the UI. The tunnel will automatically reconnect based on the updated settings.
The ITunnelClientManager interface provides information about the current tunnel connection, including real-time updates on state changes.
The available connection states are:
DisconnectedConnectingConnectedError
Additionally, you can access the last connection error message (if any) and react to state changes.
@inject ITunnelClientManager tunnelClientManager
<div>
<strong>Connection State:</strong> @connectionState.ToString()
</div>
@if (!string.IsNullOrEmpty(tunnelClientManager.LastConnectErrorMessage))
{
<div class="text-danger">
<strong>Message: </strong>@tunnelClientManager.LastConnectErrorMessage
</div>
}
@code {
private TunnelConnectionState connectionState;
protected override void OnInitialized()
{
connectionState = tunnelClientManager.ConnectionState;
tunnelClientManager.ConnectionStateChanged += OnConnectionStateChanged;
}
private void OnConnectionStateChanged(object? sender, TunnelConnectionState newState)
{
connectionState = newState;
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
tunnelClientManager.ConnectionStateChanged -= OnConnectionStateChanged;
}
}This allows your Blazor components to reflect the live tunnel status, providing a reactive and user-friendly experience.
The client WebSocket can be configured for authentication, for example setting an Authorization header to authenticate the WebSocket connection to the server as follows:
builder.Services.AddTunnelClient(options =>
{
Action<ClientWebSocketOptions> socketOptions = wsOptions =>
{
wsOptions.SetRequestHeader("Authorization", "ApiKey 123");
};
return options with
{
WebSocketOptions = socketOptions,
TunnelHost = "wss://localhost:7200",
TunnelId = "123"
};
});Or if you also want to get the response body from the faulty WebSocket connection with LastErrorResponseBody, you must specify the authorization header as follows:
builder.Services.AddTunnelClient(options =>
{
Dictionary<string, string> requestHeaders = new()
{
{ "Authorization", "ApiKey 123" }
};
return options with
{
RequestHeaders = requestHeaders,
TunnelHost = "wss://localhost:7200",
TunnelId = "123"
};
});The Tunnel Host can be configured to require Authentication for the WebSocket connection from the Tunnel Client using standard Minimal API middleware configuration as follows:
app.MapTunnelHost().RequireAuthorization();Connection aggregation helps when there are a large number of idle connections (such as WebSockets) that need to be maintained. Azure Web PubSub is an example of a cloud service that provides WebSocket connection aggregation. Inverting the WebSocket connection direction of UFX.Relay provides an equivalent capability to Azure Web PubSub but with the added benefit of being self-contained and not requiring a 3rd party service. Typically, the Forwarder would be hosted on the cloud and the listener on-prem, this allows for the cloud application to connect to the on-prem application. However, it is possible to have the forwarder on-prem and the listener in the cloud while still using an out-bound WebSocket connection from the on-prem instance to the cloud thus allowing for connection aggregation of multiple on-prem connections via a single connection to a cloud service.
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.AddTunnelListener(options =>{ options.DefaultTunnelId = "123"; });
var app = builder.Build();
app.MapTunnelHost();
app.Run();var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTunnelForwarder(options => { options.DefaultTunnelId = "123"; });
builder.Services.AddTunnelClient(options =>
options with
{
TunnelHost = "wss://localhost:7100",
TunnelId = "123"
});
var app = builder.Build();
app.MapTunnelForwarder();
app.Run();- Scaling across multiple instances of the cloud service could be achieved by using Microsoft.Orleans to store the TunnelId to instance mapping and redirect clients to the correct instance where the client is connected.
- Add an example of client certificate authentication for the WebSocket connection.
- Consider adding TCP/UDP Forwarding over the tunnel