Raxol. SSH. Server
(Raxol v2.7.0)
View Source
Serves a Raxol TEA application over SSH.
Each SSH connection gets its own Lifecycle process running the TEA app, with terminal I/O redirected through the SSH channel.
Usage
Raxol.SSH.serve(CounterExample,
port: 2222,
authorized_keys_dir: "/etc/raxol/authorized"
)Then connect: ssh localhost -p 2222
Options
:port- Port to listen on (default: 2222):host_keys_dir- Directory for SSH host keys (default:~/.raxol/ssh_keys, a persistent path so keys survive restarts):max_connections- Maximum concurrent connections (default: 50; REQUIRED explicitly when anonymous):max_per_ip- Maximum concurrent connections from one peer IP (default: 10, REQUIRED explicitly when anonymous), so a single host cannot flood the pool:idle_timeout- Milliseconds without client input before a session is closed (REQUIRED when anonymous; also passed to the daemon asidle_timefor connections holding no channel):max_session_duration- Milliseconds a session may exist before it is closed regardless of activity (REQUIRED when anonymous):negotiation_timeout- Milliseconds a connection may spend in key exchange and auth before it is dropped (default: 30_000), bounding slow-handshake holds:anonymous_public-truelets an anonymous server bind non-loopback interfaces. Without it (orRAXOL_SSH_ANONYMOUS_PUBLIC=1), anonymous servers bind loopback only, so one flag cannot carry a surface from laptop demo to public internet.
On boot the server logs one posture line naming the resulting exposure (bind address, port, auth mode, caps, host key algorithms), so what was deployed is visible in the first log anyone reads.
Authentication (fail-closed)
A surface that can reach payment Actions must not be silently anonymous, so authentication is required unless anonymous access is explicitly requested:
:allow_anonymous-trueaccepts any connection (BBS/playground use). Binds loopback unless:anonymous_publicacknowledges the exposure, and refuses to start unless all four resource caps (:max_connections,:max_per_ip,:idle_timeout,:max_session_duration) are explicit.:authorized_keys_dir- a directory holding anauthorized_keysfile; connections must present a listed public key. Single-tenant: every keyholder is the same principal regardless of the username they claim.:tenants_dir- multi-tenant: keys live per user at<tenants_dir>/<user>/ssh/authorized_keys, so a key only authenticates the username it is filed under (a keyholder cannot claim another tenant's name). Usernames are restricted to a conservative charset; anything else maps to a directory that cannot exist, so auth fails closed.
With none of these, the server refuses to start.
Per-tenant application options
:tenant_opts - an arity-1 fun of the AUTHENTICATED username returning
{:ok, keyword} (options merged into the connection's app instance,
winning over the server-level :app_opts) or {:error, reason} (the
connection is refused). This is how a multi-tenant host assigns each
user their own cwd jail, session store, and spending identity.
Summary
Functions
Build the :ssh.daemon authentication options, failing closed.
Probe an SSH listener by reading its banner, not just opening a socket.
The address the daemon binds, decided from the auth posture.
Returns a specification to start this module under a supervisor.
Returns the current number of active connections.
Default directory for SSH host keys: a persistent per-user path.
Ensure dir holds a usable host key, generating an ed25519 key with
owner-only permissions when none exists (mode 0600 on POSIX, a protected
current-user-only ACL on Windows). Fails closed with
{:error, {:ssh_host_keys_insecure, paths}} when an existing key is readable
by another identity: a readable host key means every client's host-key trust
is forgeable, so the server must not start.
A peer address as a loggable string; anything that is not an IP tuple
(the :unknown bucket) is printed as-is rather than crashing a log line.
Algorithm labels of the host keys present in dir, ed25519 first.
The resource caps an anonymous surface must declare, or the empty list.
The port the daemon is actually listening on (resolved even when the server was started with port 0).
One greppable line stating the security posture this server resolved to: bind address, port, auth mode, resource caps, and host key algorithms. Logged at boot; the posture is the thing that goes wrong silently, so it is said out loud where people already look.
Registers a new connection from peer_ip.
Normalize a client-supplied SSH username to a tenant name, or nil
when it is not a safe, canonical path component. Shared with the
tenant-option derivation so the key lookup and the workspace mapping can
never disagree about which directory a username names. Names are canonical
lowercase — a mixed-case username is refused, not case-folded, so one
principal has exactly one path and one ledger key on every filesystem.
The per-tenant SSH key directory for user:
<tenants_dir>/<user>/ssh (holding that tenant's authorized_keys).
An unsafe username maps to a reserved path no tenant directory can
ever occupy, so its key lookup fails and auth is refused.
Unregisters a connection from peer_ip when it closes.
Functions
Build the :ssh.daemon authentication options, failing closed.
Returns {:ok, opts} for allow_anonymous: true (no auth) or
authorized_keys_dir: dir (public-key auth), and {:error, :ssh_auth_required}
when neither is given so the caller refuses to start an anonymous surface.
@spec banner_probe( charlist() | String.t() | :inet.ip_address(), :inet.port_number(), timeout() ) :: :ok | {:error, :no_banner | :not_ssh | {:connect_failed, term()}}
Probe an SSH listener by reading its banner, not just opening a socket.
A daemon that accepts and immediately hangs up is indistinguishable from a
working one to a bare connect, so a health check built on connect alone
passes during the outage it exists to catch. :ok only when the peer
actually says SSH-2.0.
@spec bind_address(keyword()) :: :any | :loopback
The address the daemon binds, decided from the auth posture.
Anonymous surfaces bind loopback unless the exposure is separately
acknowledged with anonymous_public: true (or RAXOL_SSH_ANONYMOUS_PUBLIC=1
in the environment), so allow_anonymous: true alone can never open a
surface to the network. Key-authenticated surfaces bind all interfaces.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec connection_count(GenServer.server()) :: non_neg_integer()
Returns the current number of active connections.
@spec default_host_keys_dir() :: String.t()
Default directory for SSH host keys: a persistent per-user path.
A host key under /tmp rotates on reboot, which trains clients to click
through the host-key-changed warning -- the one warning that matters. Keys
live under the user's home so they survive restarts.
Ensure dir holds a usable host key, generating an ed25519 key with
owner-only permissions when none exists (mode 0600 on POSIX, a protected
current-user-only ACL on Windows). Fails closed with
{:error, {:ssh_host_keys_insecure, paths}} when an existing key is readable
by another identity: a readable host key means every client's host-key trust
is forgeable, so the server must not start.
A peer address as a loggable string; anything that is not an IP tuple
(the :unknown bucket) is printed as-is rather than crashing a log line.
Algorithm labels of the host keys present in dir, ed25519 first.
The resource caps an anonymous surface must declare, or the empty list.
When auth is off, every cap is required explicitly (a positive integer;
the timeouts are milliseconds) rather than defaulted: fifty concurrent
anonymous shells must be a stated decision, not an omission. Authenticated
surfaces keep their defaults and return [].
@spec port(GenServer.server()) :: :inet.port_number()
The port the daemon is actually listening on (resolved even when the server was started with port 0).
One greppable line stating the security posture this server resolved to: bind address, port, auth mode, resource caps, and host key algorithms. Logged at boot; the posture is the thing that goes wrong silently, so it is said out loud where people already look.
@spec register_connection(GenServer.server(), term()) :: :ok | {:error, :max_connections | :ip_limit}
Registers a new connection from peer_ip.
Returns :ok, {:error, :max_connections} (global cap), or
{:error, :ip_limit} (this peer already holds max_per_ip connections, so a
single host cannot exhaust the pool).
Normalize a client-supplied SSH username to a tenant name, or nil
when it is not a safe, canonical path component. Shared with the
tenant-option derivation so the key lookup and the workspace mapping can
never disagree about which directory a username names. Names are canonical
lowercase — a mixed-case username is refused, not case-folded, so one
principal has exactly one path and one ledger key on every filesystem.
@spec serve( module(), keyword() ) :: GenServer.on_start()
@spec start_link(keyword()) :: GenServer.on_start()
The per-tenant SSH key directory for user:
<tenants_dir>/<user>/ssh (holding that tenant's authorized_keys).
An unsafe username maps to a reserved path no tenant directory can
ever occupy, so its key lookup fails and auth is refused.
@spec unregister_connection(GenServer.server(), term()) :: :ok
Unregisters a connection from peer_ip when it closes.