Skip to content
Shiny.Net.HttpServer v1 - A lightweight feature rich HTTP Server - Tunnels, Websockets, AOT, ASPNET Featureset, & Works EVERYWHERE!Let me see!

Tunnelling

A server embedded in a phone app has no reachable address. It sits behind NAT, behind a carrier’s CGNAT, on a network that changes when the user walks out of the building. Nothing can connect in.

A tunnel solves that by dialling out: the device opens a connection to something with a public address, and requests arrive back down it.

var provider = new RelayTunnelProvider(new RelayTunnelOptions
{
Host = "relay.example.com",
Token = token,
Subdomain = "my-phone"
});
await app.RunTunnelAsync(provider, logger, cancellationToken);

ITunnelProvider is an IConnectionListener, and that is the whole design. From the server’s point of view a tunnel is just another way for connections to show up, so nothing above the transport knows a tunnel exists — the same routes, middleware, authorization, results and generated endpoints serve tunnelled requests unchanged.

public interface ITunnelProvider : IConnectionListener
{
string Name { get; }
string? PublicUrl { get; } // available once BindAsync has completed
}

RunTunnelAsync opens the tunnel, serves everything arriving through it, and drains in-flight connections on shutdown. It composes with StartAsync rather than replacing it: a server can listen locally and answer tunnelled requests at the same time, from the same routes. Used on its own, no local port is bound at all — which is what an embedded server on a phone usually wants.

Tunnelled connections report ctx.Connection.IsTunneled, count against MaxConcurrentConnections like any other, and are drained by a graceful shutdown exactly like local ones.

Provider Package Needs Good for
Quick tunnel …​.Ssh Nothing at all Demos, support, showing someone a device
SSH …​.Ssh A host you can log into A stable address you control
Azure Relay …​.AzureRelay An Azure Relay namespace A managed public HTTPS endpoint
Relay (below) core A VPS running the relay Full control, multi-tenant, your own protocol

Both ends live in the core package, under Shiny.Net.HttpServer.Tunneling: a 9-byte frame protocol (type / stream id / length) and TunnelChannel, the shared framing pump both ends use.

var provider = new RelayTunnelProvider(
new RelayTunnelOptions
{
Host = "relay.example.com",
Port = 5050,
Token = token,
Subdomain = "my-phone",
UseTls = true
},
logger
);
Option Default Notes
Host / Port localhost / 5050 The control port, not the public one
Token null Presented at registration
Subdomain null Leave null and the relay assigns one
UseTls true Turn off only for local testing
ServerCertificateValidation null For a relay behind a private CA
HandshakeTimeout 15 seconds
KeepAliveInterval 30 seconds Mobile networks drop idle connections aggressively
ReconnectDelay 3 seconds null to fail instead of reconnecting

The client dials out over TCP (plus optional TLS), registers with its token and requested subdomain, and unpacks each inbound stream into a connection handed to the server. Outbound-only, so it works from a cell network behind CGNAT.

var relay = new RelayServer(
new RelayServerOptions
{
ControlPort = 5050,
PublicPort = 443,
Domain = "example.com",
PublicScheme = "https",
ControlHttps = new HttpsOptions { Certificate = certificate },
PublicHttps = new HttpsOptions { Certificate = certificate },
Token = token
},
loggerFactory
);
await relay.StartAsync(cancellationToken);
Console.WriteLine($"Control : {relay.ControlUrl}");
Console.WriteLine($"Public : {relay.PublicUrl}");

Two listeners: a control port where clients register, and a public port where the world arrives. Requests are routed to a tunnel by their Host header.

Option Default
Address Loopback — an unconfigured relay is not public
ControlPort / PublicPort 5050 / 8080
Domain localhostabc123.example.com
PublicScheme / IncludePortInPublicUrl http / true
ControlHttps / PublicHttps null
Authorize Default: accepts any token matching Token, grants the requested subdomain when free
Token null — accepts anything
MaxTunnels 100
MaxRequestHeadSize 32 KB
AddForwardedHeaders true
RequestHeadTimeout / KeepAliveTimeout 15s / 130s

Authorize is the hook for a real registry — return the subdomain to grant, or null to refuse:

o.Authorize = request => devices.Lookup(request.Token)?.Subdomain;

The relay reads every request head, not just the first, so it can route by Host — and reads enough framing (Content-Length, chunked) to know where each body ends.

That is what makes connection reuse safe: a connection is pinned to the tunnel its first request named, and a later request on it for a different host gets 421 Misdirected Request rather than being delivered to another tenant. Browsers never hit that, because their connection pools are keyed by authority.

The relay injects X-Forwarded-For, -Proto and -Host. The tunnelled server still has to opt into trusting them:

builder.Configure(o => o.UseForwardedHeaders = true);

Without the opt-in, Request.Scheme reflects the tunnel’s own transport and the client IP is the relay’s. With it, Request.Scheme is what the public caller used — which matters, because Basic authentication refuses to run over an unencrypted connection and a tunnel’s public leg is the one that is actually TLS.

A tunnel makes a device-local server reachable by anyone who learns the URL. Before opening one:

  • Put authentication in front of everything, and consider SetFallbackPolicy so a route added later is protected by default.
  • Turn on rate limiting. The address is public, and so are the scanners.
  • Assume the URL is public knowledge. A free tunnel’s hostname is not a secret.