Skip to content
Shiny.NET

SSH & Quick Tunnels

GitHub GitHub stars for shinyorg/aspnet
Downloads NuGet downloads for Shiny.AspNet.Ssh
Frameworks
ASP.NET
Terminal window
dotnet add package Shiny.AspNet.Ssh

ssh -R, in library form, over SSH.NET. Pure managed code — nothing to install, unlike the cloudflared/ngrok/Tailscale agents.

The app opens an ordinary outbound SSH connection and asks the server to forward a remote port back down it, to the address Kestrel is listening on. Nothing has to connect to the machine running the app, which is what makes it work from behind NAT or a firewall that allows no inbound traffic.

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

builder.Services.AddQuickTunnel(autoStart: builder.Environment.IsDevelopment());
var app = builder.Build();
var tunnel = app.Services.GetRequiredService<QuickTunnel>();
tunnel.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(QuickTunnel.PublicUrl))
app.Logger.LogInformation("Public at {Url}", tunnel.PublicUrl);
};
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 is shared with someone else for longer than a session, since the address follows the key rather than changing every run.

autoStart defaults to off: putting an app on the public internet through someone else’s server should be a decision, not a side effect of adding a package. Leave it off and call QuickTunnel.StartAsync() when it is wanted — from an admin endpoint, a CLI flag, or only in Development as above.

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://app-1.example.com";
o.HostKeyFingerprints.Add("SHA256:47DEQpj8HBSa+…");
});

autoStart is on by default here: the tunnel opens once Kestrel is listening and closes when the app stops. Pass autoStart: false, resolve SshTunnel, and call StartAsync/StopAsync to control it yourself. SshTunnel exposes PublicUrl, RemotePort, IsConnected, and the ConnectivityChanged / PublicUrlChanged events.

Without DI, after app.StartAsync():

await using var tunnel = new SshTunnel(app.Services.GetRequiredService<IServer>(), options, loggerFactory);
var url = await tunnel.StartAsync();
Option Default Notes
Host / Port / Username — / 22 / —
PrivateKeyPath / PrivateKey / PrivateKeyPassPhrase null PrivateKey is bytes, for a key kept in Key Vault, a Kubernetes secret or user secrets
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 SshTunnel.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 NAT mappings are dropped after a minute or two of silence
AutoReconnect / ReconnectDelay / MaxReconnectDelay true / 2s / 2m Backoff doubles
LocalPort / LocalHost 0 / 127.0.0.1 Zero reads the port off Kestrel — see below

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

There is no listener of its own — Kestrel is the listener. SSH.NET forwards to a host and port, so the forward is pointed at the first http:// address Kestrel bound, read after it started. That means http://127.0.0.1:0, --urls and ASPNETCORE_URLS all just work: the real port is read off the running server, not off configuration. Wildcard and localhost bindings are reached over 127.0.0.1.

The registrations start the tunnel from IHostedLifecycleService.StartedAsync. That detail matters: with WebApplication, services added to builder.Services start before Kestrel, so a plain hosted service would find nothing to forward to. Shutdown closes the tunnel in StoppingAsync, before Kestrel stops answering behind it.

Forwarded requests arrive as ordinary loopback connections:

  • HttpContext.Connection.RemoteIpAddress is the forward’s loopback end, not the caller — SSH does not carry the original address.
  • Request.Scheme is http even when the public edge is HTTPS, so UseHttpsRedirection will redirect tunnelled requests. If the tunnel (or a proxy behind it) sends X-Forwarded-* headers, use UseForwardedHeaders with the loopback address as a known proxy.

A quick tunnel hands a public HTTPS address to anyone who learns it. Put authentication and rate limiting in front of the app first.

claude plugin marketplace add shinyorg/skills
claude plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

View shiny-aspnet Skill