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 tunnelsThe 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 customers bookmark, since the
address follows the key rather than changing every run.
It never confirms the SSH session request that carries the assigned URL. The ssh binary does not
wait for that confirmation and prints the address anyway; SSH.NET does wait, and there is no
supported way around it the channel types are internal to the library. So localhost.run works
only when you already know where it answers:
builder.AddQuickTunnel( QuickTunnelHost.LocalhostRun, configure: o => o.PublicUrl = "https://my-device.example.com" // your custom domain);Without a PublicUrl, StartAsync returns null, State goes to Failed, and LastError says
so. It does not invent an address.
In an app with a UI
Section titled In an app with a UIbuilder.Services.AddShinyHttpServer(http => http.Options.Port = 8080, autoStart: false);builder.AddQuickTunnel(); // pinggy, nothing to configurebuilder.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 free tunnel assigns a different address on every reconnect, and a phone reconnects whenever it
changes network. An app that captured the URL once and painted it on a label would be showing a dead
link minutes later. PublicUrl is cleared the instant the connection drops rather than left stale.
The events fire on a background thread. Marshal to the UI thread yourself MAUI will not do it for you.
A host you own
Section titled A host you ownA VPS with a stable hostname, your own TLS, and permitlisten restricting the key to one port:
builder.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 checkedSSH.NET trusts any host key by default. This provider does not: connecting without either a pinned
SHA-256 fingerprint or an explicit AcceptAnyHostKey fails, with a message telling you how to
find the fingerprint:
ssh-keyscan -p 22 tunnel.example.com | ssh-keygen -lf -An unverified host key means anything between the device and the server can pose as the server and
a tunnel exists precisely to carry traffic across networks you do not control. AcceptAnyHostKey is
reasonable while you are finding the fingerprint to pin, and not reasonable in something you ship.
Reading an assigned URL
Section titled Reading an assigned URLCaptureUrlFromSession 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
channels 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 providers own front page, and an app
would happily display it as though it were yours.
How it plugs in
Section titled How it plugs inThe 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 oneEverything 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.