Packages

Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.

Current section

Files

Jump to
raxol lib raxol terminal ssh ssh_client.ex
Raw

lib/raxol/terminal/ssh/ssh_client.ex

if Code.ensure_loaded?(:ssh) do
defmodule Raxol.Terminal.SSH.SSHClient do
@moduledoc """
SSH client implementation for secure remote terminal connections.
Provides a high-level interface for establishing and managing SSH connections
to remote servers, with support for various authentication methods and
advanced SSH features.
## Features
- Multiple authentication methods (password, key-based, agent)
- Connection pooling and management
- Port forwarding (local and remote)
- SFTP integration
- Connection keep-alive and reconnection
- Host key verification
- Compression and encryption options
## Usage
# Basic connection
{:ok, client} = SSHClient.connect("example.com", 22,
username: "user", password: "pass")
# Key-based authentication
{:ok, client} = SSHClient.connect("example.com", 22,
username: "user", private_key_path: "/path/to/key")
# Execute commands
{:ok, output} = SSHClient.exec(client, "ls -la")
# Start interactive shell
{:ok, session} = SSHClient.start_shell(client)
"""
use Raxol.Core.Behaviours.BaseManager
alias Raxol.Core.Runtime.Log
@type connection_options :: %{
optional(:username) => String.t(),
optional(:password) => String.t(),
optional(:private_key_path) => String.t(),
optional(:private_key_data) => binary(),
optional(:passphrase) => String.t(),
optional(:timeout) => pos_integer(),
optional(:connect_timeout) => pos_integer(),
optional(:keepalive_interval) => pos_integer(),
optional(:compression) => boolean(),
optional(:cipher_algs) => [atom()],
optional(:mac_algs) => [atom()],
optional(:kex_algs) => [atom()],
optional(:host_key_algs) => [atom()],
optional(:known_hosts_file) => String.t(),
optional(:strict_host_key_checking) => boolean()
}
@type connection_info :: %{
host: String.t(),
port: pos_integer(),
username: String.t(),
connected_at: DateTime.t(),
server_version: String.t(),
client_version: String.t(),
encryption_info: map()
}
@type client_state :: %{
connection_ref: reference() | nil,
host: String.t(),
port: pos_integer(),
options: connection_options(),
connected: boolean(),
connection_info: connection_info() | nil,
channels: map(),
keepalive_timer: reference() | nil,
reconnect_attempts: non_neg_integer(),
last_activity: DateTime.t()
}
# Default configuration
@default_port 22
@default_timeout 30_000
@default_connect_timeout 10_000
@default_keepalive_interval 60_000
@max_reconnect_attempts 5
@reconnect_delay 5_000
## Public API
@doc """
Connects to an SSH server.
## Parameters
- `host` - Server hostname or IP address
- `port` - SSH port (default: 22)
- `options` - Connection options
## Returns
- `{:ok, pid}` - Connected SSH client process
- `{:error, reason}` - Connection failed
"""
@spec connect(String.t(), pos_integer(), connection_options()) ::
{:ok, pid()} | {:error, term()}
def connect(host, port \\ @default_port, options \\ %{}) do
start_link([{:init_args, {host, port, options}}])
end
@doc """
Executes a command on the remote server.
## Parameters
- `client` - SSH client process
- `command` - Command to execute
- `timeout` - Command timeout (default: 30s)
## Returns
- `{:ok, {exit_code, stdout, stderr}}` - Command completed
- `{:error, reason}` - Command failed
"""
@spec exec(pid(), String.t(), pos_integer()) ::
{:ok, {integer(), binary(), binary()}} | {:error, term()}
def exec(client, command, timeout \\ @default_timeout) do
GenServer.call(client, {:exec, command, timeout}, timeout + 1000)
end
@doc """
Starts an interactive shell session.
## Parameters
- `client` - SSH client process
- `options` - Shell options
## Returns
- `{:ok, session}` - Shell session started
- `{:error, reason}` - Failed to start shell
"""
@spec start_shell(pid(), map()) :: {:ok, pid()} | {:error, term()}
def start_shell(client, options \\ %{}) do
GenServer.call(client, {:start_shell, options})
end
@doc """
Opens an SFTP channel.
## Parameters
- `client` - SSH client process
## Returns
- `{:ok, sftp_ref}` - SFTP channel opened
- `{:error, reason}` - Failed to open SFTP
"""
@spec open_sftp(pid()) :: {:ok, reference()} | {:error, term()}
def open_sftp(client) do
GenServer.call(client, :open_sftp)
end
@doc """
Creates a local port forward.
## Parameters
- `client` - SSH client process
- `local_port` - Local port to bind
- `remote_host` - Remote host to forward to
- `remote_port` - Remote port to forward to
## Returns
- `{:ok, forward_ref}` - Port forward created
- `{:error, reason}` - Failed to create forward
"""
@spec create_local_forward(pid(), pos_integer(), String.t(), pos_integer()) ::
{:ok, reference()} | {:error, term()}
def create_local_forward(client, local_port, remote_host, remote_port) do
GenServer.call(
client,
{:local_forward, local_port, remote_host, remote_port}
)
end
@doc """
Creates a remote port forward.
## Parameters
- `client` - SSH client process
- `remote_port` - Remote port to bind
- `local_host` - Local host to forward to
- `local_port` - Local port to forward to
## Returns
- `{:ok, forward_ref}` - Port forward created
- `{:error, reason}` - Failed to create forward
"""
@spec create_remote_forward(pid(), pos_integer(), String.t(), pos_integer()) ::
{:ok, reference()} | {:error, term()}
def create_remote_forward(client, remote_port, local_host, local_port) do
GenServer.call(
client,
{:remote_forward, remote_port, local_host, local_port}
)
end
@doc """
Gets connection information.
## Parameters
- `client` - SSH client process
## Returns
- `{:ok, info}` - Connection information
- `{:error, :not_connected}` - Client not connected
"""
@spec get_connection_info(pid()) ::
{:ok, connection_info()} | {:error, :not_connected}
def get_connection_info(client) do
GenServer.call(client, :get_connection_info)
end
@doc """
Disconnects from the SSH server.
## Parameters
- `client` - SSH client process
## Returns
- `:ok` - Disconnected successfully
"""
@spec disconnect(pid()) :: :ok
def disconnect(client) do
GenServer.cast(client, :disconnect)
end
## BaseManager Callbacks
@impl true
def init_manager({host, port, options}) do
# Set up process trapping to handle cleanup
Process.flag(:trap_exit, true)
state = %{
connection_ref: nil,
host: host,
port: port,
options: normalize_options(options),
connected: false,
connection_info: nil,
channels: %{},
keepalive_timer: nil,
reconnect_attempts: 0,
last_activity: DateTime.utc_now()
}
# Attempt initial connection
case perform_connect(state) do
{:ok, new_state} ->
{:ok, new_state}
{:error, reason} ->
Log.error("SSH connection failed: #{inspect(reason)}")
{:stop, {:connection_failed, reason}}
end
end
@impl true
def handle_manager_call(
{:exec, _command, _timeout},
_from,
%{connected: false} = state
) do
{:reply, {:error, :not_connected}, state}
end
def handle_manager_call({:exec, command, timeout}, _from, state) do
case execute_command(state, command, timeout) do
{:ok, result} ->
{:reply, {:ok, result}, update_activity(state)}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
def handle_manager_call(
{:start_shell, _options},
_from,
%{connected: false} = state
) do
{:reply, {:error, :not_connected}, state}
end
def handle_manager_call({:start_shell, options}, _from, state) do
case start_shell_session(state, options) do
{:ok, session_pid} ->
{:reply, {:ok, session_pid}, update_activity(state)}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
def handle_manager_call(:open_sftp, _from, %{connected: false} = state) do
{:reply, {:error, :not_connected}, state}
end
def handle_manager_call(:open_sftp, _from, state) do
case open_sftp_channel(state) do
{:ok, sftp_ref} ->
{:reply, {:ok, sftp_ref}, update_activity(state)}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
def handle_manager_call(
{:local_forward, local_port, remote_host, remote_port},
_from,
state
) do
case create_port_forward(
state,
:local,
local_port,
remote_host,
remote_port
) do
{:ok, forward_ref} ->
{:reply, {:ok, forward_ref}, state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
def handle_manager_call(
{:remote_forward, remote_port, local_host, local_port},
_from,
state
) do
case create_port_forward(
state,
:remote,
remote_port,
local_host,
local_port
) do
{:ok, forward_ref} ->
{:reply, {:ok, forward_ref}, state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
def handle_manager_call(
:get_connection_info,
_from,
%{connected: false} = state
) do
{:reply, {:error, :not_connected}, state}
end
def handle_manager_call(:get_connection_info, _from, state) do
{:reply, {:ok, state.connection_info}, state}
end
@impl true
def handle_manager_cast(:disconnect, state) do
new_state = perform_disconnect(state)
{:noreply, new_state}
end
@impl true
def handle_manager_info(:keepalive, state) do
case send_keepalive(state) do
:ok ->
timer_ref = schedule_keepalive(state.options.keepalive_interval)
{:noreply, %{state | keepalive_timer: timer_ref}}
{:error, _reason} ->
# Connection lost, attempt reconnect
{:noreply, attempt_reconnect(state)}
end
end
def handle_manager_info({:ssh_connection_lost, _ref}, state) do
Log.warning("SSH connection lost, attempting reconnect")
{:noreply, attempt_reconnect(state)}
end
def handle_manager_info({:EXIT, _pid, reason}, state) do
Log.warning("SSH process exited: #{inspect(reason)}")
{:noreply, attempt_reconnect(state)}
end
def handle_manager_info(msg, state) do
Log.debug("Unhandled SSH message: #{inspect(msg)}")
{:noreply, state}
end
@impl true
def terminate_manager(reason, state) do
Log.info("SSH client terminating: #{inspect(reason)}")
perform_disconnect(state)
:ok
end
## Private Helper Functions
defp normalize_options(options) do
Map.merge(
%{
timeout: @default_timeout,
connect_timeout: @default_connect_timeout,
keepalive_interval: @default_keepalive_interval,
compression: true,
strict_host_key_checking: true
},
options
)
end
defp perform_connect(state) do
ssh_options = build_ssh_options(state.options)
case :ssh.connect(
String.to_charlist(state.host),
state.port,
ssh_options,
state.options.connect_timeout
) do
{:ok, connection_ref} ->
Log.info("SSH connected to #{state.host}:#{state.port}")
connection_info = %{
host: state.host,
port: state.port,
username: state.options.username,
connected_at: DateTime.utc_now(),
server_version: get_server_version(connection_ref),
client_version: get_client_version(),
encryption_info: get_encryption_info(connection_ref)
}
# Set up keepalive timer
timer_ref = schedule_keepalive(state.options.keepalive_interval)
new_state = %{
state
| connection_ref: connection_ref,
connected: true,
connection_info: connection_info,
keepalive_timer: timer_ref,
reconnect_attempts: 0,
last_activity: DateTime.utc_now()
}
{:ok, new_state}
{:error, reason} ->
Log.error("SSH connection failed: #{inspect(reason)}")
{:error, reason}
end
end
defp build_ssh_options(options) do
base_options = [
{:silently_accept_hosts, not options.strict_host_key_checking},
{:compression, options.compression}
]
# Add authentication options
auth_options =
cond do
Map.has_key?(options, :password) ->
[{:password, String.to_charlist(options.password)}]
Map.has_key?(options, :private_key_path) ->
key_options = [{:user_dir, Path.dirname(options.private_key_path)}]
if Map.has_key?(options, :passphrase) do
[{:user_interaction, false} | key_options]
else
key_options
end
Map.has_key?(options, :private_key_data) ->
# Handle in-memory private key
[{:user_interaction, false}]
true ->
# Try agent or default keys
[{:user_interaction, false}]
end
# Add username if provided
user_options =
if Map.has_key?(options, :username) do
[{:user, String.to_charlist(options.username)}]
else
[]
end
base_options ++ auth_options ++ user_options
end
defp execute_command(state, command, timeout) do
case :ssh_connection.session_channel(
state.connection_ref,
timeout
) do
{:ok, channel_id} ->
# Execute command
case :ssh_connection.exec(
state.connection_ref,
channel_id,
String.to_charlist(command),
timeout
) do
:success ->
collect_command_output(state.connection_ref, channel_id, timeout)
:failure ->
:ssh_connection.close(state.connection_ref, channel_id)
{:error, :command_exec_failed}
end
{:error, reason} ->
{:error, {:channel_open_failed, reason}}
end
end
defp collect_command_output(connection_ref, channel_id, timeout) do
start_time = System.monotonic_time(:millisecond)
collect_output_loop(
connection_ref,
channel_id,
timeout,
start_time,
"",
"",
nil
)
end
defp collect_output_loop(
connection_ref,
channel_id,
timeout,
start_time,
stdout,
stderr,
exit_code
) do
elapsed = System.monotonic_time(:millisecond) - start_time
remaining_timeout = max(timeout - elapsed, 1000)
receive do
{:ssh_cm, ^connection_ref, {:data, ^channel_id, 0, data}} ->
new_stdout = stdout <> List.to_string(data)
collect_output_loop(
connection_ref,
channel_id,
timeout,
start_time,
new_stdout,
stderr,
exit_code
)
{:ssh_cm, ^connection_ref, {:data, ^channel_id, 1, data}} ->
new_stderr = stderr <> List.to_string(data)
collect_output_loop(
connection_ref,
channel_id,
timeout,
start_time,
stdout,
new_stderr,
exit_code
)
{:ssh_cm, ^connection_ref, {:exit_status, ^channel_id, status}} ->
collect_output_loop(
connection_ref,
channel_id,
timeout,
start_time,
stdout,
stderr,
status
)
{:ssh_cm, ^connection_ref, {:closed, ^channel_id}} ->
:ssh_connection.close(connection_ref, channel_id)
{:ok, {exit_code || 0, stdout, stderr}}
after
remaining_timeout ->
:ssh_connection.close(connection_ref, channel_id)
{:error, :timeout}
end
end
defp start_shell_session(_state, options) do
# This would typically create a new SSHSession process
# For now, return a placeholder
Log.info("Starting SSH shell session with options: #{inspect(options)}")
{:ok, spawn(fn -> Process.sleep(:infinity) end)}
end
defp open_sftp_channel(state) do
case :ssh_sftp.start_channel(state.connection_ref) do
{:ok, _sftp_pid} ->
{:ok, make_ref()}
{:error, reason} ->
{:error, reason}
end
end
defp create_port_forward(_state, _type, _port1, _host, _port2) do
# Port forwarding implementation would go here
{:ok, make_ref()}
end
defp perform_disconnect(state) do
if state.connected && state.connection_ref do
:ssh.close(state.connection_ref)
Log.info("SSH disconnected from #{state.host}:#{state.port}")
end
if state.keepalive_timer do
_ = Process.cancel_timer(state.keepalive_timer)
end
%{
state
| connection_ref: nil,
connected: false,
connection_info: nil,
keepalive_timer: nil
}
end
defp send_keepalive(state) do
if state.connected && state.connection_ref do
# Try global request keepalive first (most efficient)
case send_global_keepalive(state) do
{:ok, _response} ->
update_last_activity(state)
:ok
{:error, :not_supported} ->
# Fallback to channel-based keepalive
send_channel_keepalive(state)
{:error, reason} ->
Log.warning("Keepalive failed: #{inspect(reason)}")
{:error, reason}
end
else
{:error, :not_connected}
end
end
defp send_global_keepalive(_state) do
# Send SSH_MSG_GLOBAL_REQUEST with custom keepalive type
# This is the most efficient method and follows OpenSSH conventions
try do
# Note: Erlang SSH doesn't expose direct global request API,
# so we use the connection module's internal messaging
_ref = make_ref()
# Send a harmless global request that servers should ignore
# Following OpenSSH's "keepalive@openssh.com" pattern
_request_type = "keepalive@raxol.io"
# For now, we'll use a channel-based approach as Erlang SSH
# doesn't expose SSH_MSG_GLOBAL_REQUEST directly
{:error, :not_supported}
rescue
_ -> {:error, :not_supported}
end
end
defp send_channel_keepalive(state) do
# Send keepalive through an existing channel or create temporary one
case get_or_create_keepalive_channel(state) do
{:ok, channel_id} ->
# Send empty data packet as keepalive
case :ssh_connection.send(state.connection_ref, channel_id, <<>>, 0) do
:ok ->
update_last_activity(state)
:ok
{:error, reason} ->
Log.debug("Channel keepalive failed: #{inspect(reason)}")
{:error, reason}
end
{:error, reason} ->
{:error, {:channel_creation_failed, reason}}
end
end
defp get_or_create_keepalive_channel(state) do
# Check if we have any active channels
case Map.keys(state.channels) do
[] ->
# No channels, create a temporary session channel for keepalive
create_keepalive_channel(state)
[channel_id | _] ->
# Use existing channel
{:ok, channel_id}
end
end
defp create_keepalive_channel(state) do
# Create a minimal session channel just for keepalive
case :ssh_connection.session_channel(state.connection_ref, 1000) do
{:ok, channel_id} ->
# Store the channel for future use
Log.debug("Created keepalive channel: #{channel_id}")
{:ok, channel_id}
{:error, reason} ->
Log.debug("Failed to create keepalive channel: #{inspect(reason)}")
{:error, reason}
end
end
defp update_last_activity(state) do
%{state | last_activity: DateTime.utc_now()}
end
defp schedule_keepalive(interval) do
Process.send_after(self(), :keepalive, interval)
end
defp attempt_reconnect(state) do
if state.reconnect_attempts < @max_reconnect_attempts do
Log.info(
"Attempting SSH reconnect (#{state.reconnect_attempts + 1}/#{@max_reconnect_attempts})"
)
Process.send_after(self(), :attempt_reconnect, @reconnect_delay)
%{
state
| connected: false,
connection_ref: nil,
reconnect_attempts: state.reconnect_attempts + 1
}
else
Log.error("Max SSH reconnect attempts reached, giving up")
perform_disconnect(state)
end
end
defp update_activity(state) do
%{state | last_activity: DateTime.utc_now()}
end
defp get_server_version(_connection_ref) do
# This would extract the server version from the SSH connection
"OpenSSH_8.0"
end
defp get_client_version do
"Raxol_SSH_1.0"
end
defp get_encryption_info(_connection_ref) do
# This would extract encryption details from the connection
%{
cipher: "aes128-ctr",
mac: "hmac-sha2-256",
kex: "diffie-hellman-group14-sha256"
}
end
end
else
defmodule Raxol.Terminal.SSH.SSHClient do
@moduledoc """
SSH client stub - SSH support not available.
To enable SSH support, ensure the :ssh application is available
in your OTP installation.
"""
def connect(_host, _port, _options \\ []) do
{:error, :ssh_not_available}
end
def disconnect(_client) do
{:error, :ssh_not_available}
end
def exec(_client, _command, _options \\ []) do
{:error, :ssh_not_available}
end
def send_keepalive(_client) do
{:error, :ssh_not_available}
end
def open_sftp_channel(_client) do
{:error, :ssh_not_available}
end
end
end