Skip to content
Shiny.NET

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
ControlAddress / PublicAddress null — each listener uses Address
ControlPort / PublicPort 5050 / 8080
Domain localhost — abc123.example.com
PublicScheme / IncludePortInPublicUrl http / true
ControlHttps / PublicHttps null
Authorize Default: accepts any token matching Token, grants the requested subdomain when free
AuthorizeTimeout 15s — Authorize running longer refuses the registration
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. It is asynchronous, so it can look the token up in a database or ask an identity service:

o.Authorize = async (request, ct) =>
(await devices.FindByTokenAsync(request.Token, ct))?.Subdomain;

The token it receives is cancelled after AuthorizeTimeout or when the relay stops. An exception refuses the registration with the same message as any other refusal, and is logged; the client is never told why. When the relay itself grants a subdomain it checks it is free, but a subdomain returned by Authorize is taken as given: a clash with a live tunnel is refused.

When a proxy on the same machine already answers 80/443 and terminates TLS, give the relay’s two listeners different interfaces. The control port is public so devices can dial in; the public port is loopback so only the proxy reaches it:

new RelayServerOptions
{
ControlAddress = IPAddress.Any,
ControlPort = 7443,
ControlHttps = controlTls,
PublicAddress = IPAddress.Loopback,
PublicPort = 5080,
PublicScheme = "https",
IncludePortInPublicUrl = false, // the proxy is on 443
AddForwardedHeaders = false // the proxy sends X-Forwarded-*; the relay would only see itself
};

Authorize can advertise one tunnel under a different scheme than PublicScheme by setting request.PublicScheme, for example http for a host the proxy has no certificate for yet. It is used in the URL handed to the client, in RelayTunnel.PublicScheme, and in the X-Forwarded-Proto the relay adds. Only http and https are accepted; anything else refuses the registration.

Authorize can tag the tunnel it grants by setting request.State — a key id, an account — and the tag travels with the tunnel as RelayTunnel.State. The raw token never has to leave the hook.

o.Authorize = async (request, ct) =>
{
var key = await keys.FindAsync(request.Token, ct);
if (key is null || key.Revoked)
return null;
request.State = key.Id;
return request.RequestedSubdomain ?? key.DefaultSubdomain;
};
relay.TunnelConnected += (_, e) => log.Opened(e.Tunnel.Host, e.Tunnel.State, e.Tunnel.RemoteEndPoint);
relay.TunnelDisconnected += (_, e) => log.Closed(e.Tunnel.Host, e.Reason); // ConnectionClosed | Disconnected | RelayStopped
// Revoking a key: refuse it first, then close what it holds
await keys.RevokeAsync(keyId);
await relay.DisconnectAsync(t => Equals(t.State, keyId));
  • Tunnels is a snapshot of what is registered: host, public URL, client address, connection time and state. DisconnectAsync(host) closes one tunnel and every public request in flight on it.
  • TunnelDisconnected is raised exactly once for every tunnel TunnelConnected reported, however it ended, and StopAsync raises it for every open tunnel before returning. Both events run on the tunnel’s own task — keep handlers short. An exception from a handler is logged and does not affect the tunnel.
  • A client with a ReconnectDelay dials back after being disconnected and goes through Authorize again. Make Authorize refuse it before calling DisconnectAsync, or it is simply back a moment later.

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.