[ Web Proxy ]
URL:
Viewing: https://shinylib.net/httpserver/ssh/ [Back]  [Original]

SSH & Quick Tunnels | Shiny.NETSkip to content
Search
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

SSH & Quick Tunnels

NuGet package Shiny.Net.HttpServer.Ssh [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 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.

builder.Services.AddShinyHttpServer(http => http.Options.Port = 8080, autoStart: false);
builder.AddQuickTunnel(); // pinggy, nothing to configure
builder.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 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);
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.

SSH.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:

Terminal window
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.

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 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.

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.


Web Proxy Viewer  |  New URL  |  Original Page