Skip to content

Commit db118cd

Browse files
committed
Add TestServer.SSH
1 parent 49ccbd4 commit db118cd

9 files changed

Lines changed: 958 additions & 1 deletion

File tree

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Features:
1212
- HTTP/1
1313
- HTTP/2
1414
- WebSocket
15+
- SSH
1516
- Built-in TLS with self-signed certificates
1617
- Plug route matching
1718

@@ -140,6 +141,51 @@ end
140141

141142
*Note: WebSocket is not supported by the `:httpd` adapter.*
142143

144+
### SSH
145+
146+
SSH exec and shell handlers can be registered with `TestServer.SSH.exec/1` and `TestServer.SSH.shell/1`. The server autostarts on first use and is torn down when the test exits.
147+
148+
```elixir
149+
test "run remote command" do
150+
TestServer.SSH.exec(to: fn _cmd, state ->
151+
{:reply, {0, "file1\nfile2\n", ""}, state}
152+
end)
153+
154+
{host, port} = TestServer.SSH.address()
155+
{:ok, conn} = :ssh.connect(String.to_charlist(host), port,
156+
user: ~c"test",
157+
silently_accept_hosts: true,
158+
user_interaction: false
159+
)
160+
161+
{:ok, ch} = :ssh_connection.session_channel(conn, :infinity)
162+
:success = :ssh_connection.exec(conn, ch, ~c"ls", :infinity)
163+
# collect stdout, exit_status, closed messages...
164+
end
165+
```
166+
167+
Password and public key credentials can be set with the `:credentials` option:
168+
169+
```elixir
170+
# Password auth
171+
TestServer.SSH.start(credentials: [{"alice", "secret"}])
172+
173+
# Public key auth
174+
TestServer.SSH.start(credentials: [{"bob", :public_key, pem_binary}])
175+
176+
# No auth (default — accepts any connection)
177+
TestServer.SSH.start()
178+
```
179+
180+
Handlers are matched FIFO and can be filtered with `:match`:
181+
182+
```elixir
183+
TestServer.SSH.exec(
184+
match: fn cmd, _state -> cmd == "ls" end,
185+
to: fn _cmd, state -> {:reply, {0, "file1\n", ""}, state} end
186+
)
187+
```
188+
143189
### HTTP Server Adapter
144190

145191
TestServer supports `Bandit`, `Plug.Cowboy`, and `:httpd` out of the box. The HTTP adapter will be selected in this order depending which is available in the dependencies. You can also explicitly set the http server in the configuration when calling `TestServer.HTTP.start/1`:

lib/test_server/ssh.ex

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
defmodule TestServer.SSH do
2+
@moduledoc false
3+
4+
alias TestServer.{InstanceManager, SSH}
5+
6+
@spec start(keyword()) :: {:ok, pid()}
7+
def start(options \\ []) do
8+
case ExUnit.fetch_test_supervisor() do
9+
{:ok, _sup} -> start_with_ex_unit(options)
10+
:error -> raise ArgumentError, "can only be called in a test process"
11+
end
12+
end
13+
14+
defp start_with_ex_unit(options) do
15+
[_first_module_entry | stacktrace] = get_stacktrace()
16+
caller = self()
17+
18+
options =
19+
options
20+
|> Keyword.put_new(:caller, caller)
21+
|> Keyword.put_new(:stacktrace, stacktrace)
22+
23+
case InstanceManager.start_instance(caller, SSH.Instance.child_spec(options)) do
24+
{:ok, instance} ->
25+
put_ex_unit_on_exit_callback(instance)
26+
{:ok, instance}
27+
28+
{:error, error} ->
29+
raise_start_failure({:error, error})
30+
end
31+
end
32+
33+
defp put_ex_unit_on_exit_callback(instance) do
34+
ExUnit.Callbacks.on_exit(fn ->
35+
if Process.alive?(instance) do
36+
verify_handlers!(:exec, instance)
37+
verify_handlers!(:shell, instance)
38+
stop(instance)
39+
end
40+
end)
41+
end
42+
43+
defp verify_handlers!(type, instance) do
44+
handlers_fn =
45+
if type == :exec, do: &SSH.Instance.exec_handlers/1, else: &SSH.Instance.shell_handlers/1
46+
47+
instance
48+
|> handlers_fn.()
49+
|> Enum.reject(& &1.suspended)
50+
|> case do
51+
[] ->
52+
:ok
53+
54+
active ->
55+
raise """
56+
#{SSH.Instance.format_instance(instance)} did not receive #{type} requests for these handlers before the test ended:
57+
58+
#{SSH.Instance.format_handlers(active)}
59+
"""
60+
end
61+
end
62+
63+
@spec stop(pid()) :: :ok | {:error, term()}
64+
def stop(instance) do
65+
instance_alive!(instance)
66+
InstanceManager.stop_instance(instance)
67+
end
68+
69+
@spec address() :: {binary(), :inet.port_number()}
70+
def address, do: address(fetch_instance!())
71+
72+
@spec address(pid()) :: {binary(), :inet.port_number()}
73+
def address(instance) do
74+
instance_alive!(instance)
75+
options = SSH.Instance.get_options(instance)
76+
{"localhost", Keyword.fetch!(options, :port)}
77+
end
78+
79+
@spec exec(keyword()) :: :ok
80+
def exec(options) when is_list(options) do
81+
{:ok, instance} = autostart()
82+
exec(instance, options)
83+
end
84+
85+
@spec exec(pid(), keyword()) :: :ok
86+
def exec(instance, options) when is_pid(instance) and is_list(options) do
87+
instance_alive!(instance)
88+
[_first_module_entry | stacktrace] = get_stacktrace()
89+
options = Keyword.put_new(options, :to, &default_exec_handler/2)
90+
{:ok, _handler} = SSH.Instance.register(instance, {:exec, options, stacktrace})
91+
:ok
92+
end
93+
94+
defp default_exec_handler(_cmd, state), do: {:reply, {0, "", ""}, state}
95+
96+
@spec shell(keyword()) :: :ok
97+
def shell(options) when is_list(options) do
98+
{:ok, instance} = autostart()
99+
shell(instance, options)
100+
end
101+
102+
@spec shell(pid(), keyword()) :: :ok
103+
def shell(instance, options) when is_pid(instance) and is_list(options) do
104+
instance_alive!(instance)
105+
[_first_module_entry | stacktrace] = get_stacktrace()
106+
options = Keyword.put_new(options, :to, &default_shell_handler/2)
107+
{:ok, _handler} = SSH.Instance.register(instance, {:shell, options, stacktrace})
108+
:ok
109+
end
110+
111+
defp default_shell_handler(data, state), do: {:reply, data, state}
112+
113+
defp autostart do
114+
case fetch_instance() do
115+
:error -> start()
116+
{:ok, instance} -> {:ok, instance}
117+
end
118+
end
119+
120+
defp fetch_instance! do
121+
case fetch_instance() do
122+
:error -> raise "No current #{inspect(SSH.Instance)} running"
123+
{:ok, instance} -> instance
124+
end
125+
end
126+
127+
defp fetch_instance do
128+
instances = InstanceManager.get_by_caller(self()) || []
129+
ssh_instances = Enum.filter(instances, &ssh_instance?/1)
130+
131+
case ssh_instances do
132+
[] ->
133+
:error
134+
135+
[instance] ->
136+
{:ok, instance}
137+
138+
[_first | _rest] = multiple ->
139+
[{m, f, a, _} | _] = get_stacktrace()
140+
141+
formatted =
142+
multiple
143+
|> Enum.map(&{&1, SSH.Instance.get_options(&1)})
144+
|> Enum.with_index()
145+
|> Enum.map_join("\n\n", fn {{instance, options}, index} ->
146+
"""
147+
##{index + 1}: #{SSH.Instance.format_instance(instance)}
148+
#{Enum.map_join(options[:stacktrace], "\n ", &Exception.format_stacktrace_entry/1)}
149+
"""
150+
end)
151+
152+
raise """
153+
Multiple #{inspect(SSH.Instance)}'s running, please pass instance to `#{inspect(m)}.#{f}/#{a}`.
154+
155+
#{formatted}
156+
"""
157+
end
158+
end
159+
160+
defp ssh_instance?(pid) do
161+
SSH.Instance.get_options(pid)[:protocol] == :ssh
162+
rescue
163+
_ -> false
164+
end
165+
166+
defp instance_alive!(instance) do
167+
unless Process.alive?(instance),
168+
do: raise("#{SSH.Instance.format_instance(instance)} is not running")
169+
end
170+
171+
defp raise_start_failure({:error, {{:EXIT, reason}, _spec}}) do
172+
raise_start_failure({:error, reason})
173+
end
174+
175+
defp raise_start_failure({:error, error}) do
176+
raise """
177+
EXIT when starting #{inspect(SSH.Instance)}:
178+
179+
#{Exception.format_exit(error)}
180+
"""
181+
end
182+
183+
defp get_stacktrace do
184+
{:current_stacktrace, [{Process, :info, _, _} | stacktrace]} =
185+
Process.info(self(), :current_stacktrace)
186+
187+
first_module_entry =
188+
stacktrace
189+
|> Enum.reverse()
190+
|> Enum.find(fn {mod, _, _, _} -> mod == __MODULE__ end)
191+
192+
[first_module_entry] ++ prune_stacktrace(stacktrace)
193+
end
194+
195+
defp prune_stacktrace([{__MODULE__, _, _, _} | t]), do: prune_stacktrace(t)
196+
defp prune_stacktrace([{ExUnit.Assertions, _, _, _} | t]), do: prune_stacktrace(t)
197+
defp prune_stacktrace([{ExUnit.Runner, _, _, _} | _]), do: []
198+
defp prune_stacktrace([h | t]), do: [h | prune_stacktrace(t)]
199+
defp prune_stacktrace([]), do: []
200+
end

lib/test_server/ssh/channel.ex

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
defmodule TestServer.SSH.Channel do
2+
@moduledoc false
3+
4+
@behaviour :ssh_server_channel
5+
6+
alias TestServer.SSH.Instance
7+
8+
defstruct [:instance, :channel_id, :connection, type: nil, handler_state: %{}]
9+
10+
@impl true
11+
def init(instance: instance) do
12+
{:ok, %__MODULE__{instance: instance}}
13+
end
14+
15+
@impl true
16+
def handle_msg({:ssh_channel_up, channel_id, connection}, state) do
17+
{:ok, %{state | channel_id: channel_id, connection: connection}}
18+
end
19+
20+
def handle_msg(_msg, state) do
21+
{:ok, state}
22+
end
23+
24+
@impl true
25+
def handle_ssh_msg({:ssh_cm, conn, {:exec, ch_id, want_reply, command}}, state) do
26+
command = to_string(command)
27+
:ssh_connection.reply_request(conn, want_reply, :success, ch_id)
28+
29+
case GenServer.call(state.instance, {:dispatch, {:exec, command, state.handler_state}}) do
30+
{:ok, {:reply, {exit_code, stdout, stderr}, new_handler_state}} ->
31+
unless IO.iodata_length(stdout) == 0,
32+
do: :ssh_connection.send(conn, ch_id, stdout)
33+
34+
unless IO.iodata_length(stderr) == 0,
35+
do: :ssh_connection.send(conn, ch_id, 1, stderr)
36+
37+
:ssh_connection.exit_status(conn, ch_id, exit_code)
38+
:ssh_connection.send_eof(conn, ch_id)
39+
:ssh_connection.close(conn, ch_id)
40+
{:stop, ch_id, %{state | handler_state: new_handler_state}}
41+
42+
{:ok, {:ok, new_handler_state}} ->
43+
:ssh_connection.exit_status(conn, ch_id, 0)
44+
:ssh_connection.send_eof(conn, ch_id)
45+
:ssh_connection.close(conn, ch_id)
46+
{:stop, ch_id, %{state | handler_state: new_handler_state}}
47+
48+
{:error, :not_found} ->
49+
message =
50+
"#{Instance.format_instance(state.instance)} received an unexpected SSH exec request: #{inspect(command)}"
51+
52+
report_error_and_close_exec(conn, ch_id, state, RuntimeError.exception(message), [])
53+
54+
{:error, {exception, stacktrace}} ->
55+
report_error_and_close_exec(conn, ch_id, state, exception, stacktrace)
56+
end
57+
end
58+
59+
def handle_ssh_msg({:ssh_cm, conn, {:shell, ch_id, want_reply}}, state) do
60+
:ssh_connection.reply_request(conn, want_reply, :success, ch_id)
61+
{:ok, %{state | type: :shell, channel_id: ch_id, connection: conn}}
62+
end
63+
64+
def handle_ssh_msg({:ssh_cm, conn, {:data, ch_id, 0, data}}, %{type: :shell} = state) do
65+
:ssh_connection.adjust_window(conn, ch_id, byte_size(data))
66+
67+
case GenServer.call(state.instance, {:dispatch, {:shell, data, state.handler_state}}) do
68+
{:ok, {:reply, output, new_handler_state}} ->
69+
:ssh_connection.send(conn, ch_id, output)
70+
{:ok, %{state | handler_state: new_handler_state}}
71+
72+
{:ok, {:ok, new_handler_state}} ->
73+
{:ok, %{state | handler_state: new_handler_state}}
74+
75+
{:error, :not_found} ->
76+
message =
77+
"#{Instance.format_instance(state.instance)} received unexpected SSH shell data: #{inspect(data)}"
78+
79+
Instance.report_error(state.instance, {RuntimeError.exception(message), []})
80+
{:ok, state}
81+
82+
{:error, {exception, stacktrace}} ->
83+
Instance.report_error(state.instance, {exception, stacktrace})
84+
{:ok, state}
85+
end
86+
end
87+
88+
def handle_ssh_msg({:ssh_cm, conn, {:pty, ch_id, want_reply, _pty_info}}, state) do
89+
:ssh_connection.reply_request(conn, want_reply, :success, ch_id)
90+
{:ok, state}
91+
end
92+
93+
def handle_ssh_msg({:ssh_cm, conn, {:env, ch_id, want_reply, _name, _value}}, state) do
94+
:ssh_connection.reply_request(conn, want_reply, :success, ch_id)
95+
{:ok, state}
96+
end
97+
98+
def handle_ssh_msg({:ssh_cm, _conn, {:eof, _ch_id}}, state) do
99+
{:ok, state}
100+
end
101+
102+
def handle_ssh_msg({:ssh_cm, _conn, {:closed, ch_id}}, state) do
103+
{:stop, ch_id, state}
104+
end
105+
106+
def handle_ssh_msg(_msg, state) do
107+
{:ok, state}
108+
end
109+
110+
@impl true
111+
def terminate(_reason, _state), do: :ok
112+
113+
defp report_error_and_close_exec(conn, ch_id, state, exception, stacktrace) do
114+
Instance.report_error(state.instance, {exception, stacktrace})
115+
:ssh_connection.exit_status(conn, ch_id, 1)
116+
:ssh_connection.send_eof(conn, ch_id)
117+
:ssh_connection.close(conn, ch_id)
118+
{:stop, ch_id, state}
119+
end
120+
end

0 commit comments

Comments
 (0)