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 as idle_time for 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 - true lets an anonymous server bind non-loopback interfaces. Without it (or RAXOL_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 - true accepts any connection (BBS/playground use). Binds loopback unless :anonymous_public acknowledges 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 an authorized_keys file; 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

auth_daemon_opts(opts)

@spec auth_daemon_opts(keyword()) :: {:ok, keyword()} | {:error, :ssh_auth_required}

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.

bind_address(opts)

@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.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

connection_count(server \\ __MODULE__)

@spec connection_count(GenServer.server()) :: non_neg_integer()

Returns the current number of active connections.

default_host_keys_dir()

@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_host_keys(dir)

@spec ensure_host_keys(String.t()) ::
  :ok | {:error, {:ssh_host_keys_insecure, [String.t()]}}

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.

format_peer(ip)

@spec format_peer(term()) :: String.t()

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.

host_key_algs(dir)

@spec host_key_algs(String.t()) :: [String.t()]

Algorithm labels of the host keys present in dir, ed25519 first.

missing_anonymous_caps(opts)

@spec missing_anonymous_caps(keyword()) :: [atom()]

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 [].

port(server \\ __MODULE__)

@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).

posture_line(p)

@spec posture_line(map()) :: String.t()

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.

register_connection(server \\ __MODULE__, peer_ip \\ :unknown)

@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).

sanitize_tenant(user)

@spec sanitize_tenant(String.t() | charlist()) :: String.t() | nil

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.

serve(app_module, opts \\ [])

@spec serve(
  module(),
  keyword()
) :: GenServer.on_start()

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

tenant_user_dir(tenants_dir, user)

@spec tenant_user_dir(String.t(), String.t() | charlist()) :: String.t()

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.

unregister_connection(server \\ __MODULE__, peer_ip \\ :unknown)

@spec unregister_connection(GenServer.server(), term()) :: :ok

Unregisters a connection from peer_ip when it closes.