SSH & Quick Tunnels
dotnet add package Shiny.Net.HttpServer.Sshssh -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.
Quick tunnels
Section titled “Quick tunnels”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.
In an app with a UI
Section titled “In an app with a UI”builder.Services.AddHttpServer(o => o.Port = 8080, autoStart: false);builder.Services.AddQuickTunnel(); // pinggy, nothing to configurebuilder.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 host you own
Section titled “A host you own”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);Options
Section titled “Options”| 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.
Host keys are checked
Section titled “Host keys are checked”Reading an assigned URL
Section titled “Reading an assigned URL”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.
How it plugs in
Section titled “How it plugs in”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.
Before you open one
Section titled “Before you open one”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.


