Overview

SSH is the encrypted access method — the modern counterpart to telnet for reaching the gateway, and the way the gateway dials out to remote shells.

Where telnet is a cleartext NVT stream the gateway implements byte-for-byte, SSH is a full encrypted transport. The gateway does not hand-roll the SSH wire protocol — it builds on the russh crate (pure-Rust SSH) and supplies the policy around it: host keys, authentication, the bridge into a terminal session, and trust decisions when dialing out. Everything here lives in src/ssh.rs (the inbound server and key management) and the gateway driver in src/telnet.rs (the outbound proxy).

Telnet vs. SSH. Use telnet (default port 2323) for retro hardware that can't do crypto — Commodore 64, CP/M, AltairDuino. Use SSH (default port 2222) for encrypted access from modern terminals. Both land on the same menus and features, and both share one credential pair and one lockout map. See the Telnet Negotiation Reference for the cleartext side.

Two Roles

The gateway speaks SSH in both directions — keep the two straight, because they use different keys.

RoleDirectionWhat it is
SSH ServerInbound — a client connects to the gatewayAn encrypted front door onto the same menus telnet offers. The gateway proves its identity with its host key; the client authenticates with the unified username / password. Off by default.
SSH GatewayOutbound — the gateway dials out to a remote SSH serverA proxy: from the main menu you open an interactive shell on some other machine. The gateway authenticates with either a password you type or its own client key, and verifies the remote's host key trust-on-first-use.

Inbound SSH Server

An encrypted way in, on port 2222 by default (disabled until you enable it).

PropertyValue
Default statessh_enabled = false — opt-in.
Default port2222 (ssh_port).
Implementationrussh server (src/ssh.rs).
Host keyEd25519, auto-generated on first run, persisted to ethernetgateway-data/ethernet_ssh_host_key (OpenSSH PEM, 0o600 on Unix).
AuthenticationThe unified username / password (shared with telnet and the web UI), compared in constant time — plus public-key login for an enrolled slave, against relay_authorized_keys. Key auth is refused outright when that file is absent, so an installation that has never used it is unchanged.
Brute-force lockoutShared per-IP map with telnet: 3 failures → 5-minute ban. auth_rejection_time is 1 s.
CapacityBounded by max_sessions; connections over the cap are rejected at auth.
How a session is served

SSH clients expect a shell, not a raw socket, so the server bridges the SSH channel into the gateway's ordinary terminal session:

Connection lifecycle

  1. The client connects; the server presents the Ed25519 host key so the client can verify it (and pin it in their own ~/.ssh/known_hosts).
  2. auth_password runs the per-IP lockout check, then a constant-time compare of the username and password, and — only on a successful match — claims a session slot, rejecting if the server is already at max_sessions. Claiming the slot at successful login (rather than at connect) means an unauthenticated peer that opens many connections and stalls can't exhaust the session cap. The credentials are snapshotted at connect time, so saving a new password mid-session never invalidates an already-authenticated connection.
  3. On a shell_request the server opens one shell per connection and builds a duplex bridge to a TelnetSession (started in ANSI mode). SSH input is forwarded into the bridge; the session's output is read back out and sent to the client as channel data.
  4. The session writer joins the shared broadcast list, so a server-wide shutdown notice reaches SSH clients too.
  5. Client EOF (or disconnect) tears the bridge down and the session count is released.
Server auth is the unified password, plus keys for a slave. Public-key login into the gateway's SSH server is offered only to a slave whose key has been enrolled in relay_authorized_keys; every other client authenticates with the unified password. Such a key authorizes the relay exec a slave needs and not an interactive session — a shell request on a key-authenticated connection is refused, so the menu stays behind the password, which is also what an operator who blanked that password was asking for. A slave enrols itself: on a connection it authenticated with the password it sends enroll-key on its own channel, and the master appends the key with the slave's hostname and address as a comment. That command is refused unless the connection has already authenticated, is idempotent, is bounded to 64 keys, and re-renders the key from the parsed form so nothing from the wire is echoed into the file — the comment label is stripped to alphanumerics for the same reason, since a newline in it would let a peer append authorizations of its own. That exists so a slave need not store the master's login in cleartext — slave_master_password is presented rather than checked, so it can never be a hash, and a key removes the secret instead of disguising it. A rejected key does not count toward the per-IP lockout: a public key is not guessable, and counting it would ban the ordinary sequence of a slave offering a key, being refused, then logging in with its password. (Public keys are also used in the other direction; see the gateway.) See Security.

The Three Key Files

SSH touches three on-disk files, all of them inside the gateway's ethernetgateway-data folder along with everything else it creates. They are easy to confuse — here is exactly what each one is for.

FileUsed whenWhat it holds
ethernet_ssh_host_keyA client connects to our SSH serverThe gateway's own SSH server host key (Ed25519 private key). This is the identity remote clients pin.
ethernet_gateway_ssh_keyWe dial out to a remote SSH server in key modeThe gateway's outgoing client keypair (Ed25519). Put its public half into the remote's authorized_keys.
gateway_hostsWe dial out and verify the remoteTrusted remote-server fingerprints — the gateway's equivalent of OpenSSH's known_hosts. One host:port algorithm base64 entry per line.

All three are written atomically and chmod 0o600 on Unix. The private keys carry no passphrase — the gateway process must use them without interaction, so the file mode is the at-rest protection. gateway_hosts is locked down too: the stored public keys are not secret, but the file also reveals the gateway's dial history (which hosts the operator has connected to), which other local users shouldn't see.


Outbound SSH Gateway

Proxy out to a remote shell. Press S from the main menu.

Connecting

  1. Press S at the main menu. The header shows the active auth mode (password or gateway key).
  2. At the first prompt, press K to display the gateway's public key (for pasting into a remote's authorized_keys), or any other key to continue.
  3. Enter the remote hostname / IP, the port (default 22), and your username on the remote.
  4. The gateway connects and verifies the remote's host key against gateway_hosts (see Host-Key Verification).
  5. It authenticates using the one configured method — password or gateway key (see Authentication).
  6. On success you get a full interactive shell. ANSI from the remote is stripped for PETSCII / ASCII terminals, and the remote's window-title sequence is dropped for everyone (see ANSI Filtering).
  7. Press Esc twice quickly (PETSCII: the back-arrow key twice) to disconnect and return to the menu. A single Esc is passed through to the remote, so ESC-driven software keeps working — see The Esc Key.

The outbound client uses a 600-second inactivity timeout and a bounded connect timeout, so a dead or unreachable host fails cleanly rather than hanging the session.


Host-Key Verification (TOFU)

Before authenticating, the gateway checks the remote's host key against gateway_hosts — trust-on-first-use, just like OpenSSH.

On connect, the gateway captures the remote's public host key and looks up host:port in gateway_hosts. One of three things happens:

StatusMeaningWhat the gateway does
KnownStored key matches the presented key.Proceeds silently to authentication.
UnknownNo entry for this host:port.Shows the key type and SHA-256 fingerprint and asks Trust this host? (Y/N). On Y it saves the key (a TOFU accept, logged) and continues; on N it disconnects.
ChangedStored key for this host does not match.Prints WARNING: HOST KEY HAS CHANGED! with the new fingerprint and makes you review before accepting — a possible man-in-the-middle.
# gateway_hosts — one entry per host:port
example.com:22 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
192.168.1.10:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
# blank lines and # comments are ignored
Independent of your OpenSSH known_hosts. Trust here is stored only in the gateway's gateway_hosts; it does not read or write any ~/.ssh/known_hosts. Every trust decision (first-time accept, change, rejection) is written to the server log for auditability.

Authentication

Inbound and outbound authenticate differently — and the outbound gateway uses exactly one method, chosen by config.

Into the server (inbound)

Password only, against the unified username / password shared across telnet, SSH, and the web UI, compared in constant time, behind the shared per-IP lockout.

Out through the gateway (outbound)

The ssh_gateway_auth key selects one method — there is no silent fallback, so the remote sees exactly one auth attempt and a failure is unambiguous:

ssh_gateway_authMethodBehavior
password (default)Keyboard passwordThe gateway prompts you for the remote password each dial and authenticates with it. Nothing is stored.
keyGateway client keyThe gateway authenticates with its own Ed25519 key (ethernet_gateway_ssh_key, auto-generated on first use). No password is requested and there is no fallback — the remote must already trust the key.

Setting up gateway-key (passwordless) dial-out

  1. Set ssh_gateway_auth = key in config (telnet, web, or GUI).
  2. At the SSH Gateway prompt, press K to print the gateway's public key in one-line OpenSSH form (ssh-ed25519 AAAA…, with no stray comment field).
  3. Append that single line to the remote account's ~/.ssh/authorized_keys.
  4. Future dials to that host authenticate with the key and skip the password prompt entirely.
This is not pubkey-then-password. In key mode the gateway never prompts for a password, and in password mode it never offers the key. If a key-mode dial fails, fix the remote's authorized_keys (or switch the mode back to password) — the gateway will not quietly try the other method.

The Esc Key

One Esc is the remote's. Two, quickly, are the gateway's.

Esc has to do two jobs on a gateway session: it is how you leave the gateway, and it is a key the remote needs — vi, WordStar, and most full-screen software on the far end are driven with it. The rule is that every Esc is passed straight to the remote, and what disconnects you is two of them in a row, within half a second, with no other key in between. The second one is the gateway's and is not forwarded. This applies identically to the SSH gateway, the telnet gateway and the serial console bridge.

Fixed in 0.9.3: a single Esc used to reach the remote only if you typed something after it. It was held rather than sent — that is how an arrow key (ESC [ A) got through intact — so a lone Esc waited for a second byte that never came, and pressing Esc again disconnected you instead. Measured from an SC126: dialling a host straight from the modem with ATDT host:port passed Esc through fine, because that is a raw TCP passthrough that intercepts nothing, while the same host through the gateway never saw the first press.
Why “in a row” as well as “quickly”: an arrow key is ESC [ A, so two cursor presses put two Esc bytes a few milliseconds apart — closer together than any human double-tap. A rule that measured only elapsed time would throw you out for pressing Up twice. The [ between them is the whole signal, so any other key resets the pair.
Changed behaviour: the two presses must now be quick. They previously counted as a pair however far apart, so an Esc to leave vi's insert mode and another a minute later ended the session.

ANSI Filtering for Vintage Terminals

A remote shell assumes a VT-style terminal. A Commodore 64 isn't one.

When you reach the SSH gateway from a PETSCII or ASCII terminal, the gateway strips ANSI escape sequences from the remote's output — CSI, OSC, DCS, PM, APC, and SOS sequences — so raw colour and cursor-control codes don't splatter across a screen that can't interpret them. The escape-state machine is carried across reads so sequences split over packet boundaries are still caught. ANSI terminals keep their sequences — colour and cursor addressing are why the terminal asked for ANSI in the first place — with one exception, below.

The window title goes for everyone. A shell sets the title of the window it thinks it is in: bash's default PS1 sends ESC ] 0 ; user@host: ~ BEL before every prompt. No client of this gateway has a title bar, and a terminal with no OSC support swallows the two-byte ESC ] and prints the rest, so the title lands on screen in front of every prompt — which reads as the prompt appearing twice, since the title is user@host as well. Measured on a real SC126 under both EGT80 and QTERM, which is what rules out a fault in either terminal. The drop is unconditional: a modern terminal that does have a title bar loses title updates through a gateway session too, because nothing can ask a terminal whether it implements OSC and the terminals that cannot are the ones this gateway is for.

Why it is held rather than swallowed: a gateway session is a plain terminal proxy, so if you run sz on the far host its ZMODEM bytes come down this same stream and nothing in the filter can tell them from a prompt. The bytes 1B 5D turn up about once per 64 KB of binary, so swallowing ESC ] to the next BEL would eat part of the download — and eat it identically on every retry, so the protocol's own CRC could never recover it. Instead a candidate is held and dropped only once it proves to be a title: ESC ], then 0; / 1; / 2;, then printable ASCII, then BEL or ESC \, all inside 256 bytes. Anything that fails any of those — a control byte, a high byte, an overlong run, a different OSC code, an unterminated one — is released byte for byte in the order it arrived. A candidate is released the moment the remote goes quiet, too (100 ms): carrying one across a read is necessary, because a burst larger than the 4 KB read buffer splits at an arbitrary byte, but carrying one indefinitely would deadlock a stop-and-wait transfer — a block whose last byte is being weighed never completes, the receiver times out, the sender resends the identical block, and the identical hold repeats.
See also: the ANSI Escape Sequences appendix for the exact codes being filtered, and the Telnet Reference for the same PETSCII / ASCII / ANSI terminal-type model on the cleartext side.

Security Model

SSH here is built for a trusted-user / LAN deployment, and the design reflects that.

  • Encrypted transport. russh provides the encrypted channel; the gateway never sends credentials or session data in clear over SSH.
  • Unified credentials. One username / password covers telnet, SSH, and the web UI — fewer secrets to manage. Compared in constant time to avoid timing leaks.
  • Shared lockout. The per-IP brute-force map is shared with telnet, so bouncing between protocols doesn't reset an attacker's failure count: 3 failures → 5-minute ban.
  • Host-key TOFU outbound. A changed remote host key is surfaced loudly rather than silently accepted.
  • Owner-only key files. Private keys and the dial-history file are 0o600, written atomically so they are never briefly world-readable.
Known limitations (by design). The inbound server takes the unified password from every client and a public key only from an enrolled slave (no client certificates), and private keys are stored without a passphrase because the process runs unattended. Both are deliberate trade-offs for the trusted-user / LAN threat model. If you expose the gateway to a hostile network, front it with a firewall or VPN.

Configuration Keys

Editable from the telnet Configuration menu, the GUI, the web UI, or egateway.conf directly.

KeyDefaultMeaning
ssh_enabledfalseEnable the inbound SSH server.
ssh_port2222Port the SSH server listens on.
ssh_gateway_authpasswordOutbound auth method: password (prompt each dial) or key (gateway client key, no fallback).
username / passwordadmin / changemeUnified credentials for telnet, SSH, and the web UI. Change the password before exposing the gateway.
max_sessions50Concurrent session cap, applied independently to telnet and SSH (each protocol allows up to this many; only the per-IP lockout map is shared between them).
File reminder: ssh_* config keys control behavior; the actual keys live in the ethernet_ssh_host_key, ethernet_gateway_ssh_key, and gateway_hosts files described under Key Files. The legacy separate ssh_username / ssh_password keys were removed — SSH now shares the one credential pair.

References

Deep-dive reference pages for every protocol and interface, plus the character-set tables and ANSI escape-sequence reference.

CP/M Emulator

UART profiles, EGT8080, HBIOS calls, BDOS/BIOS coverage.

XMODEM

128-byte blocks; CRC-16 / checksum negotiation.

YMODEM

Block-0 metadata, batch, exact size truncation.

ZMODEM

Streaming; ZDLE, CRC-32, autostart, resume.

Kermit

Send-Init negotiation; F / A / D / Z / B transfer.

Punter

C1 dual checksum, two-phase, GOO/BAD/ACK.

AT Commands

Hayes command set, S-registers, +++ escape.

Telnet

IAC negotiation, every option, the NVT data phase.

SSH (this page)

Server, gateway, host keys, TOFU, auth modes.

Character Code Tables

Hex tables for every encoding: ASCII, ANSI, PETSCII, ATASCII, Baudot/ITA2, ZX Spectrum, TRS-80.

ANSI Escape Sequences

Cursor, colour/SGR, erase, and screen-mode escape codes, with the raw hex bytes.