Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ Features:
- HTTP/1
- HTTP/2
- WebSocket
- SSH (exec and shell)
- Built-in TLS with self-signed certificates
- Plug route matching

## Protocols

- `TestServer.HTTP` - HTTP/1, HTTP/2, and WebSocket.
- `TestServer.SSH` - SSH exec and interactive shell.

<!-- MDOC !-->

Expand Down
86 changes: 86 additions & 0 deletions lib/test_server/ssh.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
defmodule TestServer.SSH do
@external_resource "lib/test_server/ssh/README.md"
@moduledoc "lib/test_server/ssh/README.md"
|> File.read!()
|> String.split("<!-- MDOC !-->")
|> Enum.fetch!(1)

alias TestServer.SSH

@spec start(keyword()) :: {:ok, pid()}
def start(options \\ []) do
TestServer.start_instance(__MODULE__, options, &verify_instance!/1)
end

defp verify_instance!(instance) do
verify_handlers!(:exec, instance)
verify_handlers!(:shell, instance)
end

defp verify_handlers!(type, instance) do
instance
|> SSH.Instance.handlers(type)
|> Enum.reject(& &1.suspended)
|> case do
[] ->
:ok

active ->
raise """
#{TestServer.format_instance(__MODULE__, instance)} did not receive #{type} requests for these handlers before the test ended:

#{SSH.Instance.format_handlers(active)}
"""
end
end

@spec stop() :: :ok | {:error, term()}
def stop, do: stop(TestServer.fetch_instance!(__MODULE__))

@spec stop(pid()) :: :ok | {:error, term()}
def stop(instance), do: TestServer.stop_instance(__MODULE__, instance)

@spec address() :: {binary(), :inet.port_number()}
def address, do: address(TestServer.fetch_instance!(__MODULE__))

@spec address(pid()) :: {binary(), :inet.port_number()}
def address(instance) do
TestServer.ensure_instance_alive!(__MODULE__, instance)
options = SSH.Instance.get_options(instance)
{"localhost", Keyword.fetch!(options, :port)}
end

@spec exec(keyword()) :: :ok
def exec(options) when is_list(options) do
{:ok, instance} = TestServer.autostart_instance(__MODULE__)
exec(instance, options)
end

@spec exec(pid(), keyword()) :: :ok
def exec(instance, options) when is_pid(instance) and is_list(options) do
TestServer.ensure_instance_alive!(__MODULE__, instance)
[_first_module_entry | stacktrace] = TestServer.get_pruned_stacktrace(__MODULE__)
options = Keyword.put_new(options, :to, &default_exec_handler/2)
{:ok, _handler} = SSH.Instance.register(instance, {:exec, options, stacktrace})
:ok
end

defp default_exec_handler(_cmd, state), do: {:reply, {0, "", ""}, state}

@spec shell(keyword()) :: :ok
def shell(options) when is_list(options) do
{:ok, instance} = TestServer.autostart_instance(__MODULE__)
shell(instance, options)
end

@spec shell(pid(), keyword()) :: :ok
def shell(instance, options) when is_pid(instance) and is_list(options) do
TestServer.ensure_instance_alive!(__MODULE__, instance)
[_first_module_entry | stacktrace] = TestServer.get_pruned_stacktrace(__MODULE__)
options = Keyword.put_new(options, :to, &default_shell_handler/2)
{:ok, _handler} = SSH.Instance.register(instance, {:shell, options, stacktrace})
:ok
end

defp default_shell_handler(data, state), do: {:reply, data, state}
end
90 changes: 90 additions & 0 deletions lib/test_server/ssh/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# SSH

<!-- MDOC !-->

Mock SSH servers with exec and shell handler expectations, password and public key authentication.

### Exec

Add exec handler expectations with `TestServer.SSH.exec/1`. By default the handler returns exit code 0 with empty stdout and stderr.

```elixir
test "runs a remote command" do
# The test server will autostart if not already running
TestServer.SSH.exec(to: fn _cmd, state -> {:reply, {0, "deployed v1.0\n", ""}, state} end)

{host, port} = TestServer.SSH.address()
assert {:ok, 0, "deployed v1.0\n", ""} = MyApp.SSH.run(host, port, "deploy")
end
```

The `:to` handler receives `{command, state}` and must return `{:reply, {exit_code, stdout, stderr}, state}`:

```elixir
TestServer.SSH.exec(to: fn _cmd, state -> {:reply, {1, "", "permission denied\n"}, state} end)
```

A `:match` function can be set to conditionally dispatch to a handler:

```elixir
TestServer.SSH.exec(match: fn cmd, _state -> cmd == "deploy" end, to: &deploy_handler/2)
TestServer.SSH.exec(match: fn cmd, _state -> cmd == "status" end, to: &status_handler/2)
```

When a handler is matched it is consumed. Handlers are dispatched in the order they were added (FIFO):

```elixir
TestServer.SSH.exec(to: fn _, state -> {:reply, {0, "deploy 1\n", ""}, state} end)
TestServer.SSH.exec(to: fn _, state -> {:reply, {0, "deploy 2\n", ""}, state} end)

assert {:ok, 0, "deploy 1\n", ""} = MyApp.SSH.run(host, port, "deploy")
assert {:ok, 0, "deploy 2\n", ""} = MyApp.SSH.run(host, port, "deploy")
```

### Shell

Add shell handler expectations with `TestServer.SSH.shell/1`. By default the handler echoes received data.

```elixir
test "interactive shell session" do
TestServer.SSH.shell(to: fn data, state -> {:reply, "echo: " <> data, state} end)

{host, port} = TestServer.SSH.address()
{:ok, response} = MyApp.SSH.shell(host, port, "hello\n")
assert response == "echo: hello\n"
end
```

### Authentication

#### Password

Pass a list of `{username, password}` tuples as `:credentials`:

```elixir
{:ok, instance} = TestServer.SSH.start(credentials: [{"deploy", "hunter2"}])
TestServer.SSH.exec(instance, to: fn _, state -> {:reply, {0, "ok\n", ""}, state} end)

{host, port} = TestServer.SSH.address(instance)
assert {:ok, 0, "ok\n", ""} = MyApp.SSH.run(host, port, "deploy", user: "deploy", password: "hunter2")
```

#### Public Key

Pass a `{username, :public_key, pem_binary}` tuple in `:credentials`:

```elixir
pem = File.read!("test/fixtures/id_rsa.pem")
{:ok, instance} = TestServer.SSH.start(credentials: [{"deploy", :public_key, pem}])
```

#### No Authentication

Omit the `:credentials` option entirely to accept any connection without authentication:

```elixir
{:ok, _instance} = TestServer.SSH.start()
# All clients are accepted regardless of credentials
```

<!-- MDOC !-->
120 changes: 120 additions & 0 deletions lib/test_server/ssh/channel.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
defmodule TestServer.SSH.Channel do
@moduledoc false

@behaviour :ssh_server_channel

alias TestServer.SSH.Instance

defstruct [:instance, :channel_id, :connection, type: nil, handler_state: %{}]

@impl true
def init(instance: instance) do
{:ok, %__MODULE__{instance: instance}}
end

@impl true
def handle_msg({:ssh_channel_up, channel_id, connection}, state) do
{:ok, %{state | channel_id: channel_id, connection: connection}}
end

def handle_msg(_msg, state) do
{:ok, state}
end

@impl true
def handle_ssh_msg({:ssh_cm, conn, {:exec, ch_id, want_reply, command}}, state) do
command = to_string(command)
:ssh_connection.reply_request(conn, want_reply, :success, ch_id)

case GenServer.call(state.instance, {:dispatch, {:exec, command, state.handler_state}}) do
{:ok, {:reply, {exit_code, stdout, stderr}, new_handler_state}} ->
unless IO.iodata_length(stdout) == 0,
do: :ssh_connection.send(conn, ch_id, stdout)

unless IO.iodata_length(stderr) == 0,
do: :ssh_connection.send(conn, ch_id, 1, stderr)

:ssh_connection.exit_status(conn, ch_id, exit_code)
:ssh_connection.send_eof(conn, ch_id)
:ssh_connection.close(conn, ch_id)
{:stop, ch_id, %{state | handler_state: new_handler_state}}

{:ok, {:ok, new_handler_state}} ->
:ssh_connection.exit_status(conn, ch_id, 0)
:ssh_connection.send_eof(conn, ch_id)
:ssh_connection.close(conn, ch_id)
{:stop, ch_id, %{state | handler_state: new_handler_state}}

{:error, :not_found} ->
message =
"#{TestServer.format_instance(TestServer.SSH, state.instance)} received an unexpected SSH exec request: #{inspect(command)}"

report_error_and_close_exec(conn, ch_id, state, RuntimeError.exception(message), [])

{:error, {exception, stacktrace}} ->
report_error_and_close_exec(conn, ch_id, state, exception, stacktrace)
end
end

def handle_ssh_msg({:ssh_cm, conn, {:shell, ch_id, want_reply}}, state) do
:ssh_connection.reply_request(conn, want_reply, :success, ch_id)
{:ok, %{state | type: :shell, channel_id: ch_id, connection: conn}}
end

def handle_ssh_msg({:ssh_cm, conn, {:data, ch_id, 0, data}}, %{type: :shell} = state) do
:ssh_connection.adjust_window(conn, ch_id, byte_size(data))

case GenServer.call(state.instance, {:dispatch, {:shell, data, state.handler_state}}) do
{:ok, {:reply, output, new_handler_state}} ->
:ssh_connection.send(conn, ch_id, output)
{:ok, %{state | handler_state: new_handler_state}}

{:ok, {:ok, new_handler_state}} ->
{:ok, %{state | handler_state: new_handler_state}}

{:error, :not_found} ->
message =
"#{TestServer.format_instance(TestServer.SSH, state.instance)} received unexpected SSH shell data: #{inspect(data)}"

Instance.report_error(state.instance, {RuntimeError.exception(message), []})
{:ok, state}

{:error, {exception, stacktrace}} ->
Instance.report_error(state.instance, {exception, stacktrace})
{:ok, state}
end
end

def handle_ssh_msg({:ssh_cm, conn, {:pty, ch_id, want_reply, _pty_info}}, state) do
:ssh_connection.reply_request(conn, want_reply, :success, ch_id)
{:ok, state}
end

def handle_ssh_msg({:ssh_cm, conn, {:env, ch_id, want_reply, _name, _value}}, state) do
:ssh_connection.reply_request(conn, want_reply, :success, ch_id)
{:ok, state}
end

def handle_ssh_msg({:ssh_cm, _conn, {:eof, _ch_id}}, state) do
{:ok, state}
end

def handle_ssh_msg({:ssh_cm, _conn, {:closed, ch_id}}, state) do
{:stop, ch_id, state}
end

def handle_ssh_msg(_msg, state) do
{:ok, state}
end

@impl true
def terminate(_reason, _state), do: :ok

defp report_error_and_close_exec(conn, ch_id, state, exception, stacktrace) do
Instance.report_error(state.instance, {exception, stacktrace})
:ssh_connection.exit_status(conn, ch_id, 1)
:ssh_connection.send_eof(conn, ch_id)
:ssh_connection.close(conn, ch_id)
{:stop, ch_id, state}
end
end
Loading
Loading