From 302e7798562373aa5e9da7a3fb306a362b8ffaee Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 13 Jul 2022 19:48:23 +0200 Subject: [PATCH 1/6] NervesSSH.SystemShell --- lib/nerves_ssh/system_shell.ex | 311 ++++++++++++++++++++++++++ mix.exs | 1 + mix.lock | 1 + test/nerves_ssh/system_shell_test.exs | 179 +++++++++++++++ test/nerves_ssh_test.exs | 2 +- test/test_helper.exs | 2 + 6 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 lib/nerves_ssh/system_shell.ex create mode 100644 test/nerves_ssh/system_shell_test.exs 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..7d1e702 --- /dev/null +++ b/test/nerves_ssh/system_shell_test.exs @@ -0,0 +1,179 @@ +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\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 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) From 218086131b27a14ba8f3b94119bbcc81a0eeb494 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 13 Jul 2022 19:57:09 +0200 Subject: [PATCH 2/6] allow erlexec to run as root --- config/config.exs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/config.exs b/config/config.exs index 67df73c..3f07521 100644 --- a/config/config.exs +++ b/config/config.exs @@ -4,3 +4,8 @@ config :nerves_runtime, target: "host" config :nerves_runtime, Nerves.Runtime.KV.Mock, %{"nerves_fw_devpath" => "/dev/will_not_work"} + +config :erlexec, + root: true, + user: "root", + limit_users: ["root"] From 4e15b3eb34ba9d80c6ce6c7d693e57cc3ec946c1 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 13 Jul 2022 20:05:55 +0200 Subject: [PATCH 3/6] make credo happy --- lib/nerves_ssh/system_shell.ex | 16 ++++++++-------- test/nerves_ssh/system_shell_test.exs | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/nerves_ssh/system_shell.ex b/lib/nerves_ssh/system_shell.ex index b937fec..d53fdc0 100644 --- a/lib/nerves_ssh/system_shell.ex +++ b/lib/nerves_ssh/system_shell.ex @@ -113,7 +113,7 @@ defmodule NervesSSH.SystemShell do @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 + 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}} @@ -122,7 +122,7 @@ defmodule NervesSSH.SystemShell do # 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} + %{cm: cm, cid: cid} = state ) do :ssh_connection.reply_request(cm, want_reply, :success, cid) @@ -143,7 +143,7 @@ defmodule NervesSSH.SystemShell do # client requested a shell def handle_ssh_msg( {:ssh_cm, cm, {:shell, cid, want_reply} = _msg}, - state = %{cm: cm, cid: cid} + %{cm: cm, cid: cid} = state ) do {:ok, pid, os_pid} = exec_command(get_shell_command(), state) :ssh_connection.reply_request(cm, want_reply, :success, cid) @@ -152,7 +152,7 @@ defmodule NervesSSH.SystemShell do def handle_ssh_msg( {:ssh_cm, _cm, {:data, channel_id, 0, data}}, - state = %{os_pid: os_pid, cid: channel_id} + %{os_pid: os_pid, cid: channel_id} = state ) do :exec.send(os_pid, data) @@ -177,7 +177,7 @@ defmodule NervesSSH.SystemShell do def handle_ssh_msg( {:ssh_cm, cm, {:window_change, cid, width, height, _, _} = _msg}, - state = %{os_pid: os_pid, cm: cm, cid: cid} + %{os_pid: os_pid, cm: cm, cid: cid} = state ) do :exec.winsz(os_pid, height, width) @@ -196,7 +196,7 @@ defmodule NervesSSH.SystemShell do end defmodule NervesSSH.SystemShellSubsystem do - # TODO: maybe merge this into the SystemShell module + # maybe merge this into the SystemShell module # but not sure yet if it's worth the effort @moduledoc false @@ -267,7 +267,7 @@ defmodule NervesSSH.SystemShellSubsystem do @impl true def handle_ssh_msg( {:ssh_cm, cm, {:data, cid, 0, data}}, - state = %{os_pid: os_pid, cm: cm, cid: cid} + %{os_pid: os_pid, cm: cm, cid: cid} = state ) do :exec.send(os_pid, data) @@ -292,7 +292,7 @@ defmodule NervesSSH.SystemShellSubsystem do def handle_ssh_msg( {:ssh_cm, cm, {:window_change, cid, width, height, _, _}}, - state = %{os_pid: os_pid, cm: cm, cid: cid} + %{os_pid: os_pid, cm: cm, cid: cid} = state ) do :exec.winsz(os_pid, height, width) diff --git a/test/nerves_ssh/system_shell_test.exs b/test/nerves_ssh/system_shell_test.exs index 7d1e702..5a9c982 100644 --- a/test/nerves_ssh/system_shell_test.exs +++ b/test/nerves_ssh/system_shell_test.exs @@ -52,7 +52,7 @@ defmodule NervesSSH.SystemShellTest do Process.get(:ssh_port) end - defp receive_until_eof do + defp receive_until_eof() do receive_until_eof([]) end From 8a286281984e23a0224ee02c25278da7147b0561 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 13 Jul 2022 23:19:49 +0200 Subject: [PATCH 4/6] make dialyzer happy --- config/config.exs | 10 ++++--- lib/nerves_ssh/system_shell.ex | 38 +++++++++++++-------------- mix.exs | 2 +- test/nerves_ssh/system_shell_test.exs | 2 ++ 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/config/config.exs b/config/config.exs index 3f07521..0f1b12e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -5,7 +5,9 @@ config :nerves_runtime, config :nerves_runtime, Nerves.Runtime.KV.Mock, %{"nerves_fw_devpath" => "/dev/will_not_work"} -config :erlexec, - root: true, - user: "root", - limit_users: ["root"] +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 index d53fdc0..bce828d 100644 --- a/lib/nerves_ssh/system_shell.ex +++ b/lib/nerves_ssh/system_shell.ex @@ -86,23 +86,23 @@ defmodule NervesSSH.SystemShell do ) do case reason do :normal -> - :ssh_connection.exit_status(cm, cid, 0) + _ = :ssh_connection.exit_status(cm, cid, 0) {:exit_status, status} -> - :ssh_connection.exit_status(cm, cid, status) + _ = :ssh_connection.exit_status(cm, cid, status) end - :ssh_connection.send_eof(cm, cid) + _ = :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) + _ = :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) + _ = :ssh_connection.send(cm, cid, 1, data) {:ok, state} end @@ -114,7 +114,7 @@ defmodule NervesSSH.SystemShell do @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) + _ = :ssh_connection.reply_request(cm, want_reply, :success, cid) {:ok, %{state | pty_opts: pty_opts}} end @@ -124,7 +124,7 @@ defmodule NervesSSH.SystemShell do {:ssh_cm, cm, {:env, cid, want_reply, key, value}}, %{cm: cm, cid: cid} = state ) do - :ssh_connection.reply_request(cm, want_reply, :success, cid) + _ = :ssh_connection.reply_request(cm, want_reply, :success, cid) {:ok, update_in(state, [:env], fn vars -> [{key, value} | vars] end)} end @@ -136,7 +136,7 @@ defmodule NervesSSH.SystemShell do ) 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) + _ = :ssh_connection.reply_request(cm, want_reply, :success, cid) {:ok, %{state | os_pid: os_pid, port_pid: pid}} end @@ -145,8 +145,8 @@ defmodule NervesSSH.SystemShell do {:ssh_cm, cm, {:shell, cid, want_reply} = _msg}, %{cm: cm, cid: cid} = state ) do - {:ok, pid, os_pid} = exec_command(get_shell_command(), state) - :ssh_connection.reply_request(cm, want_reply, :success, cid) + {: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 @@ -154,7 +154,7 @@ defmodule NervesSSH.SystemShell do {:ssh_cm, _cm, {:data, channel_id, 0, data}}, %{os_pid: os_pid, cid: channel_id} = state ) do - :exec.send(os_pid, data) + _ = :exec.send(os_pid, data) {:ok, state} end @@ -179,7 +179,7 @@ defmodule NervesSSH.SystemShell do {: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) + _ = :exec.winsz(os_pid, height, width) {:ok, state} end @@ -244,23 +244,23 @@ defmodule NervesSSH.SystemShellSubsystem do ) do case reason do :normal -> - :ssh_connection.exit_status(cm, cid, 0) + _ = :ssh_connection.exit_status(cm, cid, 0) {:exit_status, status} -> - :ssh_connection.exit_status(cm, cid, status) + _ = :ssh_connection.exit_status(cm, cid, status) end - :ssh_connection.send_eof(cm, cid) + _ = :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) + _ = :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) + _ = :ssh_connection.send(cm, cid, 1, data) {:ok, state} end @@ -269,7 +269,7 @@ defmodule NervesSSH.SystemShellSubsystem do {:ssh_cm, cm, {:data, cid, 0, data}}, %{os_pid: os_pid, cm: cm, cid: cid} = state ) do - :exec.send(os_pid, data) + _ = :exec.send(os_pid, data) {:ok, state} end @@ -294,7 +294,7 @@ defmodule NervesSSH.SystemShellSubsystem do {:ssh_cm, cm, {:window_change, cid, width, height, _, _}}, %{os_pid: os_pid, cm: cm, cid: cid} = state ) do - :exec.winsz(os_pid, height, width) + _ = :exec.winsz(os_pid, height, width) {:ok, state} end diff --git a/mix.exs b/mix.exs index 870c6f9..336b189 100644 --- a/mix.exs +++ b/mix.exs @@ -54,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/test/nerves_ssh/system_shell_test.exs b/test/nerves_ssh/system_shell_test.exs index 5a9c982..ebbf25e 100644 --- a/test/nerves_ssh/system_shell_test.exs +++ b/test/nerves_ssh/system_shell_test.exs @@ -66,6 +66,8 @@ defmodule NervesSSH.SystemShellTest do _ -> receive_until_eof(acc) + after + 5000 -> raise "timeout" end end From b8181a9725635a61286eddd9b0834d1010670473 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Sun, 17 Jul 2022 14:50:40 +0200 Subject: [PATCH 5/6] erlexec 2.0, add section to README --- README.md | 92 ++++++++++++++++++++++++++++++++++ lib/nerves_ssh/system_shell.ex | 43 +++++++++++++--- mix.exs | 2 +- mix.lock | 2 +- 4 files changed, 131 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4755ea2..e9a71e2 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,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/lib/nerves_ssh/system_shell.ex b/lib/nerves_ssh/system_shell.ex index bce828d..02f4a1d 100644 --- a/lib/nerves_ssh/system_shell.ex +++ b/lib/nerves_ssh/system_shell.ex @@ -22,13 +22,22 @@ defmodule NervesSSH.SystemShellUtils do end end - def get_term({term, _, _, _, _, _}) when is_list(term), + # 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 @@ -50,12 +59,10 @@ defmodule NervesSSH.SystemShell do nil -> base_opts ++ [:stderr] - {_term, _cols, _rows, _, _, opts} -> + {_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}}] + base_opts ++ [{:stderr, :stdout}, {:pty, opts}, {:winsz, {rows, cols}}] end :exec.run(cmd, opts) @@ -199,7 +206,31 @@ defmodule NervesSSH.SystemShellSubsystem do # maybe merge this into the SystemShell module # but not sure yet if it's worth the effort - @moduledoc false + @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 diff --git a/mix.exs b/mix.exs index 336b189..4268a10 100644 --- a/mix.exs +++ b/mix.exs @@ -37,7 +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}, + {: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. diff --git a/mix.lock b/mix.lock index 411c0c1..d808627 100644 --- a/mix.lock +++ b/mix.lock @@ -5,7 +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"}, + "erlexec": {:hex, :erlexec, "2.0.0", "73eef450ef20760a88c8d65da6ad8ee0446390160bd84064a2682ec1e9afaaed", [:rebar3], [], "hexpm", "7273d744c1409d6b692005160593bd7179ef004ebeb96ee9976e126a88161824"}, "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"}, From d233206f5c90e373f9878475e2b9317ef29616ff Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Mon, 9 Sep 2024 21:09:42 +0200 Subject: [PATCH 6/6] update deps --- mix.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mix.lock b/mix.lock index 8663fc3..62c8c3e 100644 --- a/mix.lock +++ b/mix.lock @@ -2,17 +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.0", "73eef450ef20760a88c8d65da6ad8ee0446390160bd84064a2682ec1e9afaaed", [:rebar3], [], "hexpm", "7273d744c1409d6b692005160593bd7179ef004ebeb96ee9976e126a88161824"}, + "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"},