Running TUIs over SSH

Copy Markdown View Source

ExRatatui ships with a built-in SSH transport that lets any ExRatatui.App module be served as a remote terminal UI. Instead of rendering into the host's physical terminal, the app renders into a per-connection in-memory terminal (a ExRatatui.Session) whose ANSI output is piped back to an SSH client over a single channel.

This is the mode you want when:

  • You're running on a headless box (Nerves device, container, remote host) and want to drive a TUI from your laptop.
  • You want multiple people to attach to the same daemon, each with their own independent session.
  • You're wrapping an existing nerves_ssh daemon and just want one more subsystem for your TUI.

The entire transport is pure OTP :ssh — no ports, no extra ports, no external sshd.

The Big Picture

               
 ssh               TCP    :ssh.daemon                  
    client       ExRatatui.SSH channel    
                                 ExRatatui.Session   
                       ExRatatui.Server    
                                         your App mod  
                           

One ExRatatui.SSH.Daemon GenServer owns a :ssh.daemon/2 listening on a TCP port. Each new client connection spawns its own ExRatatui.SSH channel process, which in turn owns:

  • A ExRatatui.Session — an in-memory terminal sized to the client's PTY, backed by a VTE ANSI parser.
  • A linked internal server process running your ExRatatui.App module in :ssh transport mode.

Clients are fully isolated from each other: their own state, their own key events, their own screen size. A single daemon can serve many concurrent sessions without any shared mutable state.

Quick Start — Standalone Daemon

The simplest way to start is by adding the daemon to your supervision tree:

children = [
  {ExRatatui.SSH.Daemon,
   mod: MyApp.TUI,
   port: 2222,
   system_dir: ~c"/etc/ex_ratatui/host_keys",
   user_passwords: [{~c"admin", ~c"s3cret"}],
   auth_methods: ~c"password"}
]

Supervisor.start_link(children, strategy: :one_for_one)

Then connect from another terminal:

ssh admin@localhost -p 2222

Or try it live with the bundled example:

mix run --no-halt examples/system_monitor.exs --ssh
# (in another terminal)
ssh demo@localhost -p 2222   # password: demo

The example generates a throwaway RSA host key under your system tmp dir on first run and reuses it afterward.

App-level Shortcut

ExRatatui.App knows about the transport dispatch too, so if your module already uses the behaviour you can skip the explicit SSH.Daemon child spec:

children = [
  {MyApp.TUI,
   transport: :ssh,
   port: 2222,
   system_dir: ~c"/etc/ex_ratatui/host_keys",
   user_passwords: [{~c"admin", ~c"s3cret"}]}
]

With transport: :ssh set, MyApp.TUI.start_link/1 routes through ExRatatui.SSH.Daemon instead of the local terminal path. Omitting :transport (or passing :local) keeps the default behaviour.

Integrating with nerves_ssh

If you're already running nerves_ssh on a Nerves device, you don't need to stand up a second daemon. nerves_ssh accepts a subsystems: option that takes OTP :ssh_server_channel modules, and ExRatatui.SSH is exactly one of those. Use the subsystem/1 helper to build the tuple in the shape OTP wants:

# In your Nerves target config:
config :nerves_ssh,
  authorized_keys: [File.read!("/root/.ssh/authorized_keys")],
  subsystems: [
    :ssh_sftpd.subsystem_spec(cwd: ~c"/"),
    ExRatatui.SSH.subsystem(MyApp.TUI)
  ]

The user then connects with:

ssh nerves.local -s Elixir.MyApp.TUI

The subsystem name is the full Elixir module name as a charlist (because that's what SSH expects), so two different app modules configured into the same daemon get distinct subsystem names and don't collide.

subsystem/1 bakes a subsystem: true flag into its init args so the channel handler knows it was dispatched via OTP's :subsystems path. That matters because OTP :ssh consumes the {:subsystem, ...} channel request internally when it matches the name — handle_ssh_msg/2 never sees it. The only lifecycle message the handler receives is {:ssh_channel_up, ...}, and it uses that as its cue to synthesize a default 80x24 session and start the TUI server immediately.

Learning the client's real terminal dimensions in subsystem mode is trickier than it looks. The obvious answer — "read the dimensions off pty_req" — doesn't work on nerves_ssh (or any OTP :ssh.daemon that configures a default CLI handler alongside the subsystems list). When the client connects with ssh -t -s <name>, OTP fires pty_req before the subsystem dispatch matches, and :ssh_connection.handle_cli_msg/3 sees an unbound channel user pid, which means it hands the pty_req to the daemon's default CLI handler (IEx on Nerves, for example). Moments later the {:subsystem, ...} request arrives, OTP rebinds the channel's user pid to us, and the original CLI handler is silently orphaned — pty_req is gone and there is no OTP message to recover it from.

To sidestep all of that, the handler emits a Cursor Position Report roundtrip immediately after starting the server:

ESC[s               → save cursor
ESC[9999;9999H      → move cursor to (9999, 9999) — clamped to terminal size
ESC[6n              → "report your position"
ESC[u               → restore cursor

The client clamps the impossible position to its real (rows, cols) and answers with ESC[<row>;<col>R. That response lands on the next {:data, ...} channel message, the session's ANSI input parser (built on vte) decodes it into a %ExRatatui.Event.Resize{} just like any other resize event, and the SSH data handler intercepts it: resize the session in place, notify the running server via {:ex_ratatui_resize, w, h}. From the app's perspective it's identical to a real window_change, and subsequent window_change events during the session continue to work unchanged. The first frame may still paint briefly at 80x24 before the CPR response comes back, but on any terminal faster than a potato that window is invisible.

Always pass -t in subsystem mode

OpenSSH does not allocate a PTY by default for subsystem invocations. sftp and similar binary protocols don't need one, but a TUI absolutely does — without a PTY the client's local terminal stays in cooked mode, which means keystrokes get line-buffered and locally echoed (painting garbage over the TUI) and the alt-screen teardown on disconnect bleeds into the shell prompt. Always force PTY allocation with -t:

# ✓ interactive — local terminal enters raw mode, keys flow to the server,
#   quitting leaves a clean shell
ssh -t nerves.local -s Elixir.MyApp.TUI

# ✗ render bytes reach you but it is not usable interactively
ssh nerves.local -s Elixir.MyApp.TUI

See the nerves_ex_ratatui_example project for an end-to-end Nerves firmware that wires two TUIs into a nerves_ssh daemon and runs them on a Raspberry Pi.

Options Reference

ExRatatui.SSH.Daemon accepts:

OptionTypeDefaultDescription
:modmodule()requiredThe ExRatatui.App module to serve
:portinteger()2222TCP port to listen on; 0 picks a free port
:nameatom() | nilExRatatui.SSH.DaemonRegistered name, or nil to skip
:app_optskeyword()[]Extra opts merged into every client's mount/1 call
:auto_host_keyboolean()falseAuto-generate an RSA host key under <priv_dir>/ssh/ (see "Generating Host Keys")

Everything else is forwarded verbatim to :ssh.daemon/2, so all of OTP's :ssh options work unchanged:

  • :system_dir — host key directory
  • :user_dir — client key directory
  • :authorized_keys — authorized keys content
  • :auth_methods~c"password", ~c"publickey", ~c"publickey,password"
  • :user_passwords[{~c"user", ~c"password"}]
  • :pwdfun — custom password callback
  • :key_cb — custom key callback (e.g. in-memory keys)
  • :idle_time — auto-disconnect idle clients
  • :max_sessions — limit concurrent connections
  • :profile — multiple daemons on the same machine

See the :ssh.daemon/2 OTP docs for the full list.

Why charlist() everywhere?

OTP's :ssh module is implemented in Erlang and expects Erlang strings — i.e. charlists (~c"..."), not Elixir binaries. Any option that holds a username, password, path, or file content needs to be a charlist. If you pass a binary by mistake you'll usually see a cryptic :badarg from inside :ssh.

As a small convenience, :system_dir accepts either form: pass a binary path ("./priv/host_keys") and the daemon will convert it to a charlist before forwarding to :ssh.daemon/2. Other charlist options still need the ~c"..." sigil.

Authentication

OTP :ssh supports both password and public-key authentication. For production use, prefer public keys:

{ExRatatui.SSH.Daemon,
 mod: MyApp.TUI,
 port: 2222,
 system_dir: ~c"/etc/ex_ratatui/host_keys",
 user_dir: ~c"/etc/ex_ratatui/users",
 auth_methods: ~c"publickey"}

Drop each authorized client's public key into /etc/ex_ratatui/users/authorized_keys (the standard OpenSSH format).

For development, a fixed password is fine:

{ExRatatui.SSH.Daemon,
 mod: MyApp.TUI,
 port: 2222,
 system_dir: ~c"./priv/host_keys",
 auth_methods: ~c"password",
 user_passwords: [{~c"dev", ~c"dev"}]}

For full-custom auth (e.g. looking up users in your own DB), pass pwdfun: &MyApp.Auth.check/4 — see the OTP :ssh pwdfun docs for the callback signature.

Generating Host Keys

There are three reasonable strategies for managing the host key. Pick based on where you're running the daemon:

StrategyBest forHow
Explicit :system_dirProduction, multi-machine setups, anything where the host key needs to be backed up or rotatedGenerate with ssh-keygen, mount under config management, pass system_dir: ~c"/etc/ex_ratatui/host_keys"
auto_host_key: truePhoenix admin TUIs, internal tools, dev daemons — anywhere you don't want to babysit a directoryDaemon resolves the OTP app for :mod, generates a key under <priv_dir>/ssh/ on first boot, reuses it after
nerves_ssh-managedNerves devices already running an SSH listener for IEx and firmware updatesDon't run your own daemon at all — use ExRatatui.SSH.subsystem/1 and let nerves_ssh reuse its existing host key

The rest of this section walks through each option in detail.

OTP scans system_dir for files named ssh_host_rsa_key, ssh_host_ecdsa_key, ssh_host_ed25519_key, etc. You can generate one with ssh-keygen:

mkdir -p priv/host_keys
ssh-keygen -t ed25519 -f priv/host_keys/ssh_host_ed25519_key -N ""

Or inside the BEAM at runtime (see examples/system_monitor.exs for a ready-to-copy snippet using :public_key.generate_key/1).

auto_host_key: true for the lazy path

For Phoenix admin TUIs, internal tools, and development daemons where you don't want to babysit a system_dir, pass auto_host_key: true and let the daemon take care of it:

children = [
  {ExRatatui.SSH.Daemon,
   mod: MyAppWeb.AdminTui,
   port: 2222,
   auto_host_key: true,
   auth_methods: ~c"password",
   user_passwords: [{~c"admin", ~c"admin"}]}
]

On first boot, the daemon:

  1. Resolves the OTP application that owns :mod (via Application.get_application/1).
  2. Creates <priv_dir>/ssh/ if it doesn't exist.
  3. Generates a fresh 2048-bit RSA host key at <priv_dir>/ssh/ssh_host_rsa_key with 0600 permissions.

Subsequent boots reuse the same key, so SSH clients won't see host-key warnings between restarts. Add priv/ssh/ to your .gitignore — the key is private to that machine and should never be committed.

Passing both :auto_host_key and :system_dir is an error. If you need an explicit host-key location (production deployments, multi-machine setups), pass :system_dir and manage the keys yourself.

This option exists so you can drop the daemon straight into a Phoenix or library supervision tree and have it just work without hand-rolling a host-key bootstrap. It is not a substitute for proper key management in production — see the phoenix_ex_ratatui_example project for an end-to-end demo.

Forwarding mount/1 Opts

Anything you pass as :app_opts on the Daemon reaches every connected client's mount/1 callback:

{ExRatatui.SSH.Daemon,
 mod: MyApp.TUI,
 port: 2222,
 system_dir: ~c"/etc/ex_ratatui/host_keys",
 app_opts: [pubsub: MyApp.PubSub, feature_flags: %{beta: true}]}
defmodule MyApp.TUI do
  use ExRatatui.App

  @impl true
  def mount(opts) do
    pubsub = Keyword.fetch!(opts, :pubsub)
    Phoenix.PubSub.subscribe(pubsub, "alerts")
    {:ok, %{pubsub: pubsub, flags: opts[:feature_flags]}}
  end
end

This is how you share infrastructure (PubSub topics, Ecto repos, feature toggles) across every SSH-attached session without globals.

Known Limitations

  • One channel per session. We don't support SSH port forwarding or multiple channels on a single connection. If you need that, run a second daemon instance on a different port.
  • No X11 / agent forwarding. The TUI doesn't need either; both are unconditionally rejected.
  • Shell mode needs a PTY. Clients that connect with ssh host (no -T, no subsystem) must request a PTY or the channel is closed immediately. ssh host -T will not work; ssh host or ssh host -t will.
  • Subsystem mode starts at 80x24 until the CPR reply comes back. OTP consumes pty_req before a subsystem handler exists, so the transport discovers the client's real dimensions via a Cursor Position Report (ESC[6n) roundtrip on channel_up. The first frame paints at 80x24 until the response arrives on the next {:data, ...} message, which is usually invisibly fast but is not strictly instantaneous. Clients that don't answer ESC[6n at all (very rare — this is a half-century-old ANSI spec) stay at 80x24 until their first window_change.
  • No keystroke replay. If a client disconnects mid-session, the state is gone. There's no server-side scrollback or reattach (think tmux, not screen).

Troubleshooting

"Connection closed by remote host" right after banner : The client asked for a shell but didn't allocate a PTY. Add -t (ssh -t host) or switch to subsystem mode (ssh -s host Elixir.MyApp.TUI).

Client sees garbled box-drawing characters : Your SSH client isn't interpreting UTF-8 or isn't in a terminal that knows about Unicode line-drawing glyphs. Set LANG=en_US.UTF-8 on both sides.

:ssh_daemon_failed, :eaddrinuse : Another process holds the port. Use ss -tlnp | grep 2222 to find it, or pick a different port.

Silent failure under nerves_ssh : nerves_ssh logs its subsystem errors quietly. Enable verbose SSH logging on the client (ssh -vv ...) to see the server's subsystem-failure reason. If you're on ex_ratatui 0.6.0 specifically, ssh host -s Elixir.MyApp.TUI hangs instead of rendering — upgrade to 0.6.1 or newer; the subsystem handler used to wait for a {:subsystem, _} message that OTP consumes before the handler ever sees it.

Tests time out waiting for an initial render : The SSH channel triggers the initial render synchronously inside the linked Server's init/1 (via continue_init_ssh/3) before any client input can arrive. If your mount/1 does long I/O (e.g. HTTP, DB), the channel won't see render bytes until that finishes — move expensive work to handle_info(:refresh, ...) with a self-scheduled message so the first render doesn't block the handshake.