├── .formatter.exs ├── .gitignore ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── ssh_tunnel.ex └── ssh_tunnel │ ├── application.ex │ ├── tunnel.ex │ └── tunnel │ └── tcp_handler.ex ├── mix.exs ├── mix.lock └── test ├── ssh_tunnel_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | ssht-*.tar 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Simon Thörnqvist 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SSHTunnel 2 | 3 | Create SSH tunnels in Elixir 4 | 5 | [Documentation for SSHTunnel is available online.](https://hexdocs.pm/ssh_tunnel) 6 | 7 | ## Installation 8 | 9 | Add SSHTunnel to your `mix.exs` and run `mix deps.get` 10 | 11 | ```elixir 12 | def deps do 13 | [ 14 | {:ssh_tunnel, "~> 0.1.3"} 15 | ] 16 | end 17 | ``` 18 | 19 | ## Usage 20 | 21 | SSHTunnel can be used to create forwarded SSH channels, similair to channels created using `:ssh_connection`. 22 | Sending messages can be done using `:ssh_connection.send/3`. 23 | 24 | SSHTunnel also provide on-demand created tunnels, this is eqvivalent to using `ssh -nNT -L 8080:sshserver.example.com:80 user@sshserver.example.com`. 25 | The tunnel process will forward messages from a TCP client to a ssh connection and back. 26 | 27 | ### As channels 28 | 29 | * `directtcp-ip` 30 | 31 | ```elixir 32 | msg = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: ssht/0.1.1\r\nAccept: */*\r\n\r\n" 33 | 34 | {:ok, pid} = SSHTunnel.connect(host: "sshserver.example.com", user: "user", password: "password") 35 | {:ok, ch} = SSHTunnel.direct_tcpip(pid, {"127.0.0.1", 8080}, {"sshserver.example.com", 80}) 36 | :ok = :ssh_connection.send(pid, ch, msg) 37 | receive do 38 | {:ssh_cm, _, {:data, channel, _, data}} -> IO.puts("Data: #{(data)}") 39 | end 40 | ``` 41 | 42 | * `streamlocal forward` 43 | 44 | ```elixir 45 | msg = "GET /images/json HTTP/1.1\r\nHost: /var/run/docker.sock\r\nAccept: */*\r\n\r\n" 46 | 47 | {:ok, pid} = SSHTunnel.connect(host: "sshserver.example.com", user: "user", password: "password") 48 | {:ok, ch} = SSHTunnel.stream_local_forward(pid, "/var/run/docker.sock") 49 | :ok = :ssh_connection.send(pid, ch, msg) 50 | 51 | receive do 52 | {:ssh_cm, _, {:data, channel, _, data}} -> IO.puts("Data: #{(data)}") 53 | end 54 | ``` 55 | 56 | ### Tunnels 57 | 58 | * `directtcp-ip` 59 | 60 | ```elixir 61 | {:ok, ssh_ref} = SSHTunnel.connect(host: "sshserver.example.com", user: "user", password: "password") 62 | 63 | # Will start a tcp server listening on port 8080. 64 | # Any TCP messages received on `127.0.0.1:8080` will be forwarded to `sshserver.example.com:80` 65 | {:ok, pid} = SSHTunnel.start_tunnel(pid, {:tcpip, {8080, {"sshserver.example.com", 80}}}) 66 | 67 | # Send a TCP message 68 | %HTTPoison.Response{body: body} = HTTPoison.get!("127.0.0.1:8080") 69 | IO.puts("Received body: #{body}) 70 | ``` 71 | 72 | * `streamlocal forward` 73 | 74 | ```elixir 75 | {:ok, ssh_ref} = SSHTunnel.connect(host: "sshserver.example.com", user: "user", password: "password") 76 | 77 | # Will start a tcp server listening on the provided path. 78 | # Any TCP messages received on `/path/to/socket.socket` will be forwarded to the `/path/`to/remote.sock` on sshserver.example.com 79 | {:ok, pid} = SSHTunnel.start_tunnel(pid, {:local, {"/path/to/socket.sock", {"sshserver.example.com", "/path/to/remote.sock"}}}) 80 | 81 | # Send a TCP message 82 | %HTTPoison.Response{body: body} = HTTPoison.get!("http+unix://#{URI.encode_www_form("/path/to/socket.sock")}") 83 | IO.puts("Received body: #{body}) 84 | ``` 85 | 86 | It is also possible to mix and match: 87 | 88 | ```elixir 89 | # From a local port to a remote socket 90 | {:ok, pid} = SSHTunnel.start_tunnel(pid, {:tcpip, {8080, {"sshserver.example.com", "/path/to/remote.sock"}}}) 91 | 92 | # From a local socket to a remote port 93 | {:ok, pid} = SSHTunnel.start_tunnel(pid, {:local, {"/path/to/socket.sock", {"sshserver.example.com", 80}}}) 94 | ``` 95 | 96 | ## Testing 97 | 98 | ```bash 99 | mix test 100 | ``` 101 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :ssh_tunnel, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:ssh_tunnel, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/ssh_tunnel.ex: -------------------------------------------------------------------------------- 1 | defmodule SSHTunnel do 2 | @moduledoc ~S""" 3 | Module for creating SSH tunnels using `:ssh`. 4 | 5 | It provides functions to create forwarded ssh channels, similair 6 | to how other channels can be created using `:ssh_connection`. 7 | 8 | There are two type of channels supported 9 | * `directtcp-ip` - Forwards a port from the client machine to the remote machine. This is the same as `ssh -nNT -L 8080:forward.example.com:9000 user@sshserver.example.com` 10 | * `direct-streamlocal` - Forwards to a unix domain socket. This is the same as `ssh -nNT -L 8080:/var/lib/mysql/mysql.sock user@sshserver.example.com` 11 | 12 | When using `direct_tcpip/3` or `stream_local_forward/2` directly there will not be any local port or socket bound, 13 | this can either be done using `SSHTunnel.Tunnel` or by manually sending data with `:ssh_connection.send/3` 14 | 15 | Although `connect/1` can be used to connect to the remote host, other methods are supported. 16 | One can use [SSHex](https://github.com/rubencaro/sshex), `:ssh.connect/3` for instance. 17 | 18 | ## Tunnels 19 | 20 | Tunnels are on-demand TCP servers and are bound listeners to either a port or a path. The tunnel will handle 21 | relaying TCP messages to the ssh connection and back. 22 | 23 | ## Examples 24 | 25 | {:ok, ssh_ref} = SSHTunnel.connect(host: "sshserver.example.com", user: "user", password: "password") 26 | {:ok, pid} = SSHTunnel.start_tunnel(pid, {:tcpip, {8080, {"192.168.90.15", 80}}}) 27 | # Send a TCP message for instance HTTP 28 | %HTTPoison.Response{body: body} = HTTPoison.get!("127.0.0.1:8080") 29 | IO.puts("Received body: #{body}) 30 | 31 | """ 32 | 33 | @direct_tcpip String.to_charlist("direct-tcpip") 34 | @stream_local String.to_charlist("direct-streamlocal@openssh.com") 35 | 36 | @ini_window_size 1024 * 1024 37 | @max_packet_size 32 * 1024 38 | 39 | @type location :: {String.t(), integer()} 40 | 41 | @doc """ 42 | Create a connetion to a remote host with the provided options. This function is mostly used as 43 | convenience wrapper around `:ssh_connect/3` and does not support all options. 44 | 45 | returns: `{:ok, connection}` or `{:error, reason}`. 46 | """ 47 | @spec connect(Keyword.t()) :: {:ok, pid()} | {:error, term()} 48 | def connect(host \\ "127.0.0.1", port \\ 22, opts \\ []) do 49 | ssh_config = defaults(opts) 50 | 51 | :ssh.connect(String.to_charlist(host), port, ssh_config) 52 | end 53 | 54 | @doc ~S""" 55 | Starts a SSHTunnel.Tunnel process, the tunnel will listen to either a local port or local path and handle 56 | passing messages between the TCP client and ssh connection. 57 | 58 | ## Examples 59 | 60 | {:ok, ssh_ref} = SSHTunnel.connect(host: "sshserver.example.com", user: "user", password: "password") 61 | {:ok, pid} = SSHTunnel.start_tunnel(pid, {:tcpip, {8080, {"192.168.90.15", 80}}}) 62 | # Send a TCP message 63 | %HTTPoison.Response{body: body} = HTTPoison.get!("127.0.0.1:8080") 64 | IO.puts("Received body: #{body}) 65 | 66 | """ 67 | @spec start_tunnel(pid(), SSHTunnel.Tunnel.to(), Keyword.t()) :: {:ok, pid()} | {:error, term()} 68 | defdelegate start_tunnel(pid, to, opts \\ []), to: SSHTunnel.Tunnel, as: :start 69 | 70 | @doc ~S""" 71 | Creates a ssh directtcp-ip forwarded channel to a remote port. 72 | The returned channel together with a ssh connection reference (returned from `:ssh.connect/4`) can be used 73 | to send messages with `:ssh_connection.send/3` 74 | 75 | returns: `{:ok, channel}` or `{:error, reason}`. 76 | 77 | ## Examples: 78 | 79 | msg = "GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/7.47.0\r\nAccept: */*\r\n\r\n" 80 | 81 | {:ok, pid} = SSHTunnel.connect(host: "192.168.1.10", user: "user", password: "password") 82 | {:ok, ch} = SSHTunnel.direct_tcpip(pid, {"127.0.0.1", 8080}, {"192.168.1.10", 80}) 83 | :ok = :ssh_connection.send(pid, ch, msg) 84 | recieve do 85 | {:ssh_cm, _, {:data, channel, _, data}} -> IO.puts("Data: #{(data)}") 86 | end 87 | 88 | """ 89 | @spec direct_tcpip(pid(), location, location) :: {:ok, integer()} | {:error, term()} 90 | def direct_tcpip(pid, from, to) do 91 | {orig_host, orig_port} = from 92 | {remote_host, remote_port} = to 93 | 94 | remote_len = byte_size(remote_host) 95 | orig_len = byte_size(orig_host) 96 | 97 | msg = << 98 | remote_len::size(32), 99 | remote_host::binary, 100 | remote_port::size(32), 101 | orig_len::size(32), 102 | orig_host::binary, 103 | orig_port::size(32) 104 | >> 105 | 106 | open_channel(pid, @direct_tcpip, msg, @ini_window_size, @max_packet_size, :infinity) 107 | end 108 | 109 | @doc ~S""" 110 | Creates a ssh stream local-forward channel to a remote unix domain socket. 111 | 112 | The returned channel together with a ssh connection reference (returned from `:ssh.connect/4`) can be used 113 | to send messages with `:ssh_connection.send/3`. 114 | 115 | returns: `{:ok, channel}` or `{:error, reason}`. 116 | 117 | Ex: 118 | ``` 119 | msg = "GET /images/json HTTP/1.1\r\nHost: /var/run/docker.sock\r\nAccept: */*\r\n\r\n" 120 | 121 | {:ok, pid} = SSHTunnel.connect(host: "192.168.90.15", user: "user", password: "password") 122 | {:ok, ch} = SSHTunnel.stream_local_forward(pid, "/var/run/docker.sock") 123 | :ok = :ssh_connection.send(pid, ch, msg) 124 | ``` 125 | """ 126 | @spec stream_local_forward(pid(), String.t()) :: {:ok, integer()} | {:error, term()} 127 | def stream_local_forward(pid, socket_path) do 128 | msg = <> 129 | 130 | open_channel(pid, @stream_local, msg, @ini_window_size, @max_packet_size, :infinity) 131 | end 132 | 133 | defp open_channel(pid, type, msg, window_size, max_packet_size, timeout) do 134 | case :ssh_connection_handler.open_channel( 135 | pid, 136 | type, 137 | msg, 138 | window_size, 139 | max_packet_size, 140 | timeout 141 | ) do 142 | {:open, ch} -> {:ok, ch} 143 | {:open_error, _, reason, _} -> {:error, to_string(reason)} 144 | end 145 | end 146 | 147 | defp defaults(opts) do 148 | # convert strings to charlists for :ssh.connect 149 | Enum.map(opts, fn {k, v} -> 150 | cond do 151 | is_binary(v) -> 152 | {k, String.to_charlist(v)} 153 | 154 | true -> 155 | {k, v} 156 | end 157 | end) 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/ssh_tunnel/application.ex: -------------------------------------------------------------------------------- 1 | defmodule SSHTunnel.Application do 2 | @moduledoc """ 3 | Application module 4 | """ 5 | use Application 6 | 7 | def start(_type, _args) do 8 | children = [ 9 | {DynamicSupervisor, name: SSHTunnel.TunnelSupervisor, strategy: :one_for_one} 10 | ] 11 | 12 | Supervisor.start_link(children, strategy: :one_for_one) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/ssh_tunnel/tunnel.ex: -------------------------------------------------------------------------------- 1 | defmodule SSHTunnel.Tunnel do 2 | @type to :: {:tcpip | :local, tuple()} 3 | 4 | @spec start(pid(), to, Keyword.t()) :: {:ok, pid()} | {:error, term()} 5 | def start(ref, to, opts \\ []) do 6 | worker_opts = 7 | ref 8 | |> worker_opts(to) 9 | |> Keyword.merge(opts) 10 | |> worker_spec() 11 | 12 | DynamicSupervisor.start_child(SSHTunnel.TunnelSupervisor, worker_opts) 13 | end 14 | 15 | @spec stop(pid()) :: :ok | :error 16 | def stop(pid) do 17 | DynamicSupervisor.terminate_child(SSHTunnel.TunnelSupervisor, pid) 18 | end 19 | 20 | defp worker_spec(opts) do 21 | name = Keyword.get(opts, :name, make_ref()) 22 | 23 | ranch_opts = 24 | case Keyword.get(opts, :target) do 25 | {:local, {path, _}} -> [ip: {:local, path}, port: 0] 26 | {:tcpip, {port, _}} -> [port: port] 27 | end 28 | 29 | :ranch.child_spec( 30 | name, 31 | 100, 32 | :ranch_tcp, 33 | ranch_opts, 34 | SSHTunnel.Tunnel.TCPHandler, 35 | opts 36 | ) 37 | end 38 | 39 | defp worker_opts(ref, target) do 40 | Keyword.new() 41 | |> Keyword.put(:ssh_ref, ref) 42 | |> Keyword.put(:target, target) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/ssh_tunnel/tunnel/tcp_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule SSHTunnel.Tunnel.TCPHandler do 2 | use GenServer 3 | require Logger 4 | 5 | def start_link(ref, socket, transport, opts) do 6 | pid = :proc_lib.spawn_link(__MODULE__, :init, [{ref, socket, transport, opts}]) 7 | {:ok, pid} 8 | end 9 | 10 | def init({ref, socket, transport, opts}) do 11 | clientname = stringify_clientname(socket) 12 | target = Keyword.get(opts, :target) 13 | ssh_ref = Keyword.get(opts, :ssh_ref) 14 | 15 | {:ok, channel} = ssh_forward(ssh_ref, target) 16 | :ok = :ranch.accept_ack(ref) 17 | :ok = transport.setopts(socket, [{:active, true}]) 18 | 19 | :gen_server.enter_loop(__MODULE__, [], %{ 20 | socket: socket, 21 | transport: transport, 22 | ssh_ref: ssh_ref, 23 | channel: channel, 24 | clientname: clientname 25 | }) 26 | end 27 | 28 | def handle_info( 29 | {:tcp, _, data}, 30 | %{ssh_ref: ssh, channel: channel} = state 31 | ) do 32 | :ok = :ssh_connection.send(ssh, channel, data) 33 | 34 | {:noreply, state} 35 | end 36 | 37 | def handle_info({:tcp_error, _, reason}, %{clientname: clientname} = state) do 38 | Logger.info(fn -> "Error #{clientname}: #{inspect(reason)}" end) 39 | {:stop, :normal, state} 40 | end 41 | 42 | def handle_info( 43 | {:tcp_closed, _}, 44 | %{clientname: clientname, channel: channel} = state 45 | ) do 46 | Logger.info(fn -> "Client #{clientname} disconnected channel #{channel}" end) 47 | 48 | {:stop, :normal, state} 49 | end 50 | 51 | def handle_info( 52 | {:ssh_cm, _, {:data, _, _, data}}, 53 | %{socket: socket, transport: transport} = state 54 | ) do 55 | :ok = transport.send(socket, data) 56 | {:noreply, state} 57 | end 58 | 59 | def handle_info({:ssh_cm, _, {:eof, _channel_id}}, state) do 60 | {:stop, :normal, state} 61 | end 62 | 63 | def terminate(reason, %{ssh_ref: ssh, channel: channel}) do 64 | :ok = :ssh_connection.close(ssh, channel) 65 | Logger.info("terminated reason #{inspect(reason)}") 66 | end 67 | 68 | defp ssh_forward(ref, {_, {_, {_, path}}}) when is_binary(path), 69 | do: SSHTunnel.stream_local_forward(ref, path) 70 | 71 | defp ssh_forward(ref, {_, {local_port, {_, port} = to}}) when is_number(port), 72 | do: SSHTunnel.direct_tcpip(ref, {"127.0.0.1", local_port}, to) 73 | 74 | defp stringify_clientname(socket) do 75 | {:ok, {addr, port}} = :inet.peername(socket) 76 | 77 | address = 78 | case addr do 79 | :local -> 80 | "UNIX-SOCKET://" 81 | 82 | addr -> 83 | addr 84 | |> :inet_parse.ntoa() 85 | |> to_string() 86 | end 87 | 88 | "#{address}:#{port}" 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SSHTunnel.MixProject do 2 | use Mix.Project 3 | 4 | @source "https://github.com/drowzy/ssh_tunnel" 5 | def project do 6 | [ 7 | app: :ssh_tunnel, 8 | version: "0.1.3", 9 | elixir: "~> 1.6", 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | name: "SSHTunnel", 13 | description: "Create SSH tunnels using Erlang's SSH application", 14 | source_url: @source, 15 | homepage_url: @source, 16 | docs: [main: "SSHTunnel"], 17 | package: package() 18 | ] 19 | end 20 | 21 | # Run "mix help compile.app" to learn about applications. 22 | def application do 23 | [ 24 | extra_applications: [:logger, :ssh], 25 | mod: {SSHTunnel.Application, []} 26 | ] 27 | end 28 | 29 | defp package() do 30 | [ 31 | maintainers: ["Simon Thörnqvist"], 32 | licenses: ["MIT"], 33 | links: %{"GitHub" => @source} 34 | ] 35 | end 36 | 37 | # Run "mix help deps" to learn about dependencies. 38 | defp deps do 39 | [ 40 | {:temp, "~> 0.4", only: :test}, 41 | {:ranch, "~> 1.4"}, 42 | {:dialyxir, "~> 0.5", only: :dev, runtime: false}, 43 | {:ex_doc, "~> 0.16", only: :dev, runtime: false} 44 | ] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, 3 | "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm"}, 4 | "ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "ranch": {:hex, :ranch, "1.4.0", "10272f95da79340fa7e8774ba7930b901713d272905d0012b06ca6d994f8826b", [:rebar3], [], "hexpm"}, 6 | "temp": {:hex, :temp, "0.4.4", "da4524a102db9431f96b2b244777017eb077df3db344957cd1d10705feb25337", [:mix], [], "hexpm"}, 7 | } 8 | -------------------------------------------------------------------------------- /test/ssh_tunnel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SSHTunnelTest do 2 | use ExUnit.Case 3 | doctest SSHTunnel 4 | 5 | test "greets the world" do 6 | assert true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------