Skip to content

Remote Sessions

One ConsoleMVC process can serve the application to many people at once. Each person gets their own session: their own screen, input, current page, Back history and session values, while controllers, views, middleware and services are shared as in a local run.

Serve without changing code

Set ConsoleMVC:Serve and run the application as usual:

dotnet run -- --ConsoleMVC:Serve=2323

Then connect from other terminals:

telnet localhost 2323

The server listens on the loopback address until you choose otherwise, so nobody else on the network can connect. Press Ctrl+C in the server's console to stop it: every client is disconnected and their terminals are put back.

Setting Meaning Default
ConsoleMVC:Serve Where to listen: 2323, 127.0.0.1:2323, 0.0.0.0:2323 or [::]:2323 not set: run locally
ConsoleMVC:ServeProtocol Telnet, or Raw for clients that are already in raw mode Telnet
ConsoleMVC:ServeCertificate A .pfx file that turns on TLS none
ConsoleMVC:ServeCertificatePassword Its password (keep it in user secrets or an environment variable) none
ConsoleMVC:ServeMaxSessions The most sessions at once 16
ConsoleMVC:ServeAllowUnencrypted Allow a network address without TLS false

Serve from code

using var app = builder.Build();

await app.ServeAsync(new TerminalServerOptions
{
    EndPoint = new IPEndPoint(IPAddress.Loopback, 2323),
    MaxSessions = 50,
    IdleTimeout = TimeSpan.FromMinutes(10)
}, cancellationToken);

ServeAsync returns when the token is cancelled, after every session has ended. For more control, create the server yourself:

await using var server = new TerminalServer(app, new TerminalServerOptions());
server.Start();
Console.WriteLine($"Connect with: telnet {server.LocalEndPoint.Address} {server.LocalEndPoint.Port}");

Knowing who is connected

Ask for ITerminalSession in a controller's constructor, with @inject in a view, or from a middleware's services. It tells sessions apart and keeps values for one user for as long as they are connected:

public sealed class AccountController(ITerminalSession session, IUserStore users) : Controller
{
    public ActionResult Index() =>
        session.Items.TryGetValue("user", out var user) ? View(user) : RedirectToAction("SignIn");

    public ActionResult SignIn(string name, string password)
    {
        if (!users.Check(name, password))
            return View();

        session.Items["user"] = name;
        return RedirectToAction("Index");
    }
}
Member Meaning
Id local for the console, a new random identifier for every remote client
IsRemote Whether this is a remote client
RemoteEndPoint The client's address
TerminalType What the client reported, such as XTERM-256COLOR
Width, Height The client's window size, kept up to date when it resizes
Items Values for this session only, safe to use from several threads
Disconnect() Ends the session, for example after too many failed sign-ins

Singletons are shared by every session, so keep per-user state in Items (or in a store keyed by the session's Id), and make shared services safe to use from several threads. Scoped services are still one scope per screen. Ask for ITerminalSession in controllers, views, middleware and scoped services, never in a singleton's constructor: a singleton is created once, so it would keep the first session that created it.

What works remotely

Everything a local run does: menus, forms and validation, arrow-key fields, <scroll>, keyboard shortcuts, the mouse, full-screen mode, @refresh screens, live elements (ILiveDisplay draws on the session that started it), the screen-reader mode and localisation. The screen is laid out for each client's width and laid out again when they resize the window.

Two things stay on the server: Console.WriteLine in your own code writes to the server's console, and so does an IMarkupRenderer or ITextInput resolved from services. Views draw with their own Renderer and Input, which the framework sets for each session.

Ctrl+C ends a remote session (it does not stop the server), and so does a client that stays idle longer than IdleTimeout (30 minutes unless you choose).

Security

The defaults are safe:

  • the server listens on the loopback address only;
  • it refuses to listen on a network address without TLS, because everything typed, passwords included, would cross the network in clear text, unless you set AllowUnencryptedNetworkAccess (only on a network you trust);
  • it accepts at most 16 sessions, buffers at most 4096 typed characters per session, keeps window sizes to at most 1000 by 500, and drops idle clients and clients that do not finish the TLS handshake in 10 seconds;
  • a client that stops reading cannot hold its session: a write that blocks for WriteTimeout (30 seconds) disconnects it, and stopping the server closes any connection whose session has not ended after 5 seconds;
  • a filter (AcceptConnection) that throws refuses that connection and is logged; the server carries on;
  • session identifiers are random, from a cryptographic generator.

Turn on TLS 1.2 or 1.3 with a certificate:

options.Certificate = X509CertificateLoader.LoadPkcs12FromFile("server.pfx", password);

TLS clients connect with, for example, socat -,raw,echo=0 OPENSSL:shop.example.com:2323 (use the Raw protocol for them). The server does not sign users in: add a sign-in screen as above, filter addresses with TerminalServerOptions.AcceptConnection, or put SSH in front.

Over SSH

OpenSSH authenticates users and encrypts the connection, which makes it the best choice on the internet. There are two ways to use it:

  • A process per user, with nothing to set up in the application: make the application the user's command in sshd_config. A Native AOT build starts in milliseconds.
Match User shop
    ForceCommand /opt/shop/Shop
  • One process for everyone: serve on the loopback address with the Raw protocol, and forward SSH users to it. The SSH terminal must be put in raw mode first, so keys pass straight through:
Match User shop
    ForceCommand sh -c 'stty raw -echo; exec nc 127.0.0.1 2323'

The application then sees every SSH user as a connection from the loopback address; sign them in with a screen of your own if it needs to know who they are.

In a web browser

ttyd shows a terminal program in a browser page:

ttyd --writable ./Shop                       # a process per browser tab
ttyd --writable telnet 127.0.0.1 2323        # one process, a session per tab

Other transports

RemoteTerminal turns any pair of streams into a terminal, so a WebSocket, an SSH library's channel or a serial port can run a session:

await using var terminal = new RemoteTerminal(stream, new RemoteTerminalOptions { Columns = 100, Rows = 30 });
connection.Resized += (columns, rows) => terminal.Resize(columns, rows);
await app.RunSessionAsync(terminal, cancellationToken);

The other end must send keys as they are pressed and echo nothing (raw mode); the terminal decodes arrows, function keys and modifiers, and writes ANSI text with CR LF line endings.

Testing

ConsoleMVC.Testing runs the application locally, which is how sessions behave too. To test that two users are kept apart, run two sessions on RemoteTerminals over in-memory streams, or start a TerminalServer on port 0 and connect with TcpClient (server.LocalEndPoint has the port chosen).