Full Code of rubencaro/sshex for AI

master e22f405c4485 cached
12 files
35.1 KB
11.4k tokens
61 symbols
1 requests
Download .txt
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: <IO.device>,
        known_hosts: <IO.device> ]}
      )
  `
  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()
Download .txt
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
Download .txt
SYMBOL INDEX (61 symbols across 6 files)

FILE: lib/sshex.ex
  class SSHEx (line 3) | defmodule SSHEx
    method connect (line 28) | def connect(opts) do
    method run (line 64) | def run(conn, cmd, opts \\ []) do
    method cmd! (line 96) | def cmd!(conn, cmd, opts \\ []) do
    method stream (line 140) | def stream(conn, cmd, opts \\ []) do
    method do_stream_next (line 168) | defp do_stream_next(conn, channel, opts) do
    method open_channel_and_exec (line 182) | defp open_channel_and_exec(conn, cmd, opts) do
    method open_channel (line 191) | defp open_channel(conn, channel_timeout, connection_module) do
    method exec (line 197) | defp exec(channel, conn, cmd, exec_timeout, connection_module) do
    method get_response (line 207) | defp get_response(conn, channel, timeout, stdout, stderr, status, clos...
    method receive_and_parse_response (line 226) | defp receive_and_parse_response(conn, chn, connection_module, tout,
    method format_response (line 254) | defp format_response(raw, opts) do

FILE: lib/sshex/configurable_client_keys.ex
  class SSHEx.ConfigurableClientKeys (line 1) | defmodule SSHEx.ConfigurableClientKeys
    method add_host_key (line 35) | def add_host_key(hostname, key, opts) do
    method is_host_key (line 57) | def is_host_key(key, hostname, _alg, opts) do
    method user_key (line 66) | def user_key(_alg, opts) do
    method get_cb_module (line 78) | def get_cb_module(opts) do
    method key (line 87) | defp key(opts) do
    method accept_hosts (line 92) | defp accept_hosts(opts) do
    method known_hosts (line 97) | defp known_hosts(opts) do
    method cb_opts (line 102) | defp cb_opts(opts) do
    method has_fingerprint (line 106) | defp has_fingerprint(fingerprints, key, hostname) do

FILE: lib/sshex/helpers.ex
  class SSHEx.Helpers (line 1) | defmodule SSHEx.Helpers
    method env (line 9) | def env(key, default \\ nil), do: env(Mix.Project.get!.project[:app], ...
    method env (line 10) | def env(app, key, default), do: Application.get_env(app, key, default)
    method maybe_spit (line 30) | def maybe_spit(obj, opts, nil), do: do_spit(obj, opts)
    method do_spit (line 35) | defp do_spit(obj, opts) do
    method defaults (line 79) | def defaults(args, defs) do
    method convert_values (line 83) | def convert_values(args) do
    method convert_value (line 91) | def convert_value(v), do: v

FILE: mix.exs
  class SSHEx.Mixfile (line 1) | defmodule SSHEx.Mixfile
    method project (line 4) | def project do
    method application (line 13) | def application do
    method package (line 17) | defp package do
    method deps (line 23) | defp deps, do: [{:ex_doc, ">= 0.0.0", only: :dev}]

FILE: test/configurable_client_keys_test.exs
  class SSHEx.ConfigurableClientKeysTest (line 1) | defmodule SSHEx.ConfigurableClientKeysTest

FILE: test/sshex_test.exs
  class SSHExTest (line 1) | defmodule SSHExTest
    method send_long_sequence (line 139) | defp send_long_sequence(lines, opts \\ []) do
    method send_regular_sequence (line 152) | defp send_regular_sequence(mocked_data, status, opts \\ []) do
    method send_separated_sequence (line 160) | defp send_separated_sequence(mocked_stdout, mocked_stderr) do
  class AllOKMock (line 170) | defmodule AllOKMock
    method connect (line 171) | def connect(_,_,_,_), do: {:ok, :mocked}
    method session_channel (line 172) | def session_channel(conn,_), do: {:ok, conn}
    method exec (line 173) | def exec(_,_,_,_), do: :success
    method adjust_window (line 174) | def adjust_window(_,_,_), do: :ok
    method close (line 175) | def close(_, _), do: :ok
  class ExecFailureMock (line 178) | defmodule ExecFailureMock
    method session_channel (line 179) | def session_channel(_,_), do: {:ok, :mocked}
    method exec (line 180) | def exec(_,_,_,_), do: :failure
    method adjust_window (line 181) | def adjust_window(_,_,_), do: :ok
    method close (line 182) | def close(_, _), do: :ok
  class ExecErrorMock (line 185) | defmodule ExecErrorMock
    method session_channel (line 186) | def session_channel(_,_), do: {:ok, :mocked}
    method exec (line 187) | def exec(_,_,_,_), do: {:error, :reason}
    method adjust_window (line 188) | def adjust_window(_,_,_), do: :ok
    method close (line 189) | def close(_, _), do: :ok
  class SessionChannelErrorMock (line 192) | defmodule SessionChannelErrorMock
    method session_channel (line 193) | def session_channel(_,_), do: {:error, :reason}
    method exec (line 194) | def exec(_,_,_,_), do: :success
    method adjust_window (line 195) | def adjust_window(_,_,_), do: :ok
    method close (line 196) | def close(_, _), do: :ok
Condensed preview — 12 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (38K chars).
[
  {
    "path": ".gitignore",
    "chars": 127,
    "preview": "/_build\n/deps\nerl_crash.dump\n*.ez\n*.swp\n*.kate-swp\n.kateproject.d\n.zedstate\n.directory\n.elixir_ls\n\n/config/prod.exs\n\n/re"
  },
  {
    "path": ".tool-versions",
    "chars": 34,
    "preview": "elixir 1.8.1-otp-21\nerlang 21.2.5\n"
  },
  {
    "path": ".travis.yml",
    "chars": 67,
    "preview": "language: elixir\nelixir:\n  - 1.8\notp_release:\n    21.0\nsudo: false\n"
  },
  {
    "path": "LICENSE",
    "chars": 1077,
    "preview": "The MIT License (MIT)\n\nCopyright (c) 2014 Rubén Caro\n\nPermission is hereby granted, free of charge, to any person obtain"
  },
  {
    "path": "README.md",
    "chars": 4332,
    "preview": "----\nThe actively maintained version of SSHEx is now in https://github.com/witchtails/sshex\n\nThis repo has been archived"
  },
  {
    "path": "lib/sshex/configurable_client_keys.ex",
    "chars": 3406,
    "preview": "defmodule SSHEx.ConfigurableClientKeys do\n  @moduledoc ~S\"\"\"\n  Provides public key behavior for SSH clients.\n  \n  valid "
  },
  {
    "path": "lib/sshex/helpers.ex",
    "chars": 2578,
    "preview": "defmodule SSHEx.Helpers do\n  @moduledoc \"\"\"\n    require SSHEx.Helpers, as: H  # the cool way\n  \"\"\"\n  @doc \"\"\"\n    Conven"
  },
  {
    "path": "lib/sshex.ex",
    "chars": 8914,
    "preview": "require SSHEx.Helpers, as: H\n\ndefmodule SSHEx do\n\n  @moduledoc \"\"\"\n    Module to deal with SSH connections. It uses low "
  },
  {
    "path": "mix.exs",
    "chars": 490,
    "preview": "defmodule SSHEx.Mixfile do\n  use Mix.Project\n\n  def project do\n    [app: :sshex,\n     version: \"2.2.1\",\n     elixir: \">="
  },
  {
    "path": "test/configurable_client_keys_test.exs",
    "chars": 8082,
    "preview": "defmodule SSHEx.ConfigurableClientKeysTest do\n  use ExUnit.Case\n\n  alias SSHEx.ConfigurableClientKeys\n\n    @key \"\"\"\n  --"
  },
  {
    "path": "test/sshex_test.exs",
    "chars": 6770,
    "preview": "defmodule SSHExTest do\n  use ExUnit.Case\n\n  test \"connect\" do\n    opts = [ip: '123.123.123.123',\n      user: 'myuser',\n "
  },
  {
    "path": "test/test_helper.exs",
    "chars": 15,
    "preview": "ExUnit.start()\n"
  }
]

About this extraction

This page contains the full source code of the rubencaro/sshex GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 12 files (35.1 KB), approximately 11.4k tokens, and a symbol index with 61 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!