diff --git a/lib/nerves_ssh/system_shell.ex b/lib/nerves_ssh/system_shell.ex new file mode 100644 index 0000000..b937fec --- /dev/null +++ b/lib/nerves_ssh/system_shell.ex @@ -0,0 +1,311 @@ +defmodule NervesSSH.SystemShellUtils do + @moduledoc false + + def get_shell_command() do + cond do + shell = System.get_env("SHELL") -> + [shell, "-i"] + + shell = System.find_executable("sh") -> + [shell, "-i"] + + true -> + raise "SHELL environment variable not set and sh not available" + end + end + + def get_term(nil) do + if term = System.get_env("TERM") do + [{"TERM", term}] + else + [{"TERM", "xterm"}] + end + end + + def get_term({term, _, _, _, _, _}) when is_list(term), + do: [{"TERM", List.to_string(term)}] +end + +defmodule NervesSSH.SystemShell do + @moduledoc """ + A `:ssh_server_channel` that uses `:erlexec` to provide an interactive system shell. + """ + + @behaviour :ssh_server_channel + + require Logger + + import NervesSSH.SystemShellUtils + + defp exec_command(cmd, %{pty_opts: pty_opts, env: env}) do + base_opts = [ + :stdin, + :stdout, + :monitor, + env: [:clear] ++ env ++ get_term(pty_opts) + ] + + opts = + case pty_opts do + nil -> + base_opts ++ [:stderr] + + {_term, _cols, _rows, _, _, opts} -> + # https://www.erlang.org/doc/man/ssh_connection.html#type-pty_ch_msg + # erlexec understands the format of the erlang ssh pty_ch_msg + base_opts ++ [{:stderr, :stdout}, {:pty, opts}] + # not yet released + # ++ [{:winsz, {rows, cols}}] + end + + :exec.run(cmd, opts) + end + + @impl true + def init(_opts) do + {:ok, + %{ + port_pid: nil, + os_pid: nil, + pty_opts: nil, + env: [], + cid: nil, + cm: nil + }} + end + + @impl true + def handle_msg({:ssh_channel_up, channel_id, connection_manager}, state) do + {:ok, %{state | cid: channel_id, cm: connection_manager}} + end + + # port closed + def handle_msg( + {:DOWN, os_pid, :process, port_pid, reason}, + %{os_pid: os_pid, port_pid: port_pid, cm: cm, cid: cid} = state + ) do + case reason do + :normal -> + :ssh_connection.exit_status(cm, cid, 0) + + {:exit_status, status} -> + :ssh_connection.exit_status(cm, cid, status) + end + + :ssh_connection.send_eof(cm, cid) + {:stop, cid, state} + end + + def handle_msg({:stdout, os_pid, data} = _msg, %{cm: cm, cid: cid, os_pid: os_pid} = state) do + :ssh_connection.send(cm, cid, data) + {:ok, state} + end + + def handle_msg({:stderr, os_pid, data} = _msg, %{cm: cm, cid: cid, os_pid: os_pid} = state) do + :ssh_connection.send(cm, cid, 1, data) + {:ok, state} + end + + def handle_msg(msg, state) do + Logger.error("[NervesSSH.SystemShell] unhandled message: #{inspect(msg)}") + {:ok, state} + end + + @impl true + # client sent a pty request + def handle_ssh_msg({:ssh_cm, cm, {:pty, cid, want_reply, pty_opts} = _msg}, state = %{cm: cm}) do + :ssh_connection.reply_request(cm, want_reply, :success, cid) + + {:ok, %{state | pty_opts: pty_opts}} + end + + # client wants to set an environment variable + def handle_ssh_msg( + {:ssh_cm, cm, {:env, cid, want_reply, key, value}}, + state = %{cm: cm, cid: cid} + ) do + :ssh_connection.reply_request(cm, want_reply, :success, cid) + + {:ok, update_in(state, [:env], fn vars -> [{key, value} | vars] end)} + end + + # client wants to execute a command + def handle_ssh_msg( + {:ssh_cm, cm, {:exec, cid, want_reply, command} = _msg}, + state = %{cm: cm, cid: cid} + ) + when is_list(command) do + {:ok, pid, os_pid} = exec_command(List.to_string(command), state) + :ssh_connection.reply_request(cm, want_reply, :success, cid) + {:ok, %{state | os_pid: os_pid, port_pid: pid}} + end + + # client requested a shell + def handle_ssh_msg( + {:ssh_cm, cm, {:shell, cid, want_reply} = _msg}, + state = %{cm: cm, cid: cid} + ) do + {:ok, pid, os_pid} = exec_command(get_shell_command(), state) + :ssh_connection.reply_request(cm, want_reply, :success, cid) + {:ok, %{state | os_pid: os_pid, port_pid: pid}} + end + + def handle_ssh_msg( + {:ssh_cm, _cm, {:data, channel_id, 0, data}}, + state = %{os_pid: os_pid, cid: channel_id} + ) do + :exec.send(os_pid, data) + + {:ok, state} + end + + def handle_ssh_msg({:ssh_cm, _, {:eof, _}}, state) do + {:ok, state} + end + + def handle_ssh_msg({:ssh_cm, _, {:signal, _, _} = _msg}, state) do + {:ok, state} + end + + def handle_ssh_msg({:ssh_cm, _, {:exit_signal, channel_id, _, _error, _}}, state) do + {:stop, channel_id, state} + end + + def handle_ssh_msg({:ssh_cm, _, {:exit_status, channel_id, _status}}, state) do + {:stop, channel_id, state} + end + + def handle_ssh_msg( + {:ssh_cm, cm, {:window_change, cid, width, height, _, _} = _msg}, + state = %{os_pid: os_pid, cm: cm, cid: cid} + ) do + :exec.winsz(os_pid, height, width) + + {:ok, state} + end + + def handle_ssh_msg(msg, state) do + Logger.error("[NervesSSH.SystemShell] unhandled ssh message: #{inspect(msg)}") + {:ok, state} + end + + @impl true + def terminate(_reason, _state) do + :ok + end +end + +defmodule NervesSSH.SystemShellSubsystem do + # TODO: maybe merge this into the SystemShell module + # but not sure yet if it's worth the effort + + @moduledoc false + + @behaviour :ssh_server_channel + + require Logger + + import NervesSSH.SystemShellUtils + + @impl true + def init(opts) do + # SSH subsystems do not send :exec, :shell or :pty messages + command = Keyword.get_lazy(opts, :command, fn -> get_shell_command() end) + force_pty = Keyword.get(opts, :force_pty, true) + + base_opts = [ + :stdin, + :stdout, + :monitor, + env: get_term(nil) + ] + + opts = + if force_pty do + base_opts ++ [{:stderr, :stdout}, :pty, :pty_echo] + else + base_opts ++ [:stderr] + end + + {:ok, port_pid, os_pid} = :exec.run(command, opts) + + {:ok, %{os_pid: os_pid, port_pid: port_pid, cid: nil, cm: nil}} + end + + @impl true + def handle_msg({:ssh_channel_up, channel_id, connection_manager}, state) do + {:ok, %{state | cid: channel_id, cm: connection_manager}} + end + + # port closed + def handle_msg( + {:DOWN, os_pid, :process, port_pid, reason}, + %{os_pid: os_pid, port_pid: port_pid, cm: cm, cid: cid} = state + ) do + case reason do + :normal -> + :ssh_connection.exit_status(cm, cid, 0) + + {:exit_status, status} -> + :ssh_connection.exit_status(cm, cid, status) + end + + :ssh_connection.send_eof(cm, cid) + {:stop, cid, state} + end + + def handle_msg({:stdout, os_pid, data}, %{os_pid: os_pid, cm: cm, cid: cid} = state) do + :ssh_connection.send(cm, cid, data) + {:ok, state} + end + + def handle_msg({:stderr, os_pid, data}, %{os_pid: os_pid, cm: cm, cid: cid} = state) do + :ssh_connection.send(cm, cid, 1, data) + {:ok, state} + end + + @impl true + def handle_ssh_msg( + {:ssh_cm, cm, {:data, cid, 0, data}}, + state = %{os_pid: os_pid, cm: cm, cid: cid} + ) do + :exec.send(os_pid, data) + + {:ok, state} + end + + def handle_ssh_msg({:ssh_cm, _, {:eof, _}}, state) do + {:ok, state} + end + + def handle_ssh_msg({:ssh_cm, _, {:signal, _, _}}, state) do + {:ok, state} + end + + def handle_ssh_msg({:ssh_cm, _, {:exit_signal, channel_id, _, _error, _}}, state) do + {:stop, channel_id, state} + end + + def handle_ssh_msg({:ssh_cm, _, {:exit_status, channel_id, _status}}, state) do + {:stop, channel_id, state} + end + + def handle_ssh_msg( + {:ssh_cm, cm, {:window_change, cid, width, height, _, _}}, + state = %{os_pid: os_pid, cm: cm, cid: cid} + ) do + :exec.winsz(os_pid, height, width) + + {:ok, state} + end + + def handle_ssh_msg(msg, state) do + Logger.error("[NervesSSH.SystemShellSubsystem] unhandled ssh message: #{inspect(msg)}") + {:ok, state} + end + + @impl true + def terminate(_reason, _state) do + :ok + end +end diff --git a/mix.exs b/mix.exs index 3b1a5d0..870c6f9 100644 --- a/mix.exs +++ b/mix.exs @@ -37,6 +37,7 @@ defmodule NervesSSH.MixProject do {:ex_doc, "~> 0.22", only: :docs, runtime: false}, {:ssh_subsystem_fwup, "~> 0.5"}, {:nerves_runtime, "~> 0.11"}, + {:erlexec, "~> 1.21.0", optional: true}, # lfe currently requires `compile: "make"` to build and this is # disallowed when pushing the package to hex.pm. Work around this by # listing it as dev/test only. diff --git a/mix.lock b/mix.lock index b03bedb..411c0c1 100644 --- a/mix.lock +++ b/mix.lock @@ -5,6 +5,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.25", "2024618731c55ebfcc5439d756852ec4e85978a39d0d58593763924d9a15916f", [:mix], [], "hexpm", "56749c5e1c59447f7b7a23ddb235e4b3defe276afc220a6227237f3efe83f51e"}, "elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "erlexec": {:hex, :erlexec, "1.21.0", "748af41a9b77fde69bb82d5ec37e37384d4f849f971687de49d57e8cb59422ea", [:rebar3], [], "hexpm", "826616a344d1bc0a35c9d2c7175478166f607a4e3b14c878229fd405694d65c0"}, "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, diff --git a/test/nerves_ssh/system_shell_test.exs b/test/nerves_ssh/system_shell_test.exs new file mode 100644 index 0000000..5ef8e38 --- /dev/null +++ b/test/nerves_ssh/system_shell_test.exs @@ -0,0 +1,180 @@ +defmodule NervesSSH.SystemShellTest do + use ExUnit.Case, async: true + + @base_ssh_port 4022 + @rsa_public_key String.trim(File.read!("test/fixtures/good_user_dir/id_rsa.pub")) + + defp default_config() do + NervesSSH.Options.with_defaults( + name: :shell_server, + authorized_keys: [@rsa_public_key], + system_dir: Path.absname("test/fixtures/system_dir"), + user_dir: Path.absname("test/fixtures/system_dir"), + port: ssh_port(), + daemon_option_overrides: [ssh_cli: {NervesSSH.SystemShell, []}] + ) + end + + defp subsystem_config() do + NervesSSH.Options.with_defaults( + name: :shell_subsystem_server, + authorized_keys: [@rsa_public_key], + system_dir: Path.absname("test/fixtures/system_dir"), + user_dir: Path.absname("test/fixtures/system_dir"), + port: ssh_port(), + subsystems: [ + {'shell', {NervesSSH.SystemShellSubsystem, []}} + ] + ) + end + + defp ssh_run(cmd) do + ssh_options = [ + ip: '127.0.0.1', + port: ssh_port(), + user_interaction: false, + silently_accept_hosts: true, + save_accepted_host: false, + user: 'test_user', + password: 'password', + user_dir: Path.absname("test/fixtures/good_user_dir") + ] + + # Short sleep to make sure server is up an running + Process.sleep(200) + + with {:ok, conn} <- SSHEx.connect(ssh_options) do + SSHEx.run(conn, cmd) + end + end + + defp ssh_port() do + Process.get(:ssh_port) + end + + defp receive_until_eof do + receive_until_eof([]) + end + + defp receive_until_eof(acc) do + receive do + {:ssh_cm, _, {:data, _, _, data}} -> + receive_until_eof([data | acc]) + + {:ssh_cm, _, {:eof, _}} -> + IO.iodata_to_binary(Enum.reverse(acc)) + + _ -> + receive_until_eof(acc) + end + end + + setup_all do + Application.ensure_all_started(:erlexec) + + :ok + end + + setup context do + # Use unique ssh port numbers for each test to support async: true + Process.put(:ssh_port, @base_ssh_port + :erlang.phash2({context.module, context.test}, 10000)) + :ok + end + + @tag :has_good_sshd_exec + describe "ssh_cli" do + test "exec mode" do + start_supervised!({NervesSSH, default_config()}) + assert {:ok, "ok\n", 0} == ssh_run("echo ok") + end + + test "shell mode with pty" do + start_supervised!({NervesSSH, default_config()}) + # Short sleep to make sure server is up an running + Process.sleep(200) + + assert {:ok, conn} = + :ssh.connect( + '127.0.0.1', + ssh_port(), + [ + silently_accept_hosts: true, + save_accepted_host: false, + user: 'test_user', + password: 'password', + user_dir: Path.absname("test/fixtures/good_user_dir") |> to_charlist() + ], + 5000 + ) + + assert {:ok, channel} = :ssh_connection.session_channel(conn, 5000) + + assert :success = + :ssh_connection.ptty_alloc(conn, channel, + term: "dumb", + width: 99, + height: 33, + pty_opts: [echo: 1] + ) + + assert :success = :ssh_connection.setenv(conn, channel, 'PS1', 'prompt> ', 5000) + + assert :ok = :ssh_connection.shell(conn, channel) + assert :ok = :ssh_connection.send(conn, channel, "echo cool\n") + assert :ok = :ssh_connection.send(conn, channel, "echo $TERM\n") + assert :ok = :ssh_connection.send(conn, channel, "exit 0\n") + + assert receive_until_eof() =~ + "prompt> echo cool\r\ncool\r\nprompt> echo $TERM\r\ndumb\r\nprompt> exit 0\r\nexit\r\n" + end + end + + @tag :has_good_sshd_exec + describe "subsystem" do + test "normal elixir exec" do + start_supervised!({NervesSSH, subsystem_config()}) + assert {:ok, "2", 0} == ssh_run("1 + 1") + end + + test "subsystem login" do + start_supervised!({NervesSSH, subsystem_config()}) + # Short sleep to make sure server is up an running + Process.sleep(200) + + assert {:ok, conn} = + :ssh.connect( + '127.0.0.1', + ssh_port(), + [ + silently_accept_hosts: true, + save_accepted_host: false, + user: 'test_user', + password: 'password', + user_dir: Path.absname("test/fixtures/good_user_dir") |> to_charlist() + ], + 5000 + ) + + assert {:ok, channel} = :ssh_connection.session_channel(conn, 5000) + + assert :success = + :ssh_connection.ptty_alloc(conn, channel, + term: "dumb", + width: 80, + height: 25, + pty_opts: [echo: 1] + ) + + assert :success = :ssh_connection.subsystem(conn, channel, 'shell', 5000) + + assert :ok = :ssh_connection.send(conn, channel, "echo cool\n") + assert :ok = :ssh_connection.send(conn, channel, "echo $TERM\n") + assert :ok = :ssh_connection.send(conn, channel, "exit 0\n") + + result = receive_until_eof() + assert result =~ "echo cool\r\n" + assert result =~ "exit 0\r\n" + assert result =~ "0\r\n" + end + end +end diff --git a/test/nerves_ssh_test.exs b/test/nerves_ssh_test.exs index eb9bd05..07447f4 100644 --- a/test/nerves_ssh_test.exs +++ b/test/nerves_ssh_test.exs @@ -56,7 +56,7 @@ defmodule NervesSSHTest do setup context do # Use unique ssh port numbers for each test to support async: true - Process.put(:ssh_port, @base_ssh_port + context.line) + Process.put(:ssh_port, @base_ssh_port + :erlang.phash2({context.module, context.test}, 10000)) :ok end diff --git a/test/test_helper.exs b/test/test_helper.exs index dfb9399..3d05a6e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -4,4 +4,6 @@ otp_version = System.otp_release() |> Integer.parse() |> elem(0) # kind of works, but has quirks, so don't test it. exclude = if otp_version >= 23, do: [], else: [has_good_sshd_exec: true] +System.put_env("SHELL", System.find_executable("sh")) + ExUnit.start(exclude: exclude, capture_log: true)