Repository: rubencaro/sshex Branch: master Commit: e22f405c4485 Files: 12 Total size: 35.1 KB Directory structure: gitextract_8d5oz6yg/ ├── .gitignore ├── .tool-versions ├── .travis.yml ├── LICENSE ├── README.md ├── lib/ │ ├── sshex/ │ │ ├── configurable_client_keys.ex │ │ └── helpers.ex │ └── sshex.ex ├── mix.exs └── test/ ├── configurable_client_keys_test.exs ├── sshex_test.exs └── test_helper.exs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ /_build /deps erl_crash.dump *.ez *.swp *.kate-swp .kateproject.d .zedstate .directory .elixir_ls /config/prod.exs /rel /doc ================================================ FILE: .tool-versions ================================================ elixir 1.8.1-otp-21 erlang 21.2.5 ================================================ FILE: .travis.yml ================================================ language: elixir elixir: - 1.8 otp_release: 21.0 sudo: false ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2014 Rubén Caro Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ ---- The actively maintained version of SSHEx is now in https://github.com/witchtails/sshex This repo has been archived. ---- # SSHEx [![Build Status](https://travis-ci.org/rubencaro/sshex.svg?branch=master)](https://travis-ci.org/rubencaro/sshex) [![Hex Version](http://img.shields.io/hexpm/v/sshex.svg?style=flat)](https://hex.pm/packages/sshex) [![Hex Version](http://img.shields.io/hexpm/dt/sshex.svg?style=flat)](https://hex.pm/packages/sshex) Simple SSH helpers for Elixir. Library to unify helpers already used on several applications. It uses low level Erlang [ssh library](http://www.erlang.org/doc/man/ssh.html). The only purpose of these helpers is to avoid repetitive patterns seen when working with SSH from Elixir. It doesn't mean to hide anything from the venerable code underneath. If there's an ugly crash from `:ssh` it will come back as `{:error, reason}`. ## Installation Just add `{:sshex, "2.2.1"}` to your deps on `mix.exs` and run `mix deps.get` ## Use Then assuming `:ssh` application is already started with `:ssh.start` (hence it is listed on deps), you should acquire an SSH connection using `SSHEx.connect/1` like this: 1.You can use `ssh-copy-id myuser@123.123.123.123.123` to copy ssh key to remote host and then connect using this line: ```elixir {:ok, conn} = SSHEx.connect ip: '123.123.123.123', user: 'myuser' ``` 2.You can supply the password: ```elixir {:ok, conn} = SSHEx.connect ip: '123.123.123.123', user: 'myuser', password: 'your-password' ``` Then you can use the acquired `conn` with the `cmd!/4` helper like this: ```elixir SSHEx.cmd! conn, 'mkdir -p /path/to/newdir' res = SSHEx.cmd! conn, 'ls /some/path' ``` This is meant to run commands which you don't care about the return code. `cmd!/3` will return the output of the command only, and __will raise any errors__. If you want to check the status code, and control errors too, you can use `run/3` like this: ```elixir {:ok, _, 0} = SSHEx.run conn, 'rm -fr /something/to/delete' {:ok, res, 0} = SSHEx.run conn, 'ls /some/path' {:error, reason} = SSHEx.run failing_conn, 'ls /some/path' ``` You can pass the option `:separate_streams` to get separated stdout and stderr. Like this: ```elixir {:ok, stdout, stderr, 2} = SSHEx.run conn, 'ls /nonexisting/path', separate_streams: true ``` You will be reusing the same SSH connection all over. ## Streaming You can use `SSHEx` to run some command and create a [`Stream`](http://elixir-lang.org/docs/stable/elixir/Stream.html), so you can lazily process an arbitrarily long output as it arrives. Internally `Stream.resource/3` is used to create the `Stream`, and every response from `:ssh` is emitted so it can be easily matched with a simple `case`. You just have to use `stream/3` like this: ```elixir str = SSHEx.stream conn, 'somecommand' Enum.each(str, fn(x)-> case x do {:stdout, row} -> process_stdout(row) {:stderr, row} -> process_stderr(row) {:status, status} -> process_exit_status(status) {:error, reason} -> process_error(reason) end end) ``` ## Alternative keys To use alternative keys you should save them somewhere on disk and then set the `:user_dir` option for `SSHEx.connect/4`. See [ssh library docs](http://www.erlang.org/doc/man/ssh.html) for more options. ## TODOs * Add tunnelling helpers [*](http://erlang.org/pipermail/erlang-questions/2014-June/079481.html) ## Changelog ### 2.2.1 * Add new possible exit_signal message format ### 2.2.0 * Add ability to specify ssh key and known hosts * Make it Elixir string friendly ### 2.1.2 * Remove Elixir 1.4 warnings ### 2.1.1 * Close channel after stream ### 2.1.0 * Add `connect/1` to improve testability by easier mocking ### 2.0.1 * Avoid some Elixir 1.2.0 warnings * Adjust the SSH flow control window to handle long outputs (fixes #4). ### 2.0.0 Backwards incompatible changes: * Remove every `raise`, get clean controlled `{:error, reason}` responses * Put every optional parameter under a unique Keyword list ### 1.3.1 * Fix Elixir version requested. Use >= 1.0 now. ### 1.3 * Support streaming via `stream/3` * Stop using global mocks (i.e. `:meck`) ### 1.2 * Uniform `raise` behaviour on `:ssh` errors. * Document and test `:ssh` error handling ### 1.1 * Add support for separate stdout/stderr responses. ### 1.0 * Initial release ================================================ FILE: lib/sshex/configurable_client_keys.ex ================================================ defmodule SSHEx.ConfigurableClientKeys do @moduledoc ~S""" Provides public key behavior for SSH clients. valid options: - `key`: `IO.device` providing the ssh key (required) - `known_hosts`: `IO.device` providing the known hosts list (required) - `accept_hosts`: `boolean` silently accept and add new hosts to the known hosts. By default only known hosts will be accepted. ` SSHEx.connect( ip: to_charlist(hostname), user: to_charlist(username), key_cb: {SSHEx.ConfigurableClientKeys, [ key: , known_hosts: ]} ) ` A convenience method is provided that can take filenames instead of IO devices ` cb_module = SSHEx.ConfigurableClientKeys.get_cb_module(key_file: "path/to/keyfile", known_hosts_file: "path_to_known_hostsFile", accept_hosts: false) SSHEx.connect( ip: to_charlist(hostname), user: to_charlist(username), key_cb: cb_module ) ` """ @behaviour :ssh_client_key_api @spec add_host_key(hostname :: charlist, key :: :public_key.public_key , opts :: list) :: :ok | {:error, term} def add_host_key(hostname, key, opts) do case accept_hosts(opts) do true -> opts |> known_hosts |> IO.read(:all) |> :public_key.ssh_decode(:known_hosts) |> (fn decoded -> decoded ++ [{key, [{:hostnames, [hostname]}]}] end).() |> :public_key.ssh_encode(:known_hosts) |> (fn encoded -> IO.write(known_hosts(opts), encoded) end).() _ -> message = """ Error: unknown fingerprint found for #{inspect hostname} #{inspect key}. You either need to add a known good fingerprint to your known hosts file for this host, *or* pass the accept_hosts option to your client key callback """ {:error, message} end end @spec is_host_key(key :: :public_key.public_key, hostname :: charlist, alg :: :ssh_client_key_api.algorithm, opts :: list) :: boolean def is_host_key(key, hostname, _alg, opts) do opts |> known_hosts |> IO.read(:all) |> :public_key.ssh_decode(:known_hosts) |> has_fingerprint(key, hostname) end @spec user_key(alg :: :ssh_client_key_api.algorithm, opts :: list) :: {:error, term} | {:ok, :public_key.private_key} def user_key(_alg, opts) do material = opts |> key |> IO.read(:all) |> :public_key.pem_decode |> List.first |> :public_key.pem_entry_decode {:ok, material} end @spec get_cb_module(opts :: list) :: {atom, list} def get_cb_module(opts) do opts = opts |> Keyword.put(:key, File.open!(opts[:key_file])) |> Keyword.put(:known_hosts, File.open!(opts[:known_hosts_file])) {__MODULE__, opts} end @spec key(opts :: list) :: IO.device defp key(opts) do cb_opts(opts)[:key] end @spec accept_hosts(opts :: list) :: boolean defp accept_hosts(opts) do cb_opts(opts)[:accept_hosts] end @spec known_hosts(opts :: list) :: IO.device defp known_hosts(opts) do cb_opts(opts)[:known_hosts] end @spec cb_opts(opts :: list) :: list defp cb_opts(opts) do opts[:key_cb_private] end defp has_fingerprint(fingerprints, key, hostname) do Enum.any?(fingerprints, fn {k, v} -> (k == key) && (Enum.member?(v[:hostnames], hostname)) end ) end end ================================================ FILE: lib/sshex/helpers.ex ================================================ defmodule SSHEx.Helpers do @moduledoc """ require SSHEx.Helpers, as: H # the cool way """ @doc """ Convenience to get environment bits. Avoid all that repetitive `Application.get_env( :myapp, :blah, :blah)` noise. """ def env(key, default \\ nil), do: env(Mix.Project.get!.project[:app], key, default) def env(app, key, default), do: Application.get_env(app, key, default) @doc """ Spit to output any passed variable, with location information. If `sample` option is given, it should be a float between 0.0 and 1.0. Output will be produced randomly with that probability. Given `opts` will be fed straight into `inspect`. Any option accepted by it should work. """ defmacro spit(obj \\ "", opts \\ []) do quote do opts = unquote(opts) obj = unquote(obj) opts = Keyword.put(opts, :env, __ENV__) SSHEx.Helpers.maybe_spit(obj, opts, opts[:sample]) obj # chainable end end @doc false def maybe_spit(obj, opts, nil), do: do_spit(obj, opts) def maybe_spit(obj, opts, prob) when is_float(prob) do if :rand.uniform <= prob, do: do_spit(obj, opts) end defp do_spit(obj, opts) do %{file: file, line: line} = opts[:env] name = Process.info(self())[:registered_name] chain = [ :bright, :red, "\n\n#{file}:#{line}", :normal, "\n #{inspect self()}", :green," #{name}"] msg = inspect(obj, opts) chain = chain ++ [:red, "\n\n#{msg}"] (chain ++ ["\n\n", :reset]) |> IO.ANSI.format(true) |> IO.puts end @doc """ Print to stdout a _TODO_ message, with location information. """ defmacro todo(msg \\ "") do quote do %{file: file, line: line} = __ENV__ [ :yellow, "\nTODO: #{file}:#{line} #{unquote(msg)}\n", :reset] |> IO.ANSI.format(true) |> IO.puts :todo end end @doc """ Apply given defaults to given Keyword. Returns merged Keyword. The inverse of `Keyword.merge`, best suited to apply some defaults in a chainable way. Ex: kw = gather_data |> transform_data |> H.defaults(k1: 1234, k2: 5768) |> here_i_need_defaults Instead of: kw1 = gather_data |> transform_data kw = [k1: 1234, k2: 5768] |> Keyword.merge(kw1) |> here_i_need_defaults """ def defaults(args, defs) do defs |> Keyword.merge(args) end def convert_values(args) do Enum.map(args, fn {k, v} -> {k, convert_value(v)} end) end def convert_value(v) when is_binary(v) do String.to_charlist(v) end def convert_value(v), do: v end ================================================ FILE: lib/sshex.ex ================================================ require SSHEx.Helpers, as: H defmodule SSHEx do @moduledoc """ Module to deal with SSH connections. It uses low level erlang [ssh library](http://www.erlang.org/doc/man/ssh.html). :ssh.start # just in case {:ok, conn} = SSHEx.connect ip: '123.123.123.123', user: 'myuser' """ @doc """ Establish a connection with given options. Uses `:ssh.connect/4` for that. Recognised options are `ip` (mandatory), `port` and `negotiation_timeout`. Any other option is passed to `:ssh.connect/4` as is (so be careful if you use binaries and `:ssh` expects char lists...). See [its reference](http://erlang.org/doc/man/ssh.html#connect-4) for available options. Default values exist for some options, which are: * `port`: 22 * `negotiation_timeout`: 5000 * `silently_accept_hosts`: `true` Returns `{:ok, connection}`, or `{:error, reason}`. """ def connect(opts) do opts = opts |> H.convert_values |> H.defaults(port: 22, negotiation_timeout: 5000, silently_accept_hosts: true, ssh_module: :ssh) own_keys = [:ip, :port, :negotiation_timeout, :ssh_module] ssh_opts = opts |> Enum.filter(fn({k,_})-> not (k in own_keys) end) opts[:ssh_module].connect(opts[:ip], opts[:port], ssh_opts, opts[:negotiation_timeout]) end @doc """ Gets an open SSH connection reference (as returned by `:ssh.connect/4`), and a command to execute. Optionally it gets a `channel_timeout` for the underlying SSH channel opening, and an `exec_timeout` for the execution itself. Both default to 5000ms. Returns `{:ok,data,status}` on success. Otherwise `{:error, details}`. If `:separate_streams` is `true` then the response on success looks like `{:ok,stdout,stderr,status}`. Ex: ``` {:ok, _, 0} = SSHEx.run conn, 'rm -fr /something/to/delete' {:ok, res, 0} = SSHEx.run conn, 'ls /some/path' {:error, reason} = SSHEx.run failing_conn, 'ls /some/path' {:ok, stdout, stderr, 2} = SSHEx.run conn, 'ls /nonexisting/path', separate_streams: true ``` """ def run(conn, cmd, opts \\ []) do opts = opts |> H.convert_values |> H.defaults(connection_module: :ssh_connection, channel_timeout: 5000, exec_timeout: 5000) cmd = H.convert_value(cmd) case open_channel_and_exec(conn, cmd, opts) do {:error, r} -> {:error, r} chn -> get_response(conn, chn, opts[:exec_timeout], "", "", nil, false, opts) end end @doc """ Convenience function to run `run/3` and get output string straight from it, like `:os.cmd/1`. See `run/3` for options. Returns `response` only if `run/3` return value matches `{:ok, response, _}`, or returns `{stdout, stderr}` if `run/3` returns `{:ok, stdout, stderr, _}`. Raises any `{:error, details}` returned by `run/3`. Note return status from `cmd` is also ignored. Ex: ``` SSHEx.cmd! conn, 'mkdir -p /path/to/newdir' res = SSHEx.cmd! conn, 'ls /some/path' ``` """ def cmd!(conn, cmd, opts \\ []) do case run(conn, cmd, opts) do {:ok, response, _} -> response {:ok, stdout, stderr, _} -> {stdout, stderr} any -> raise inspect(any) end end @doc """ Gets an open SSH connection reference (as returned by `:ssh.connect/4`), and a command to execute. See `run/3` for options. Returns a `Stream` that you can use to lazily retrieve each line of output for the given command. Each iteration of the stream will read from the underlying connection and return one of these: * `{:stdout,row}` * `{:stderr,row}` * `{:status,status}` * `{:error,reason}` Keep in mind that rows may not be received in order. Ex: ``` {:ok, conn} = :ssh.connect('123.123.123.123', 22, [ {:user,'myuser'}, {:silently_accept_hosts, true} ], 5000) str = SSHEx.stream conn, 'somecommand' Stream.each(str, fn(x)-> case x do {:stdout,row} -> process_stdout(row) {:stderr,row} -> process_stderr(row) {:status,status} -> process_exit_status(status) {:error,reason} -> process_error(row) end end) ``` """ def stream(conn, cmd, opts \\ []) do opts = opts |> H.convert_values |> H.defaults(connection_module: :ssh_connection, channel_timeout: 5000, exec_timeout: 5000) cmd = H.convert_value(cmd) start_fun = fn-> open_channel_and_exec(conn,cmd,opts) end next_fun = fn(input)-> case input do :halt_next -> {:halt, 'Halt requested on previous iteration'} {:error, _} = x -> {[x], :halt_next} # emit error, then halt chn -> do_stream_next(conn, chn, opts) end end after_fun = fn(channel) -> :ok = opts[:connection_module].close(conn, channel) end Stream.resource start_fun, next_fun, after_fun end # Actual mapping of `:ssh` responses into streamable chunks # defp do_stream_next(conn, channel, opts) do case receive_and_parse_response(conn, channel, opts[:connection_module], opts[:exec_timeout]) do {:loop, {_, _, "", "", nil, false}} -> {[], channel} {:loop, {_, _, x, "", nil, false}} -> {[ {:stdout,x} ], channel} {:loop, {_, _, "", x, nil, false}} -> {[ {:stderr,x} ], channel} {:loop, {_, _, "", "", x, false}} -> {[ {:status,x} ], channel} {:loop, {_, _, "", "", nil, true }} -> {:halt, channel} {:error, _} = x -> {[x], :halt_next} # emit error, then halt end end # Try to get the channel, and then execute the given command. # Just a DRY to call internal `open_channel/3` and `exec/5`. # defp open_channel_and_exec(conn, cmd, opts) do case open_channel(conn, opts[:channel_timeout], opts[:connection_module]) do {:error, r} -> {:error, r} {:ok, chn} -> exec(chn, conn, cmd, opts[:exec_timeout], opts[:connection_module]) end end # Try to get the channel # defp open_channel(conn, channel_timeout, connection_module) do connection_module.session_channel(conn, channel_timeout) end # Execute the given command. Map every error to `{:error,reason}`. # defp exec(channel, conn, cmd, exec_timeout, connection_module) do case connection_module.exec(conn, channel, cmd, exec_timeout) do :success -> channel :failure -> {:error, "Could not exec '#{cmd}'!"} any -> any # {:error, reason} end end # Loop until all data is received. Return read data and the exit_status. # defp get_response(conn, channel, timeout, stdout, stderr, status, closed, opts) do # if we got status and closed, then we are done parsed = case {status, closed} do {st, true} when not is_nil(st) -> format_response({:ok, stdout, stderr, status}, opts) _ -> receive_and_parse_response(conn, channel, opts[:connection_module], timeout, stdout, stderr, status, closed) end # tail recursion case parsed do {:loop, {ch, tout, out, err, st, cl}} -> # loop again, still things missing get_response(conn, ch, tout, out, err, st, cl, opts) x -> x end end # Parse ugly response # defp receive_and_parse_response(conn, chn, connection_module, tout, stdout \\ "", stderr \\ "", status \\ nil, closed \\ false) do response = receive do {:ssh_cm, ^conn, res} -> res after tout -> {:error, "Timeout. Did not receive data for #{tout}ms."} end # call adjust_window to allow more data income, but only when needed case response do {:data, ^chn, _, new_data} -> connection_module.adjust_window(conn, chn, byte_size(new_data)) _ -> :ok end case response do {:data, ^chn, 1, new_data} -> {:loop, {chn, tout, stdout, stderr <> new_data, status, closed}} {:data, ^chn, 0, new_data} -> {:loop, {chn, tout, stdout <> new_data, stderr, status, closed}} {:eof, ^chn} -> {:loop, {chn, tout, stdout, stderr, status, closed}} {:exit_signal, ^chn, _, _} -> {:loop, {chn, tout, stdout, stderr, status, closed}} {:exit_signal, ^chn, _, _, _} -> {:loop, {chn, tout, stdout, stderr, status, closed}} {:exit_status, ^chn, new_status} -> {:loop, {chn, tout, stdout, stderr, new_status, closed}} {:closed, ^chn} -> {:loop, {chn, tout, stdout, stderr, status, true}} any -> any # {:error, reason} end end # Format response for given raw response and given options # defp format_response(raw, opts) do case opts[:separate_streams] do true -> raw _ -> {:ok, stdout, stderr, status} = raw {:ok, stdout <> stderr, status} end end end ================================================ FILE: mix.exs ================================================ defmodule SSHEx.Mixfile do use Mix.Project def project do [app: :sshex, version: "2.2.1", elixir: ">= 1.0.0", package: package(), deps: deps(), description: "Simple SSH helpers for Elixir" ] end def application do [ applications: [:ssh] ] end defp package do [maintainers: ["Rubén Caro"], licenses: ["MIT"], links: %{github: "https://github.com/rubencaro/sshex"}] end defp deps, do: [{:ex_doc, ">= 0.0.0", only: :dev}] end ================================================ FILE: test/configurable_client_keys_test.exs ================================================ defmodule SSHEx.ConfigurableClientKeysTest do use ExUnit.Case alias SSHEx.ConfigurableClientKeys @key """ -----BEGIN RSA PRIVATE KEY----- MIIEpQIBAAKCAQEAr7CylYwuNUCYjOV7uj0X8ZRVVKlwhKtavL0vtmCiSes/TQ+u bQb7787djy4fILh/9ALsvOs6mzmAI9Ye6CxwG2nPhbweD76K/92cAAvbcwWFhKTS 6q2MY6XhATcORqQmYhi6JToYkz51JFeG0k38TwyiIBaLe4yKTCnZ6F0tIB9szdR8 pNOoZTMDAjXRDA1T0Y1wgxXn5dCFR4ywDcphRTu18FWhulruyPGQjRjFRzZCF8rO PYRCVBaWCIQ9Guj6VnAOaPH3tIkkdTxAeMigflCsCbFttbKLSVbi+woQrOnpQt1G T9YPaZed9vuXXJMI5IzUacoveyqr/X5cOfdVjwIDAQABAoIBAQCWJHRJt1WZ7s0v w8H8A8/dhT1zL6ZXyrStjSQkQOsQLrmXGqqexBQz+V6AyRKS/PlkR8eXH5OjKf2n IoqhMbDQzJkrmfs6y0SwqutxYrC02GglVlJledD7K7xhNHK/zfJ7bNRPkhmEZCDp 4N74BOt1hr9amsmy2QUrV6zAljhFNQtGXlMIzjSwkhAO5RyVDdNUIuXVbK4vYQ8n 60LAFUqrNMU0H5P7N1I9Wv1XdVroQ23bZkkoQ2YNg3+Xt8/ma1R8ImliODq593EF EWDdtcm6gUeKfZSmu3V3XLX2w03/xng9PiQJWhDyGiiqylLWw61V72BOfk8Lsq03 bCI8HP5pAoGBANmMlI31H1E6zNrJ5/1+2IETRLJhwhCE5SJ14UgHpGjzWpCRVoi1 KY+N6FfUgReMn6efFtJj59cKUxkODDr4yt+8mfM0evfIUJumQ56czDv5l2m3dnm1 0JOX4jIwetFv0ROrbk7EONkg4BYLV3OFlekB+iNR0cW3mtPM/x35hvbbAoGBAM6+ Icf5pDwvU9tqgBlbYdN9D8IJa2kTC2818XAeEBcnDsQNrpKNGfG7jcOb7Os7Qs4q ArIrfaWKH5OnRv8sxEJzvR1yQ7zfN0qL5+FgI95/5GmNzmiIOMIRltszuqvXhEbf VfxoEOrYidiliI1P2oSkaSHnKh06vTcX/Iuve3hdAoGAPLahFuUb8l2Iol7K4dIu tgccmvPxZw7Pq8heMO4BElEoK0SEc+6rRKcD+s8Rn/Lc87jQc7LyFu+ItWtYOnUI mVxXUqqIzvIWnPnP0UpNLUfA2/4ZkGoPZcFznTIudJjSLr0fMdhNTTuBjmVn6JOV fMvSdVz2QEm3afjCEil7YxUCgYEAkSPH4XUvyJTNQTfGUIbn6apdurIUNwMIvv1W z4g7cZWY9yhHy1jFwwARqSa5L/c9kjDKDb0ci2+pdWY1IIWUDrbkKF0Ekv79+Ra5 Jm7xH44Xk8bbBmXDuvLQPnlVbrhxg7Pc0MNaRRTZyT+E2vgZh49Iw2VfGoAXQCtV v9blTn0CgYEAoaLnpcDDIAJrSEuYJlmMkCSOLnDaUJ2Gvk7h8J3AKUJqaZKDrYtp LkmEnkNn9pRguHw5O4t2A2/MPTMMPl9okxWUxmFol6vrLcVWJ7fHKnAgN4VeVdmV 3wC+maU88MNIdY/eZWowKv/3ZzENQAJYVSOoDKRM5prZ4UMml4xIv4c= -----END RSA PRIVATE KEY----- """ @decoded_pem { :RSAPrivateKey, :"two-prime", 22178836200351380318740579128076760436035138298677133998095994045880250237512489621454049374968347715235055783881967698436999743499552314220245159073073380722782484638674394343203468456842721969160490509408462854393345405217052033027002266498826680781860593217442724766608969408399340485759397900671701217164071787627996052566922654086785056460976416517428062469453531934201888286810352377976660566632576939435504625681508553755882812006916212863287750386635961610258614389401711948452227087543533788958800504728999838598664499003820759527927048804714300161960621149036100364182740694238727122433513404946091683042703, 65537, 18953722005479038672989358915211180275890260321558970411086292300953891314102903798293741601596842249220589427161410575497215994540174656492260412045190058045697523798132914292381351875465619868574569967505985612493829380502486125604518301719636021034677605693414631216007271459728426119381823980696705221015479522156363868435684783617296463247438499300802671603744372814426304362919053721052072690042881679754394100902833620569116419541999627788678348085500419601780112784642580771348412671226267444389048117048054825692970232535861820613163424630322678634426098569280437755714074716898258095743415742430656778272361, 152768202594113506003906892512748290305531150912607635622225249629386697488954984355599970491458110778989746505533403503662866670470953760749589017642309508310049856672577159050375653128905845977073586607530375373027518020058386877444130805997536867700689067133764524765224528717473435950253382932149817505499, 145179663200449145917521205971590191715095399605877657860535867727166023541655152176425320419506590078056361653718878871175026174125734643334617196310933880264431271402500332782840931546131335362589195212219385352510583753009500018046055463721898551203113792493296805078356511882223907692461773372201488840797, 42634396225740208200122939165023822110993251906428332934533161660153542229168052609425568156747621132302706312254237302317680568273093737646062272192469000823821839244113039031865521701141155727614567329168722486117358203562383020102433014048475659707426385673383785616612854269230955697241777527641182266133, 101920611626859098746040147787461939524540705867934527984274451657219304776355522780797909077026392768989962056944197903228585062565435177154621093200325875415203905670958966858503264102811489825554515502983073693999716921620063267013762696345283281845431778671957714037110407177460667546919659598114321682045, 113504902981879519219294186506942993139718372538463522557610536232722856450238191548259358573620555616453272575244746754527476128589876390856908911294135573670756100331272684920761429283802284667615556476745952741230523406386621965212672405641392988569738039218761014896354457746693861804733309700621570981767, :asn1_NOVALUE } @known_hosts """ github.com,192.30.252.128 ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== """ @host_key { :RSAPublicKey, 21634204163197213132817109123906975906368888521544012567769262995559431966147970056259890368935740096079275379887017970430632559083119648736096672444000478892100121400122505155635695213610246722639150597148186404829574795017869184029845276838222700401896051725788665083080114314875103545837696279553436341967388240785773957395421170074137268759810304409727303757139265883118481355074238232002946450070460602471201997377623196017810991617729908588802783067540409316213603919494955068312762445623851275603318105356333614840694877780018045461415298887693169943421044958989561462337777142733008105081260079805978461159529, 35 } @tmp_dir "./test/temp" setup_all do File.mkdir_p!(@tmp_dir) on_exit(fn -> File.rm_rf!(@tmp_dir) end) end setup do {:ok, known_hosts} = StringIO.open(@known_hosts) {:ok, key} = StringIO.open(@key) %{ known_hosts: known_hosts, key: key } end test "add_host_key writes an entry to known hosts if accept_hosts is true" do {:ok, known_hosts} = StringIO.open("") ConfigurableClientKeys.add_host_key( "example.com", @host_key, [key_cb_private: [accept_hosts: true, known_hosts: known_hosts]] ) result = StringIO.flush(known_hosts) assert result =~ "example.com" end test "add_host_key returns an error if accept_hosts is false" do {:ok, known_hosts} = StringIO.open("") result = ConfigurableClientKeys.add_host_key( "example.com", @host_key, [key_cb_private: [accept_hosts: false, known_hosts: known_hosts]]) assert {:error, _message} = result end test "is_host_key returns true if host and key match known hosts entry", %{known_hosts: known_hosts} do result = ConfigurableClientKeys.is_host_key( @host_key, 'github.com', nil, [key_cb_private: [accept_hosts: false, known_hosts: known_hosts]]) assert result end test "is_host_key returns false if host and key do not match known hosts entry", %{known_hosts: known_hosts} do result = ConfigurableClientKeys.is_host_key( @host_key, 'other.com', nil, [key_cb_private: [accept_hosts: false, known_hosts: known_hosts]]) refute result end test "user key returns the contents of the key option", %{key: key} do result = ConfigurableClientKeys.user_key( nil, [key_cb_private: [key: key]] ) assert result == {:ok, @decoded_pem} end test "get_cb_module returns IO devices for the specified files" do key_file = Path.join(@tmp_dir, "./foo") known_hosts_file = Path.join(@tmp_dir, "./bar") File.touch(key_file) File.touch(known_hosts_file) {_module, opts} = ConfigurableClientKeys.get_cb_module([key_file: key_file, known_hosts_file: known_hosts_file ]) assert opts[:key_file] assert opts[:known_hosts_file] end end ================================================ FILE: test/sshex_test.exs ================================================ defmodule SSHExTest do use ExUnit.Case test "connect" do opts = [ip: '123.123.123.123', user: 'myuser', ssh_module: AllOKMock] assert SSHEx.connect(opts) == {:ok, :mocked} end test "Plain `cmd!`" do # send mocked response sequence to the mailbox mocked_data = "output" status = 123 # any would do send_regular_sequence mocked_data, status # actually test it assert SSHEx.cmd!(:mocked, 'somecommand', connection_module: AllOKMock) == mocked_data end test "String `cmd!`" do # send mocked response sequence to the mailbox mocked_data = "output" status = 123 # any would do send_regular_sequence mocked_data, status # actually test it assert SSHEx.cmd!(:mocked, "somecommand", connection_module: AllOKMock) == mocked_data end test "Plain `run`" do # send mocked response sequence to the mailbox mocked_data = "output" status = 123 # any would do send_regular_sequence mocked_data, status assert SSHEx.run(:mocked, 'somecommand', connection_module: AllOKMock) == {:ok, mocked_data, status} end test "String `run`" do # send mocked response sequence to the mailbox mocked_data = "output" status = 123 # any would do send_regular_sequence mocked_data, status assert SSHEx.run(:mocked, "somecommand", connection_module: AllOKMock) == {:ok, mocked_data, status} end test "Stream long response" do lines = ["some", "long", "output", "sequence"] send_long_sequence(lines) response = Enum.map(lines,&( {:stdout,&1} )) ++ [ {:stderr,"mockederror"}, {:status, 0} ] str = SSHEx.stream :mocked, 'somecommand', connection_module: AllOKMock assert Enum.to_list(str) == response end test "Separate streams" do # send mocked response sequence to the mailbox mocked_stdout = "output" mocked_stderr = "something failed" send_separated_sequence mocked_stdout, mocked_stderr # actually test it res = SSHEx.run :mocked, 'failingcommand', connection_module: AllOKMock, separate_streams: true assert res == {:ok, mocked_stdout, mocked_stderr, 2} end test "`:ssh` error message when `run`" do send self(), {:ssh_cm, :mocked, {:error, :reason}} assert SSHEx.run(:mocked, 'somecommand', connection_module: AllOKMock) == {:error, :reason} end test "`:ssh` error message when `cmd!`" do send self(), {:ssh_cm, :mocked, {:error, :reason}} assert_raise RuntimeError, "{:error, :reason}", fn -> SSHEx.cmd!(:mocked, 'somecommand', connection_module: AllOKMock) end end test "`:ssh` error message while `stream`" do lines = ["some", "long", "output", "sequence"] send_long_sequence(lines, error: true) response = Enum.map(lines,&( {:stdout,&1} )) ++ [ {:error, :reason} ] str = SSHEx.stream :mocked, 'somecommand', connection_module: AllOKMock assert Enum.to_list(str) == response end test "`:ssh_connection.exec` failure" do assert SSHEx.run(:mocked, 'somecommand', connection_module: ExecFailureMock) == {:error, "Could not exec 'somecommand'!"} str = SSHEx.stream(:mocked, 'somecommand', connection_module: ExecFailureMock) assert Enum.to_list(str) == [error: "Could not exec 'somecommand'!"] assert_raise RuntimeError, "{:error, \"Could not exec 'somecommand'!\"}", fn -> SSHEx.cmd!(:mocked, 'somecommand', connection_module: ExecFailureMock) end end test "`:ssh_connection.exec` error" do assert SSHEx.run(:mocked, 'somecommand', connection_module: ExecErrorMock) == {:error, :reason} str = SSHEx.stream(:mocked, 'somecommand', connection_module: ExecErrorMock) assert Enum.to_list(str) == [error: :reason] assert_raise RuntimeError, "{:error, :reason}", fn -> SSHEx.cmd!(:mocked, 'somecommand', connection_module: ExecErrorMock) end end test "`:ssh_connection.session_channel` error" do assert SSHEx.run(:mocked, 'somecommand', connection_module: SessionChannelErrorMock) == {:error, :reason} str = SSHEx.stream(:mocked, 'somecommand', connection_module: SessionChannelErrorMock) assert Enum.to_list(str) == [error: :reason] assert_raise RuntimeError, "{:error, :reason}", fn -> SSHEx.cmd!(:mocked, 'somecommand', connection_module: SessionChannelErrorMock) end end test "receive only from given connection" do # send mocked response sequence to the mailbox for 2 different connections status = 123 # any would do mocked_data1 = "output1" send_regular_sequence mocked_data1, status, conn: :mocked1 mocked_data2 = "output2" send_regular_sequence mocked_data2, status, conn: :mocked2 # check that we only receive for the one we want assert SSHEx.cmd!(:mocked2, 'somecommand', connection_module: AllOKMock) == mocked_data2 end defp send_long_sequence(lines, opts \\ []) do for l <- lines do send self(), {:ssh_cm, :mocked, {:data, :mocked, 0, l}} end if opts[:error], do: send(self(), {:ssh_cm, :mocked, {:error, :reason}}) send self(), {:ssh_cm, :mocked, {:data, :mocked, 1, "mockederror"}} send self(), {:ssh_cm, :mocked, {:eof, :mocked}} send self(), {:ssh_cm, :mocked, {:exit_status, :mocked, 0}} send self(), {:ssh_cm, :mocked, {:closed, :mocked}} end defp send_regular_sequence(mocked_data, status, opts \\ []) do conn = opts[:conn] || :mocked send self(), {:ssh_cm, conn, {:data, conn, 0, mocked_data}} send self(), {:ssh_cm, conn, {:eof, conn}} send self(), {:ssh_cm, conn, {:exit_status, conn, status}} send self(), {:ssh_cm, conn, {:closed, conn}} end defp send_separated_sequence(mocked_stdout, mocked_stderr) do send self(), {:ssh_cm, :mocked, {:data, :mocked, 0, mocked_stdout}} send self(), {:ssh_cm, :mocked, {:data, :mocked, 1, mocked_stderr}} send self(), {:ssh_cm, :mocked, {:eof, :mocked}} send self(), {:ssh_cm, :mocked, {:exit_status, :mocked, 2}} send self(), {:ssh_cm, :mocked, {:closed, :mocked}} end end defmodule AllOKMock do def connect(_,_,_,_), do: {:ok, :mocked} def session_channel(conn,_), do: {:ok, conn} def exec(_,_,_,_), do: :success def adjust_window(_,_,_), do: :ok def close(_, _), do: :ok end defmodule ExecFailureMock do def session_channel(_,_), do: {:ok, :mocked} def exec(_,_,_,_), do: :failure def adjust_window(_,_,_), do: :ok def close(_, _), do: :ok end defmodule ExecErrorMock do def session_channel(_,_), do: {:ok, :mocked} def exec(_,_,_,_), do: {:error, :reason} def adjust_window(_,_,_), do: :ok def close(_, _), do: :ok end defmodule SessionChannelErrorMock do def session_channel(_,_), do: {:error, :reason} def exec(_,_,_,_), do: :success def adjust_window(_,_,_), do: :ok def close(_, _), do: :ok end ================================================ FILE: test/test_helper.exs ================================================ ExUnit.start()