Skip to content

Latest commit

 

History

History

README.md

Shiny.Net.HttpServer.Ssh

Publishes an embedded HttpServer through SSH remote port forwarding — ssh -R, in library form.

The device opens an ordinary outbound SSH connection and asks the server to forward a port back down it. Nothing connects to the device, which is what makes it work on a phone: a cellular device sits behind carrier-grade NAT with no routable address and no port you could forward.

curl ──▶ https://device-1.example.com          (nginx/Caddy, TLS)
                    │
              127.0.0.1:8080                   (the forwarded port on your VPS)
                    │  ssh -R, opened by the device
                    ▼
          127.0.0.1:{ephemeral}                (loopback socket this package owns)
                    │
            your app's HttpServer

The tunnel binds its own ephemeral loopback socket and hands what arrives to the server, so the app never binds a port of its own. Only processes on the device can reach that socket.

Three ways to use it. In a hurry, skip to the one-liner: a public URL on a phone with no account and nothing installed. Otherwise: a server you own (a $5 VPS — stable hostname, your TLS, your rules) or a hosted tunnel configured by hand.


A. A server you own

1. Create a user that can do nothing but forward

Do not tunnel as a user with a shell. A forwarding account needs no shell, no filesystem, no commands — and if the device is compromised, that limit is the only thing standing between the attacker and your box.

sudo adduser --system --shell /usr/sbin/nologin --no-create-home tunnel
sudo mkdir -p /home/tunnel/.ssh && sudo chown -R tunnel: /home/tunnel

2. Give the device a key

On the device's build machine (never on the server):

ssh-keygen -t ed25519 -f device-1 -N '' -C 'device-1 tunnel'

Add the public key to the server, restricted to exactly what it needs — no shell, no agent, no X11, and only the one port it may bind:

# /home/tunnel/.ssh/authorized_keys
restrict,port-forwarding,permitlisten="127.0.0.1:8080" ssh-ed25519 AAAAC3Nza… device-1

restrict denies everything and then port-forwarding adds back the one capability needed. permitlisten stops a stolen key from binding any port other than 8080. One key and one port per device.

3. Allow remote forwarding in sshd

# /etc/ssh/sshd_config
AllowTcpForwarding remote      # remote (-R) only; the device never needs -L
GatewayPorts no                # forwarded ports bind loopback, not the internet

ClientAliveInterval 30         # notice a dead device and free its port
ClientAliveCountMax 2

Match User tunnel
    PermitTTY no
    X11Forwarding no
    AllowAgentForwarding no
sudo sshd -t && sudo systemctl reload ssh

GatewayPorts no is the right default: the forwarded port stays on loopback and a reverse proxy in front of it terminates TLS. Setting GatewayPorts yes (or clientspecified) publishes the port on the public interface directly — plaintext HTTP on an odd port, no certificate. Only do that for a throwaway.

4. Terminate TLS in front of it

With Caddy, which gets a certificate on its own:

device-1.example.com {
    reverse_proxy 127.0.0.1:8080
}

Or nginx:

server {
    server_name device-1.example.com;
    listen 443 ssl;                                # certbot fills in the cert lines

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSockets and SSE, if you serve them
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 1h;
        proxy_buffering off;                       # SSE needs this
    }
}

5. Pin the host key

Get the fingerprint from a machine you trust, ideally the server itself:

# on the server
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

# or remotely, if you accept the first-contact risk
ssh-keyscan -t ed25519 tunnel.example.com | ssh-keygen -lf -

Both print 256 SHA256:47DEQpj8HBSa+… no comment (ED25519). The SHA256:… part is what you pin.

6. Wire it up

dotnet add package Shiny.Net.HttpServer.Ssh
using Shiny.Net.HttpServer;
using Shiny.Net.HttpServer.Ssh;

// The tunnel is the only way in here, so the server is never started locally.
var server = new HttpServer();
server.MapGet("/api/widgets", ctx => Results.Ok(widgets, AppJson.Default.WidgetArray));

var tunnel = new SshTunnelProvider(new SshTunnelOptions
{
    Host = "tunnel.example.com",
    Username = "tunnel",
    PrivateKey = await SecureStorage.GetKeyBytesAsync(),   // wherever you keep it
    RemoteBindAddress = "127.0.0.1",                       // matches permitlisten
    RemotePort = 8080,
    PublicUrl = "https://device-1.example.com",            // what the proxy answers on
    HostKeyFingerprints = { "SHA256:47DEQpj8HBSa+…" }
});

await server.RunTunnelAsync(tunnel, cancellationToken: token);

Because a proxy sits in front, tell the server to believe its forwarding headers — otherwise every request looks like it came from 127.0.0.1 over plain HTTP:

var server = new HttpServer(new HttpServerOptions { UseForwardedHeaders = true });

Only with a proxy you control. The header is a claim by whoever sent it, and believing it from an arbitrary client lets any caller pick its own IP.


The one-liner: a public URL with no account

For a phone that needs to hand someone a link, none of the setup above is required:

builder.Services.AddHttpServer(configureServer: s => s.MapGet("/",), autoStart: false);
builder.Services.AddQuickTunnel();          // pinggy by default; nothing to sign up for

QuickTunnel implements INotifyPropertyChanged, so a view binds straight to it:

public sealed class SharingViewModel : INotifyPropertyChanged
{
    readonly QuickTunnel tunnel;

    public SharingViewModel(QuickTunnel tunnel)
    {
        this.tunnel = tunnel;

        // Raised on a background thread. MAUI will not marshal this for you.
        tunnel.PropertyChanged += (_, _) => MainThread.BeginInvokeOnMainThread(() =>
        {
            this.Url = tunnel.PublicUrl;              // show it, or render it as a QR code
            this.Status = tunnel.State.ToString();
            this.OnPropertyChanged(nameof(this.Url));
            this.OnPropertyChanged(nameof(this.Status));
        });
    }

    public string? Url { get; private set; }
    public string? Status { get; private set; }

    public Task ShareAsync() => this.tunnel.StartAsync();
    public Task StopSharingAsync() => this.tunnel.StopAsync();
}

Bind to PublicUrl; do not read it once. A free tunnel assigns a different address on every reconnect, and a phone reconnects whenever it changes network. An app that painted the first URL on a label would be showing a dead link a few minutes later. State goes ConnectingConnectedReconnectingConnected, and PublicUrl is cleared the moment the connection drops — showing nothing beats showing a link that no longer works.

For a console app or a sample:

await app.RunQuickTunnelAsync(url => Console.WriteLine($"Reachable at {url}"));

Four hosts are preset, and they are not interchangeable:

Host Needs
QuickTunnelHost.Pinggy (default) Nothing. It wants a key but not a registered one, so UseEphemeralKey generates one in memory. Anonymous tunnels expire after 60 minutes; pass an access token as the subdomain argument to lift that
QuickTunnelHost.Sish A key enrolled at pico.sh, which tuns.sh checks — an unknown key is refused. Derives the subdomain from the key, so the same key gets the same address: worth having if the URL goes on a label or into someone's bookmarks
QuickTunnelHost.LocalhostRun A localhost.run account with a custom domain, set as PublicUrl — see below
QuickTunnelHost.Serveo A key, and luck: it is frequently unreachable for days at a time

StartAsync returns null when the tunnel came up without an address. Check for it and show LastError — a non-throwing call is not the same as a working link.

localhost.run cannot report its own address here

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 the channel types needed to skip the wait are internal to it. So this preset is only usable when you already know where it answers:

builder.Services.AddQuickTunnel(
    QuickTunnelHost.LocalhostRun,
    configure: o => o.PublicUrl = "https://my-device.example.com"
);

What you are accepting. These hosts publish no stable key to pin, so AcceptAnyHostKey is on, and your traffic passes through someone else's server. That is the trade for zero setup. For anything you would mind being logged, run your own sish or use the VPS setup below — the same QuickTunnel API points at either.

B. A hosted tunnel

Nothing to run, a URL assigned to you, and traffic through a third party. Fine for development, a demo, or a webhook you are debugging. For anything you would be upset to see logged, run your own.

pinggy

No account and no key to provision — it takes any key, and UseEphemeralKey makes one:

var tunnel = new SshTunnelProvider(new SshTunnelOptions
{
    Host = "a.pinggy.io",
    Port = 443,
    Username = "a",                     // or your access token
    UseEphemeralKey = true,
    RemoteBindAddress = "localhost",
    RemotePort = 0,                     // it assigns the port as well as the name
    CaptureUrlFromSession = true,
    UrlPattern = new Regex(@"https://[a-z0-9.-]+\.(?:free\.pinggy\.net|pinggy-free\.link)"),
    AcceptAnyHostKey = true
});

await tunnel.BindAsync(token);
Console.WriteLine(tunnel.PublicUrl);    // https://xxxxx-1-2-3-4.free.pinggy.net

sish

Public instance at tuns.sh, which is run by pico.sh and needs a key enrolled there — an unknown key is refused outright. Self-host the container and it takes any key. Either way it derives your subdomain from the key, so the same key gets the same URL:

var tunnel = new SshTunnelProvider(new SshTunnelOptions
{
    Host = "tuns.sh",
    Username = "anything",
    PrivateKeyPath = keyPath,
    RemoteBindAddress = "device-1",     // requested subdomain
    RemotePort = 80,
    CaptureUrlFromSession = true,       // sish prints the assigned URL
    HostKeyFingerprints = { "SHA256:…" }
});

await tunnel.BindAsync(token);
Console.WriteLine(tunnel.PublicUrl);    // https://device-1.tuns.sh

localhost.run

Authenticates nobody, and — as above — will not tell this library the URL it assigned. Give it one you already own:

new SshTunnelOptions
{
    Host = "localhost.run",
    Username = "nokey",
    RemoteBindAddress = "localhost",
    RemotePort = 80,
    PublicUrl = "https://my-device.example.com",   // your localhost.run custom domain
    AcceptAnyHostKey = true                       // no stable key to pin
}

Serveo

new SshTunnelOptions
{
    Host = "serveo.net",
    Username = "device-1",
    RemoteBindAddress = "device-1",     // requested subdomain
    RemotePort = 80,
    CaptureUrlFromSession = true,
    AcceptAnyHostKey = true
}

CaptureUrlFromSession is how you learn where you landed: these providers print the URL on the session channel once forwarding is up, and there is no other way to know — the address is the server's to choose. The channel is left open afterwards, because some providers tear the forward down with it.

Anchor UrlPattern to the provider's own tunnel domain. The built-in default takes the first https:// in the output, 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 documentation, dashboard and social media, and on localhost.run that greeting arrives on the channel's error stream, ahead of the address and in the same read. Each QuickTunnelHost preset ships a pattern for its own domain.

UrlCaptureTimeout (15s) bounds the whole capture, including opening the session channel — which is a blocking call SSH.NET holds until the server confirms the request. If nothing matches in that window PublicUrl stays null and a warning is logged; it is not filled in with a guess, because for a hosted tunnel http://{host}:{port} is a link to the provider's own front page.

Pin fingerprints for these too where the provider publishes them. AcceptAnyHostKey means anything on the path can pose as the tunnel and read everything going through it.


Starting and stopping in-app

builder.Services.AddHttpServer(
    configureServer: server => server.MapGet("/api/widgets",),
    autoStart: false          // tunnel only; drop this to also listen locally
);

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

Then resolve SshTunnel and drive it from a toggle:

public sealed class RemoteAccessViewModel(SshTunnel tunnel)
{
    public async Task ToggleAsync(bool on) =>
        this.Url = on ? await tunnel.StartAsync() : null;
}

Batteries

  • Reconnect is on by default, with backoff from ReconnectDelay to MaxReconnectDelay. Not optional on a phone: moving from Wi-Fi to cellular kills the TCP connection under the tunnel, and without it the app looks fine while being unreachable.
  • KeepAliveInterval (30s) keeps the NAT mapping alive. Carriers drop idle mappings in a minute or two; longer is kinder to the battery but risks silent death between requests.
  • IsConnected / ConnectivityChanged — surface the state rather than assuming the tunnel is up.
  • RemotePort — set RemotePort = 0 and the server allocates one, readable here after binding. Needs sshd to permit it, and means the public URL changes on every reconnect.
  • LocalPort — the loopback port the tunnel listens on. Zero (the default) takes an ephemeral one; nothing else needs to find it.

Trade-offs worth knowing

This package is not AOT- or trim-clean, and the core server still is. SSH.NET carries BouncyCastle and its own algorithm registries. That is why this lives in its own package: reference it and you accept the weight; don't, and the server stays a few megabytes of AOT.

Host keys are checked because this package makes you check them. SSH.NET accepts any key by default. Here, connecting without either a pinned fingerprint or an explicit AcceptAnyHostKey fails — an unverified key means anything on the path can pose as the server, and a tunnel exists to cross networks you do not control.

The caller's IP does not survive the forward. SSH carries bytes, not addresses, so RemoteEndPoint is the loopback end of the tunnel. If the proxy in front sets X-Forwarded-For, turn on UseForwardedHeaders — and only then.

Latency is a round trip to the SSH host. Every request goes device → server → caller. Put it near your users, and prefer a local listener when both ends are on the same network.

One device per forwarded port. A second device binding the same remote port is refused by sshd, not load-balanced. One port, or one subdomain, per device.