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

SSH & Quick Tunnels

NuGet package Shiny.Net.HttpServer.Ssh
Terminal window
dotnet add package Shiny.Net.HttpServer.Ssh

ssh -R, in library form, over SSH.NET. Pure managed code, so it runs on iOS and Android where an agent-based tunnel cannot — which is the whole reason this exists rather than a wrapper around ngrok.

The device opens an ordinary outbound SSH connection and asks the server to forward a remote port back down it. Nothing has to connect to the device, which is what makes it work from behind carrier-grade NAT.

The zero-ceremony version: no account, nothing installed, no infrastructure.

await app.RunQuickTunnelAsync(url => Console.WriteLine($"Reachable at {url}"));
Host What it gives you What it needs
QuickTunnelHost.Pinggy (default) pinggy.io — a fresh *.pinggy-free.link address, reported in about two seconds Nothing. It wants a key but not a registered one, so UseEphemeralKey generates one in memory
QuickTunnelHost.Sish The public sish at tuns.sh — derives the subdomain from your key, so the same key gets the same address A key enrolled at pico.sh. An unknown key is refused outright
QuickTunnelHost.LocalhostRun localhost.run — forwards fine, but cannot report its own address here (see below) A localhost.run account with a custom domain, set as PublicUrl
QuickTunnelHost.Serveo serveo.net — same idea, longest-running A key, and patience: it is frequently unreachable for days

Anonymous pinggy tunnels expire after 60 minutes. Pass an access token as the subdomain argument to lift that — pinggy carries the token in the SSH username, so that argument is where it goes.

Sish is the one to use when the URL goes on a label or into a customer’s bookmark, since the address follows the key rather than changing every run.

builder.Services.AddHttpServer(o => o.Port = 8080, autoStart: false);
builder.Services.AddQuickTunnel(); // pinggy, nothing to configure
builder.Services.AddQuickTunnel(QuickTunnelHost.Sish, subdomain: "my-device");

autoStart on the tunnel defaults to off, which is almost always right: putting a server on the public internet is a decision a person makes by tapping something, not a side effect of the app launching.

public sealed class ShareViewModel(QuickTunnel tunnel)
{
public async Task ShareAsync() => await tunnel.StartAsync();
// …
tunnel.PropertyChanged += (_, _) => MainThread.BeginInvokeOnMainThread(() =>
{
this.Url = tunnel.PublicUrl;
this.Status = tunnel.State.ToString();
});
}

QuickTunnel is INotifyPropertyChanged with PublicUrl, State (Stopped/Connecting/Connected/Reconnecting/Failed) and LastError.

A VPS with a stable hostname, your own TLS, and permitlisten restricting the key to one port:

builder.Services.AddSshTunnel(o =>
{
o.Host = "tunnel.example.com";
o.Username = "tunnel";
o.PrivateKeyPath = keyPath;
o.RemoteBindAddress = "0.0.0.0";
o.RemotePort = 8080;
o.PublicUrl = "https://device-1.example.com";
o.HostKeyFingerprints.Add("SHA256:47DEQpj8HBSa+…");
});

Resolve SshTunnel and call StartAsync/StopAsync, or leave autoStart: true (the default here) to run it with the host.

For a console app, drive the provider directly:

var provider = new SshTunnelProvider(options, logger);
await app.RunTunnelAsync(provider, logger, cancellationToken);
Option Default Notes
Host / Port / Username — / 22 / —
PrivateKeyPath / PrivateKey / PrivateKeyPassPhrase null PrivateKey is bytes, for a key kept in the keychain or an embedded resource
Password null Prefer a key
RemoteBindAddress localhost 0.0.0.0 exposes it directly and needs GatewayPorts on the server
RemotePort 0 Zero asks the server to allocate one; read it back from SshTunnelProvider.RemotePort
PublicUrl null The address the world will use, when you know it up front
CaptureUrlFromSession false See below
UrlPattern / UrlCaptureTimeout first https:// / 15s
HostKeyFingerprints / AcceptAnyHostKey empty / false See below
ConnectTimeout 30 seconds
KeepAliveInterval 30 seconds Carriers drop idle NAT mappings in a minute or two
AutoReconnect / ReconnectDelay / MaxReconnectDelay true / 2s / 2m Backoff doubles
LocalPort 0 Ephemeral, which is what you want

RemoteBindAddress = "localhost" keeps the forwarded port private to the server, which is right when a reverse proxy on that box terminates TLS and forwards to it. Hosted tunnels want localhost with RemotePort 80.

CaptureUrlFromSession reads the address a hosted tunnel assigns, which these providers print on the session channel and nowhere else. It is off by default, because a server you own has no such banner and opening a shell channel on it is pointless — the quick-tunnel presets turn it on for you.

UrlPattern picks the URL out of that output. Give it a pattern that only the tunnel address can satisfy. The built-in default takes the first https:// it sees, which is fine for a provider that prints one line and wrong for every provider that greets you first: they open with links to their own documentation, dashboard and social media, and on localhost.run that greeting arrives on the channel’s error stream, ahead of the address, in the same read. Each quick-tunnel preset ships a pattern anchored to its own domain — copy that approach for a provider of your own:

o.UrlPattern = new Regex(@"https://[a-z0-9-]+\.tunnel\.example\.com", RegexOptions.IgnoreCase);

UrlCaptureTimeout (15s) bounds the whole capture, including opening the session channel — which is a blocking call inside SSH.NET that waits for the server to confirm the request. If nothing matches in that window, PublicUrl stays null and a warning is logged. It is not filled in with a guess: for a hosted tunnel, http://{host}:{port} is a link to the provider’s own front page, and an app would happily display it as though it were yours.

The provider owns a private ephemeral loopback listener and points the remote forward at it, so the app binds no port of its own and the whole thing plugs into RunTunnelAsync like any other ITunnelProvider. Accepted connections report ctx.Connection.IsTunneled.

Reconnect with backoff is on by default, because a phone changing networks kills the tunnel underneath it.

SshTunnelProvider also exposes IsConnected, RemotePort, and the ConnectivityChanged / PublicUrlChanged events that QuickTunnel surfaces as bindable properties.

Everything in Tunnelling → Security applies. A quick tunnel in particular hands a public HTTPS address to anyone who learns it, on a server whose defaults were chosen for loopback — put authentication in front of it first.