diff --git a/README.md b/README.md index 07a80f8..20fdb14 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,98 @@ If you are migrating from `:nerves_firmware_ssh`, or updating to `:nerves_pack [SSHSubsystemFwup](https://hexdocs.pm/ssh_subsystem_fwup/readme.html) for other supported options +## Experimental: Adding a Unix shell + +Nerves devices typically only expose an Elixir or Erlang shell prompt. While this is handy, +some tasks are easier to run in a more `bash`-like shell environment. `:nerves_ssh` supports +running a separate SSH daemon that launches a system shell (busybox's `ash` by default). + +To enable this functionality, you need to add `erlexec` as a dependency to your project +(at least version `2.0`): + +```elixir +def deps do + [ + {:erlexec, "~> 2.0"} + ] +end +``` + +You also have to configure `erlexec` to allow running as `root`: + +```elixir +# config/target.exs +config :erlexec, + root: true, + user: "root", + limit_users: ["root"] +``` + +Then, change the `erlinit` configuration to set the `SHELL` environment variable: + +```elixir +# config/target.exs +config :nerves, + erlinit: [ + hostname_pattern: "nerves-%s", + # add this + env: "SHELL=/bin/sh" + ] +``` + +If you use a custom base system with another shell installed, you can change this path, +e.g. to `/bin/bash`. + +The last step is to start the separate daemon in your application. This assumes that you +configured the default daemon using the application environment: + +```elixir +# application.ex +def children(_target) do + [ + # run a second ssh daemon on another port + # but with all other options being the same + # as the default daemon on port 22 + {NervesSSH, + NervesSSH.Options.with_defaults( + Application.get_all_env(:nerves_ssh) + |> Keyword.merge( + name: :shell, + port: 2222, + shell: :disabled, + daemon_option_overrides: [{:ssh_cli, {NervesSSH.SystemShell, []}}] + ) + )} + ] +end +``` + +As an alternative to the last step, you may also run the Unix shell in a subsystem +similar to the firmware update functionality. This allows all SSH functionality to run +on a single TCP port, but has the following known issues that cannot be fixed: + +* the terminal is only sized correctly after resizing it for the first time +* direct command execution is not possible (e.g. `ssh my-nerves-device -s shell echo foo` will not work) +* correct interactivity requires your ssh client to force pty allocation (e.g. `ssh my-nerves-device -tt -s shell`) +* setting environment variables is not supported (e.g. `ssh -o SetEnv="FOO=Bar" my-nerves-device`) + +You can enable the shell subsystem by adding it to the default configuration: + +```elixir +# config/target.exs +config :nerves_ssh, + subsystems: [ + :ssh_sftpd.subsystem_spec(cwd: '/'), + {'shell', {NervesSSH.SystemShellSubsystem, []}}, + ], + # ... +``` + +Then, connect using `ssh your-nerves-device -tt -s shell` (`shell` being the name set in your +configuration). + +Please report any issues you find when trying this functionality. + ## Goals * [X] Support public key authentication diff --git a/config/config.exs b/config/config.exs index 67df73c..0f1b12e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -4,3 +4,10 @@ config :nerves_runtime, target: "host" config :nerves_runtime, Nerves.Runtime.KV.Mock, %{"nerves_fw_devpath" => "/dev/will_not_work"} + +if System.get_env("CI") == "true" or System.cmd("whoami", []) == {"root\n", 0} do + config :erlexec, + root: true, + user: "root", + limit_users: ["root"] +end diff --git a/lib/nerves_ssh/system_shell.ex b/lib/nerves_ssh/system_shell.ex new file mode 100644 index 0000000..02f4a1d --- /dev/null +++ b/lib/nerves_ssh/system_shell.ex @@ -0,0 +1,342 @@ +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 + + # erlang pty_ch_msg contains the value of TERM + # https://www.erlang.org/doc/man/ssh_connection.html#type-pty_ch_msg + def get_term({term, _, _, _, _, _} = _pty_ch_msg) 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. + + > #### Warning {: .error} + > + > This module does not work when used as an SSH subsystem, as it expects to receive + > `pty`, `exec` / `shell` ssh messages that are not available when running as a subsystem. + > If you want to run a Unix shell in a subsystem, have a look at `NervesSSH.SystemShellSubsystem` + > instead. + """ + + @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}, {: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}, %{cm: cm} = state) 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}}, + %{cm: cm, cid: cid} = state + ) 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}, + %{cm: cm, cid: cid} = state + ) do + {:ok, pid, os_pid} = exec_command(get_shell_command() |> Enum.map(&to_charlist/1), 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}}, + %{os_pid: os_pid, cid: channel_id} = state + ) 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}, + %{os_pid: os_pid, cm: cm, cid: cid} = state + ) 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 + # maybe merge this into the SystemShell module + # but not sure yet if it's worth the effort + + @moduledoc """ + A `:ssh_server_channel` that uses `:erlexec` to provide an interactive system shell + running as an SSH subsystem. + + ## Configuration + + This module accepts a keywordlist for configuring it. Currently, the only supported + options are: + + * `command` - the command to run when a client connects, defaults to the SHELL + environment variable or `sh`. + * `force_pty` - enables pseudoterminal allocation, defaults to `true`. + + For example: + + ```elixir + # config/target.exs + config :nerves_ssh, + subsystems: [ + :ssh_sftpd.subsystem_spec(cwd: '/'), + {'shell', {NervesSSH.SystemShellSubsystem, [command: '/bin/cat', force_pty: 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}}, + %{os_pid: os_pid, cm: cm, cid: cid} = state + ) 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, _, _}}, + %{os_pid: os_pid, cm: cm, cid: cid} = state + ) 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 9cdb8c7..ace8d21 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, "~> 2.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. @@ -53,7 +54,7 @@ defmodule NervesSSH.MixProject do defp dialyzer() do [ flags: [:missing_return, :extra_return, :unmatched_returns, :error_handling, :underspecs], - plt_add_apps: [:lfe] + plt_add_apps: [:lfe, :erlexec] ] end diff --git a/mix.lock b/mix.lock index 08db575..62c8c3e 100644 --- a/mix.lock +++ b/mix.lock @@ -2,16 +2,17 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.40", "f3534689f6b58f48aa3a9ac850d4f05832654fe257bf0549c08cc290035f70d5", [:mix], [], "hexpm", "cdb34f35892a45325bad21735fadb88033bcb7c4c296a999bde769783f53e46a"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "erlexec": {:hex, :erlexec, "2.0.7", "76d0bc7487929741b5bb9f74da2af5daf1492134733cf9a05c7aaa278b6934c5", [:rebar3], [], "hexpm", "af2dd940bb8e32f5aa40a65cb455dcaa18f5334fd3507e9bfd14a021e9630897"}, "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, - "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, - "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "lfe": {:hex, :lfe, "2.1.4", "b35eea489f3c9488263713c128d0a9880dffcd7eb3ccec183c060bddb9363f6b", [:rebar3], [], "hexpm", "b334e41cd82f772bd7ac4350f1c035008dca0978b527a751ad8700f0803b1528"}, "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, "nerves_logging": {:hex, :nerves_logging, "0.2.2", "d0e878ac92e6907757fa9898b661250fa1cf50474763ca59ecfadca1c2235337", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "74c181c6f011ea0c2d52956ad82065a59d7c7b62ddfba5967b010ef125f460a5"}, "nerves_runtime": {:hex, :nerves_runtime, "0.13.7", "0a7b15d5f55af1b695f7a4a1bd597c2f6101f8cbe7fe7a30743e3e441b7e233f", [:mix], [{:nerves_logging, "~> 0.2.0", [hex: :nerves_logging, repo: "hexpm", optional: false]}, {:nerves_uevent, "~> 0.1.0", [hex: :nerves_uevent, repo: "hexpm", optional: false]}, {:uboot_env, "~> 0.3.0 or ~> 1.0", [hex: :uboot_env, repo: "hexpm", optional: false]}], "hexpm", "6bafb89344709e5405cc7f9b140f91ca76ea3749f0eb3af80d8f8b8c53388b2d"}, "nerves_uevent": {:hex, :nerves_uevent, "0.1.0", "651111a46be9a238560cbf7946989fc500e5f33d7035fd9ea7194d07a281bc19", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:property_table, "~> 0.2.0", [hex: :property_table, repo: "hexpm", optional: false]}], "hexpm", "cb0b1993c3ed3cefadbcdb534e910af0661f95c3445796ce8a7c8be3519a4e5f"}, diff --git a/test/nerves_ssh/system_shell_test.exs b/test/nerves_ssh/system_shell_test.exs new file mode 100644 index 0000000..ebbf25e --- /dev/null +++ b/test/nerves_ssh/system_shell_test.exs @@ -0,0 +1,181 @@ +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) + after + 5000 -> raise "timeout" + 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\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" + end + end +end diff --git a/test/nerves_ssh_test.exs b/test/nerves_ssh_test.exs index 3f0bf03..a7c2759 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)