Termite.SSH (termite_ssh v0.1.2)

Copy Markdown View Source

Runs Termite terminal applications over SSH.

Add Termite.SSH to your application's supervision tree. Each accepted SSH shell starts one temporary child under an internal DynamicSupervisor. The configured entrypoint receives the connection's Termite.SSH.Session in its startup options.

Example

children = [
  {Termite.SSH,
   name: MyApp.SSH,
   port: 2222,
   auth:
     {:public_key,
      [{System.fetch_env!("SSH_USER"), System.fetch_env!("SSH_AUTHORIZED_KEYS_DIR")}]},
   system_dir: Application.app_dir(:my_app, "priv/ssh"),
   entrypoint: {MyApp.TerminalSession, []}}
]

Supervisor.start_link(children, strategy: :one_for_one)

The entrypoint must provide start_link/1 or child_spec/1. The session is inserted into its keyword options under :session, replacing any existing value. The authenticated SSH username is available as session.username:

def start_link(opts) do
  session = Keyword.fetch!(opts, :session)

  Task.start_link(fn ->
    terminal = Termite.SSH.terminal(session)
    run_terminal_application(terminal)
    Termite.SSH.disconnect(session)
  end)
end

When an attached PTY shell closes or the application shuts down normally, the SSH channel makes a best-effort attempt to restore common terminal modes before disconnecting. This cannot restore the terminal if the Erlang node or network connection has already been lost.

Options

The following options are required:

  • :auth - authentication configuration. Use {:public_key, users_or_verifier} or {:password, verifier} outside local development. See "Authentication" below. :none and plaintext credential lists are intended for local development only and require allow_insecure_auth: true.
  • :system_dir - directory containing at least one ssh_host_*_key host key. Run mix termite.ssh.gen_host_key to create a development key.
  • :entrypoint - {module, keyword_options} used to start each session.

Optional settings are:

  • :port - listening port. Defaults to 2222.
  • :ip - listening address. Defaults to the loopback address {127, 0, 0, 1}.
  • :allow_insecure_auth - permits :none or a plaintext credential list when set to true. Defaults to false. Using it logs a warning.
  • :name - name for the SSH server process.
  • :session_supervisor_name - explicit name for the internal session supervisor. For example, when :name is MyApp.SSH, this defaults to MyApp.SSH.SessionSupervisor.
  • :max_sessions - maximum simultaneous SSH connections. Defaults to 100.
  • :max_channels - maximum active channels per connection. Defaults to 1.
  • :terminal_attach_timeout - milliseconds allowed for the entrypoint to attach its terminal after the shell request, or :infinity. Defaults to 5_000.
  • :hello_timeout - milliseconds allowed for the client's first SSH message. Defaults to 10_000.
  • :negotiation_timeout - milliseconds allowed for key exchange and authentication. Defaults to 30_000.
  • :max_initial_idle_time - milliseconds an authenticated connection may wait before opening its first channel. Defaults to 10_000.
  • :idle_time - milliseconds a connection may remain open without any channels. Defaults to 60_000.
  • :max_reader_queue_length - maximum number of messages allowed in the terminal reader's mailbox before an input-flooding client is disconnected. Defaults to 64.
  • :max_terminal_width and :max_terminal_height - upper bounds for client-supplied terminal dimensions. Defaults to 500 by 200.

Authentication

Public-key authentication associates each SSH username with a directory containing an OpenSSH-compatible authorized_keys or authorized_keys2 file:

auth: {:public_key, [{"alice", "/srv/my_app/ssh/alice"}]}

A password verifier is an arity-four function:

auth:
  {:password,
   fn username, password, peer, state ->
     MyApp.Accounts.verify_ssh_password(username, password, peer, state)
   end}

The verifier receives binary username and password values, the peer address, and per-connection state. It must return the same values accepted by OTP's pwdfun/4: a boolean, :disconnect, or {boolean, new_state}. Implement password verification in constant time and apply rate limiting across connections.

Public keys can instead be authorized dynamically with an arity-two verifier:

auth:
  {:public_key,
   fn username, public_key ->
     MyApp.Accounts.authorized_ssh_key?(username, public_key)
   end}

It receives a binary username and OTP's decoded public-key value on every authentication check and must return a boolean. Keep the lookup bounded and free of side effects because SSH clients may present the same key more than once. Exceptions, throws, and exits deny authentication.

Security

auth: :none accepts every client without authentication, while a plaintext credential list keeps passwords directly in application configuration. Either form requires allow_insecure_auth: true. The daemon configures no SSH subsystems or TCP forwarding, rejects every command execution request, and only exposes the Termite CLI channel.

Summary

Types

SSH authentication configuration.

Option accepted by start_link/1.

Password verifier called by the SSH authentication layer.

Result returned by a password verifier.

Public-key verifier called by the SSH authentication layer.

Functions

Returns a specification to start this module under a supervisor.

Requests that the SSH channel for session be closed.

Starts an SSH server linked to the caller.

Starts and attaches a Termite terminal for session.

Types

auth_option()

@type auth_option() ::
  :none
  | [{String.t(), String.t()}]
  | {:password, password_auth_fun()}
  | {:public_key, public_key_auth_fun() | [{String.t(), String.t()}]}

SSH authentication configuration.

option()

@type option() ::
  {:port, pos_integer()}
  | {:ip, :inet.ip_address()}
  | {:allow_insecure_auth, boolean()}
  | {:auth, auth_option()}
  | {:system_dir, String.t()}
  | {:entrypoint, {module(), keyword()}}
  | {:name, GenServer.name()}
  | {:session_supervisor_name, GenServer.name()}
  | {:max_sessions, pos_integer()}
  | {:max_channels, pos_integer()}
  | {:terminal_attach_timeout, timeout()}
  | {:hello_timeout, timeout()}
  | {:negotiation_timeout, timeout()}
  | {:max_initial_idle_time, timeout()}
  | {:idle_time, timeout()}
  | {:max_reader_queue_length, pos_integer()}
  | {:max_terminal_width, pos_integer()}
  | {:max_terminal_height, pos_integer()}

Option accepted by start_link/1.

password_auth_fun()

@type password_auth_fun() :: (String.t(),
                        String.t(),
                        {:inet.ip_address(), :inet.port_number()},
                        term() ->
                          password_auth_result())

Password verifier called by the SSH authentication layer.

password_auth_result()

@type password_auth_result() :: boolean() | :disconnect | {boolean(), term()}

Result returned by a password verifier.

public_key_auth_fun()

@type public_key_auth_fun() :: (String.t(), :public_key.public_key() -> boolean())

Public-key verifier called by the SSH authentication layer.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

disconnect(session)

@spec disconnect(Termite.SSH.Session.t()) :: term()

Requests that the SSH channel for session be closed.

Session entrypoints should call this after their terminal application exits normally. Client disconnects are delivered to the terminal reader as {:signal, :hup}.

start_link(opts)

@spec start_link([option()]) :: GenServer.on_start()

Starts an SSH server linked to the caller.

This function is normally invoked by a supervisor. See the module documentation for required options and defaults.

terminal(session)

@spec terminal(Termite.SSH.Session.t()) :: %Termite.Terminal{
  adapter: term(),
  reader: term(),
  size: term()
}

Starts and attaches a Termite terminal for session.

Call this from the session entrypoint process. Input, output, resize signals, and disconnect signals are routed through the session's SSH channel.